cordance_cli/lib.rs
1//! Cordance CLI — library entry.
2//!
3//! All dispatch logic for the `cordance` binary lives here. The companion
4//! `src/main.rs` is a thin shim that calls [`run`]. The umbrella package
5//! [`cordance`](https://crates.io/crates/cordance) also calls into `run` so
6//! `cargo install cordance` and `cargo install cordance-cli` install the
7//! same binary.
8//!
9//! ## Subcommands
10//! - `cordance init` — write `cordance.toml`.
11//! - `cordance scan` — emit a Markdown report at `.cordance/scan-report.md`.
12//! - `cordance pack` — compile the pack, emit every target.
13//! - `cordance advise` — deterministic doctrine checks (no LLM).
14//! - `cordance doctrine <topic>` — query engineering-doctrine.
15//! - `cordance cortex push` — emit a `cordance-cortex-receipt-v1-candidate.json`.
16//! - `cordance check` — drift detection vs `.cordance/sources.lock`.
17//! - `cordance explain <rule>` — provenance of a generated rule.
18//! - `cordance watch` — re-run pack on file changes.
19//! - `cordance serve` — start the JSON-RPC MCP server (stdio).
20//! - `cordance doctor` — pre-flight checks.
21//! - `cordance completions <shell>` — emit shell completion scripts.
22
23#![forbid(unsafe_code)]
24
25mod advise_cmd;
26mod check_cmd;
27mod completions_cmd;
28mod config;
29mod cortex_cmd;
30mod doctor_cmd;
31mod doctrine_cmd;
32mod explain_cmd;
33mod init_cmd;
34mod mcp;
35mod pack_cmd;
36mod scan_cmd;
37mod serve_cmd;
38mod watch_cmd;
39
40use std::process::ExitCode;
41
42use anyhow::Context;
43use camino::Utf8PathBuf;
44use clap::{CommandFactory, Parser, Subcommand};
45use cordance_core::pack::PackTargets;
46
47#[derive(Parser, Debug)]
48#[command(
49 name = "cordance",
50 version,
51 about = "Deterministic-first context-pack compiler.",
52 long_about = "Cordance compiles project doctrine, ADRs, schemas, and source layout \
53 into auditable AI-agent context packs. Doctrine-shaped. Cortex-aware. \
54 Axiom-harness-compatible. No LLM at v0."
55)]
56struct Cli {
57 /// Path to cordance.toml (default: ./cordance.toml).
58 #[arg(long, env = "CORDANCE_CONFIG")]
59 config: Option<String>,
60
61 /// Repo to operate on (default: current directory).
62 #[arg(long, default_value = ".")]
63 target: String,
64
65 /// Permit --target paths outside the current working directory.
66 ///
67 /// Default off: prevents accidental filesystem enumeration when an
68 /// untrusted argument resolves outside the invocation's CWD (e.g.
69 /// `--target /` or a relative path that climbs out via `..`). Pass
70 /// this flag explicitly when running cordance against a sibling
71 /// project, e.g. `cordance pack --target ../sibling
72 /// --allow-outside-cwd`. UNC paths (`\\server\share`) and Windows
73 /// extended-length prefixes (`\\?\`) are also gated by this flag.
74 #[arg(long, global = true, default_value_t = false)]
75 allow_outside_cwd: bool,
76
77 #[command(subcommand)]
78 cmd: Command,
79}
80
81#[derive(Subcommand, Debug)]
82enum Command {
83 /// Write a default cordance.toml.
84 Init,
85
86 /// Scan the repo and emit a JSON report.
87 Scan {
88 #[arg(long)]
89 json: bool,
90 },
91
92 /// Compile the pack and emit all selected targets.
93 Pack {
94 /// `write` (default), `dry-run`, or `diff`.
95 #[arg(long, default_value = "write")]
96 output_mode: String,
97
98 /// Comma-separated subset of targets, default = all.
99 #[arg(long)]
100 targets: Option<String>,
101
102 /// LLM provider for candidate prose generation.
103 /// Default: from cordance.toml `[llm].provider`, or "none".
104 /// Accepted values: `none`, `ollama`.
105 #[arg(long)]
106 llm: Option<String>,
107
108 /// Ollama model name (only used when `--llm ollama`).
109 #[arg(long, default_value = "qwen2.5-coder:14b")]
110 ollama_model: String,
111 },
112
113 /// Run deterministic doctrine checks.
114 Advise {
115 #[arg(long)]
116 json: bool,
117 },
118
119 /// Query engineering-doctrine for a topic.
120 Doctrine {
121 topic: String,
122 },
123
124 /// Cortex subcommands.
125 #[command(subcommand)]
126 Cortex(CortexCmd),
127
128 /// Validate the repo against the most recent pack's sources.lock.
129 Check,
130
131 /// Show the doctrine/ADR/schema sources behind a generated rule.
132 Explain {
133 rule: String,
134 },
135
136 /// Watch target for changes and re-run pack automatically.
137 Watch {
138 /// Debounce delay in milliseconds.
139 #[arg(long, default_value = "500")]
140 debounce_ms: u64,
141 },
142
143 /// Start a JSON-RPC MCP server (stdio) exposing context, advise, check.
144 Serve,
145
146 /// Emit shell completion scripts.
147 Completions {
148 /// Shell type: bash, zsh, fish, powershell.
149 shell: String,
150 },
151
152 /// Run pre-flight checks (axiom, doctrine, Ollama reachability).
153 Doctor,
154}
155
156#[derive(Subcommand, Debug)]
157enum CortexCmd {
158 /// Emit a cordance-cortex-receipt-v1-candidate JSON ready for Cortex acceptance.
159 Push {
160 #[arg(long)]
161 dry_run: bool,
162 },
163}
164
165/// Run the cordance CLI: parse argv, install tracing, dispatch the subcommand,
166/// and return a process [`ExitCode`].
167///
168/// Both `cordance-cli`'s thin `main.rs` and the [`cordance`](https://crates.io/crates/cordance)
169/// umbrella package's `main.rs` call this directly. Embedding `cordance` as a
170/// library (e.g. inside a test harness or a higher-level CLI) is also
171/// supported — call `cordance_cli::run()` from your own `main()`.
172#[must_use]
173pub fn run() -> ExitCode {
174 let cli = Cli::parse();
175
176 // MCP stdout is reserved for JSON-RPC frames. `serve` installs its own
177 // stderr-only subscriber inside `serve_cmd::run`; all other subcommands
178 // get the normal tracing fmt subscriber (which targets stdout by default
179 // but is acceptable because they are not speaking a wire protocol).
180 if !matches!(cli.cmd, Command::Serve) {
181 let _ = tracing_subscriber::fmt()
182 .with_env_filter(
183 tracing_subscriber::EnvFilter::try_from_env("CORDANCE_LOG")
184 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
185 )
186 .try_init();
187 }
188
189 match dispatch(&cli) {
190 Ok(()) => ExitCode::SUCCESS,
191 Err(e) => {
192 // Print the full error chain so the operator sees both the
193 // context (e.g. "loading cordance.toml at ...") and the
194 // underlying cause (e.g. "toml parse error in ...: ...").
195 // Without this, a `with_context()` wrapper hides the root
196 // cause behind its summary and the user is told "something
197 // went wrong" with no actionable path.
198 eprint!("cordance: {e}");
199 for cause in e.chain().skip(1) {
200 eprint!("\n caused by: {cause}");
201 }
202 eprintln!();
203 ExitCode::from(1)
204 }
205 }
206}
207
208fn dispatch(cli: &Cli) -> anyhow::Result<()> {
209 let target = validate_cli_target(&cli.target, cli.allow_outside_cwd)?;
210 // ADR `single-source-of-truth.md` + round-2 bughunt #3: a malformed
211 // `cordance.toml` must fail loudly with the parse error path printed,
212 // not silently downgrade to defaults.
213 let cfg = config::Config::load_strict(&target)
214 .with_context(|| format!("loading cordance.toml at {target}"))?;
215
216 match &cli.cmd {
217 Command::Init => {
218 init_cmd::run(&target)?;
219 }
220
221 Command::Scan { json } => {
222 scan_cmd::run(&target, *json)?;
223 }
224
225 Command::Pack {
226 output_mode,
227 targets,
228 llm,
229 ollama_model,
230 } => {
231 let selected_targets = PackTargets::from_csv(targets.as_deref())
232 .with_context(|| "parsing --targets")?;
233 let pack_config = pack_cmd::PackConfig {
234 target,
235 output_mode: pack_cmd::OutputMode::from_str(output_mode),
236 selected_targets,
237 doctrine_root: None,
238 llm_provider: llm.clone(),
239 ollama_model: Some(ollama_model.clone()),
240 quiet: false,
241 };
242 let pack = pack_cmd::run(&pack_config)?;
243 let counts = pack.outputs.len();
244 println!("cordance pack: {counts} outputs written");
245 }
246
247 Command::Advise { json } => {
248 let pack_config = pack_cmd::PackConfig {
249 target,
250 output_mode: pack_cmd::OutputMode::DryRun,
251 selected_targets: PackTargets::default(),
252 doctrine_root: None,
253 llm_provider: None,
254 ollama_model: None,
255 quiet: false,
256 };
257 let pack = pack_cmd::run(&pack_config)?;
258 advise_cmd::run(&pack, *json)?;
259 }
260
261 Command::Doctrine { topic } => {
262 doctrine_cmd::run(topic, &cfg.doctrine_root(&target))?;
263 }
264
265 Command::Cortex(CortexCmd::Push { dry_run }) => {
266 let pack_config = pack_cmd::PackConfig {
267 target: target.clone(),
268 output_mode: pack_cmd::OutputMode::DryRun,
269 quiet: false,
270 selected_targets: PackTargets {
271 cortex_receipt: true,
272 ..Default::default()
273 },
274 doctrine_root: None,
275 llm_provider: None,
276 ollama_model: None,
277 };
278 let pack = pack_cmd::run(&pack_config)?;
279 cortex_cmd::run_push(&pack, &target, *dry_run)?;
280 }
281
282 Command::Check => {
283 let code = check_cmd::run(&target)?;
284 std::process::exit(code);
285 }
286
287 Command::Watch { debounce_ms } => {
288 watch_cmd::run(&target, *debounce_ms)?;
289 }
290
291 Command::Serve => {
292 serve_cmd::run(&cfg, &target)?;
293 }
294
295 Command::Completions { shell } => {
296 completions_cmd::run(shell, Cli::command())?;
297 }
298
299 Command::Explain { rule } => {
300 explain_cmd::run(rule, &target)?;
301 }
302
303 Command::Doctor => {
304 doctor_cmd::run(&cfg, &target)?;
305 }
306 }
307
308 Ok(())
309}
310
311// `parse_targets` lived here through round 3 as a hand-rolled substring
312// scanner that matched `cursor` inside `no-cursor` and `supercursor`. Round-4
313// codereview #4 / bughunt #9 consolidated the logic into
314// `cordance_core::pack::PackTargets::from_csv`, which exact-matches each
315// trimmed token and returns a typed `ParseTargetsError::UnknownTarget` for
316// anything else. Both the CLI dispatcher above and `mcp::tools::pack` route
317// through it now, eliminating the divergence between the two parsers.
318
319/// Validate a CLI `--target` argument before any subcommand runs.
320///
321/// This is the CLI-side counterpart to [`mcp::validation::validate_target`]:
322/// every untrusted target string is canonicalised and must resolve under the
323/// invocation's current working directory unless the caller explicitly opted
324/// out with `--allow-outside-cwd`.
325///
326/// Non-existent paths are permitted as long as the deepest existing ancestor
327/// canonicalises into CWD — this is what makes `cordance init --target ./new`
328/// usable while still rejecting `--target /etc/passwd/foo`.
329///
330/// UNC paths (`\\server\share`) and Windows extended-length prefixes (`\\?\`)
331/// are rejected without `--allow-outside-cwd`, because they cannot meaningfully
332/// be "inside" a posix-ish CWD and are a common bypass shape.
333fn validate_cli_target(target: &str, allow_outside: bool) -> anyhow::Result<Utf8PathBuf> {
334 if target.is_empty() {
335 anyhow::bail!("target is empty");
336 }
337 if target.as_bytes().contains(&0) {
338 anyhow::bail!("target contains NUL byte");
339 }
340
341 if !allow_outside {
342 // `\\?\` and `\\server\share` always start with two backslashes; reject
343 // them up-front before we even try to canonicalise. This keeps the
344 // "must live under CWD" promise simple — a UNC path has no meaningful
345 // ancestor relationship with a local CWD.
346 if target.starts_with("\\\\") || target.starts_with("//") {
347 anyhow::bail!(
348 "UNC and extended-length paths are not permitted without --allow-outside-cwd"
349 );
350 }
351 }
352
353 let raw = Utf8PathBuf::from(target);
354 let cwd = std::env::current_dir().context("cannot resolve current directory")?;
355 let cwd_canonical = dunce::canonicalize(&cwd).context("cannot canonicalise cwd")?;
356
357 // Resolve target absolutely. For non-existent paths, canonicalise the
358 // deepest existing ancestor and re-attach the trailing components — this
359 // mirrors how `validate_target` handles `.` in the MCP layer but is more
360 // permissive: the CLI is allowed to point at a path that does not yet
361 // exist (e.g. `cordance init --target ./new-repo`).
362 let abs_path: std::path::PathBuf = if raw.is_absolute() {
363 raw.as_std_path().to_path_buf()
364 } else {
365 cwd.join(raw.as_std_path())
366 };
367
368 // Round-2 bughunt #1 + round-3 bughunt #1: a *dangling* symlink (its
369 // target does not exist) never enters the `abs_path.exists()` branch,
370 // and the ancestor walk below silently follows it via component-by-
371 // component pop. To plug that bypass we explicitly walk every existing
372 // component of the requested path and reject any symlink whose resolved
373 // target escapes CWD. The round-3 refinement: relative symlink targets
374 // containing `..` segments must be normalised before the prefix check,
375 // because `Path::starts_with` is syntactic and would otherwise accept
376 // a target like `<cwd>/../../etc/passwd`. Windows directory junctions
377 // are also reparse points and are now caught alongside symlinks.
378 if !allow_outside {
379 reject_symlinks_pointing_outside(&abs_path, &cwd_canonical)?;
380 }
381
382 let canonical = if abs_path.exists() {
383 dunce::canonicalize(&abs_path)
384 .with_context(|| format!("cannot canonicalise {target}"))?
385 } else {
386 let mut probe = abs_path.clone();
387 while !probe.exists() {
388 match probe.parent() {
389 Some(p) => probe = p.to_path_buf(),
390 None => anyhow::bail!("target path has no existing ancestor: {target}"),
391 }
392 }
393 let canon_ancestor = dunce::canonicalize(&probe)
394 .with_context(|| format!("cannot canonicalise ancestor of {target}"))?;
395 let suffix = abs_path.strip_prefix(&probe).unwrap_or(&abs_path);
396 canon_ancestor.join(suffix)
397 };
398
399 if !allow_outside && !canonical.starts_with(&cwd_canonical) {
400 anyhow::bail!(
401 "target is outside the current working directory; pass --allow-outside-cwd \
402 to permit it explicitly"
403 );
404 }
405
406 Utf8PathBuf::try_from(canonical)
407 .map_err(|_| anyhow::anyhow!("target path is not valid UTF-8"))
408}
409
410/// Walk every existing ancestor of `path` and reject any reparse point
411/// (POSIX symlink, Windows symlink, OR Windows directory junction) whose
412/// resolved target points outside `cwd_canonical`. Catches dangling symlinks
413/// (whose target does not exist) and reparse points embedded as intermediate
414/// components, which `dunce::canonicalize` would otherwise quietly follow or
415/// preserve.
416///
417/// Round-3 bughunt #1 (relative-symlink escape):
418/// `Path::starts_with` is a syntactic prefix check. A relative symlink target
419/// of `../../../etc/passwd` joined onto the link's parent yields a path that
420/// *textually* contains the parent directory as its prefix — so a naive
421/// `starts_with` says "yes, inside cwd" even though `..` segments make the
422/// effective path escape. We MUST normalise `..` segments before the prefix
423/// check, even for paths that do not exist on disk (dangling targets).
424///
425/// Round-3 bughunt (cross-platform): Windows directory junctions are reparse
426/// points that `std::fs::FileType::is_symlink` returns `false` for, but they
427/// behave like symlinks for the purpose of jumping out of the cwd subtree.
428/// [`is_reparse_or_symlink`] also checks the Windows reparse-point file
429/// attribute so junctions are caught.
430///
431/// "Resolved target" semantics:
432/// * Absolute link targets are checked verbatim.
433/// * Relative link targets are joined onto the symlink's parent and then
434/// syntactically normalised (`..` pops, `.` is ignored).
435/// * If the resolved target itself exists, it is canonicalised (so a chain
436/// of symlinks that ultimately escapes is still caught).
437/// * Windows junctions: we can't read the target without unsafe Win32 APIs,
438/// so we conservatively reject every reparse point we can't classify
439/// without `--allow-outside-cwd`.
440fn reject_symlinks_pointing_outside(
441 path: &std::path::Path,
442 cwd_canonical: &std::path::Path,
443) -> anyhow::Result<()> {
444 let mut probe: std::path::PathBuf = path.to_path_buf();
445 loop {
446 if is_reparse_or_symlink(&probe) {
447 // POSIX symlinks have a target we can read via `read_link`.
448 // Windows junctions also respond to `read_link` on modern
449 // Rust (>=1.83), but the target is an unparsed UNC-prefixed
450 // path; treat read failures conservatively as "can't prove
451 // it's inside cwd" and reject.
452 match std::fs::read_link(&probe) {
453 Ok(link_target) => {
454 let resolved = if link_target.is_absolute() {
455 link_target.clone()
456 } else {
457 probe
458 .parent()
459 .map_or_else(|| link_target.clone(), |p| p.join(&link_target))
460 };
461 // Critical: normalise `..` segments BEFORE canonicalising
462 // or starts_with-checking. For an existing target we
463 // canonicalise (which also resolves `..`); for a dangling
464 // target the manual normaliser below is the only thing
465 // that pops `..` so we don't trust a syntactic prefix.
466 let resolved_canonical = if resolved.exists() {
467 dunce::canonicalize(&resolved).with_context(|| {
468 format!(
469 "cannot canonicalise symlink target {}",
470 resolved.display()
471 )
472 })?
473 } else {
474 normalise_path_segments(&resolved)
475 };
476 if !resolved_canonical.starts_with(cwd_canonical) {
477 anyhow::bail!(
478 "target path contains a symlink pointing outside cwd; \
479 pass --allow-outside-cwd to permit"
480 );
481 }
482 }
483 Err(_) => {
484 // A reparse point whose target we can't read. We don't
485 // know if it's safe; refuse it unless the operator
486 // explicitly opts in via --allow-outside-cwd. Windows
487 // directory junctions can land here on older toolchains
488 // and on edge-case reparse tags.
489 anyhow::bail!(
490 "target path contains a reparse point whose target cannot be \
491 resolved; pass --allow-outside-cwd to permit"
492 );
493 }
494 }
495 }
496 if !probe.pop() {
497 break;
498 }
499 }
500 Ok(())
501}
502
503/// Syntactically normalise a path: pop a component for every `..`, ignore
504/// every `.`. Used on dangling symlink targets where neither `exists()` nor
505/// `canonicalize()` will resolve `..` for us.
506///
507/// This is intentionally *not* equivalent to `canonicalize`: it does not
508/// resolve symlinks, does not access the filesystem, and does not detect
509/// pops past the root. For our use case (proving the *parent path* of a
510/// dangling link target either lives inside cwd or doesn't), syntactic
511/// normalisation is enough — a path that pops past root will land at an
512/// empty `PathBuf`, which won't `starts_with(cwd_canonical)` and is rightly
513/// rejected.
514fn normalise_path_segments(p: &std::path::Path) -> std::path::PathBuf {
515 let mut out = std::path::PathBuf::new();
516 for component in p.components() {
517 match component {
518 std::path::Component::ParentDir => {
519 out.pop();
520 }
521 std::path::Component::CurDir => {}
522 other => out.push(other),
523 }
524 }
525 out
526}
527
528/// Return `true` when `path` is a POSIX symlink OR a Windows reparse point
529/// (symlink-file, symlink-dir, or directory junction).
530///
531/// `std::fs::FileType::is_symlink()` returns `false` for Windows directory
532/// junctions even though they short-circuit the filesystem in the same way a
533/// symlink does. On Windows we additionally read the file attributes and check
534/// for `FILE_ATTRIBUTE_REPARSE_POINT` (0x400) so junctions are caught.
535fn is_reparse_or_symlink(path: &std::path::Path) -> bool {
536 let Ok(meta) = std::fs::symlink_metadata(path) else {
537 return false;
538 };
539 if meta.file_type().is_symlink() {
540 return true;
541 }
542 #[cfg(windows)]
543 {
544 use std::os::windows::fs::MetadataExt;
545 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
546 if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
547 return true;
548 }
549 }
550 false
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use clap::CommandFactory;
557
558 #[test]
559 fn cli_parses() {
560 Cli::command().debug_assert();
561 }
562
563 #[test]
564 fn validate_cli_target_accepts_dot() {
565 let resolved = validate_cli_target(".", false).expect("cwd should be allowed");
566 let cwd = std::env::current_dir().expect("cwd");
567 let cwd_canon = dunce::canonicalize(&cwd).expect("canon cwd");
568 assert!(resolved.as_std_path().starts_with(&cwd_canon));
569 }
570
571 #[test]
572 fn validate_cli_target_rejects_empty() {
573 let err = validate_cli_target("", false).expect_err("empty must error");
574 assert!(err.to_string().contains("empty"));
575 }
576
577 #[test]
578 fn validate_cli_target_rejects_nul() {
579 let err = validate_cli_target("foo\0bar", false).expect_err("NUL must error");
580 assert!(err.to_string().contains("NUL"));
581 }
582
583 #[test]
584 fn validate_cli_target_rejects_unc_without_flag() {
585 let err = validate_cli_target("\\\\server\\share", false)
586 .expect_err("UNC must error without --allow-outside-cwd");
587 assert!(err.to_string().contains("UNC"));
588 }
589
590 /// Round-2 bughunt #1: a dangling symlink whose target lives outside
591 /// CWD must be rejected up-front. Before this fix, `validate_cli_target`
592 /// happily climbed the ancestor chain (the link target did not exist,
593 /// so it never tripped the canonical-prefix check) and `cordance init`
594 /// would write through the link at I/O time.
595 #[test]
596 #[cfg(unix)]
597 fn cli_rejects_dangling_symlink_pointing_outside_cwd() {
598 let dir = tempfile::tempdir().expect("tempdir");
599 let link_path = dir.path().join("evil-link");
600 std::os::unix::fs::symlink("/etc/passwd-nonexistent", &link_path)
601 .expect("symlink");
602 // Run with cwd set to `dir` so the symlink lives inside cwd but its
603 // target does not. `validate_cli_target` resolves relative paths
604 // against the process cwd, so we pass an absolute path to keep the
605 // test independent of where the test runner was launched from.
606 let result = validate_cli_target(
607 link_path.to_str().expect("symlink path is utf8"),
608 false,
609 );
610 assert!(
611 result.is_err(),
612 "dangling symlink to /etc should be rejected, got {result:?}"
613 );
614 let msg = format!("{:?}", result.err().unwrap_or_else(|| anyhow::anyhow!("?")));
615 assert!(
616 msg.contains("symlink") || msg.contains("outside"),
617 "expected symlink/outside-cwd error, got {msg}"
618 );
619 }
620
621 /// Round-3 bughunt #1: a relative symlink target that escapes cwd via
622 /// `..` segments must be rejected. The earlier guard relied on
623 /// `Path::starts_with`, which is a textual-prefix check and was happy
624 /// to accept `<cwd>/<dir>/../<dir>/../../etc/passwd` since the leading
625 /// substring `<cwd>` did show up in the joined path. The fix
626 /// (normalise_path_segments) pops `..` segments before the prefix
627 /// check so the resolved target is treated semantically.
628 #[test]
629 #[cfg(unix)]
630 fn cli_rejects_relative_symlink_escaping_cwd_via_dotdot() {
631 let dir = tempfile::tempdir().expect("tempdir");
632 let link_path = dir.path().join("trap");
633 // The target is RELATIVE (no leading slash) and contains enough
634 // `..` segments to pop past the temp dir and any plausible cwd.
635 std::os::unix::fs::symlink(
636 "../../../../../../../../../../../etc/passwd-nonexistent",
637 &link_path,
638 )
639 .expect("symlink");
640 let result = validate_cli_target(
641 link_path.to_str().expect("symlink path is utf8"),
642 false,
643 );
644 assert!(
645 result.is_err(),
646 "relative symlink with .. escape must be rejected, got {result:?}"
647 );
648 let msg = format!("{:?}", result.err().unwrap_or_else(|| anyhow::anyhow!("?")));
649 assert!(
650 msg.contains("symlink") || msg.contains("outside"),
651 "expected symlink/outside-cwd error, got {msg}"
652 );
653 }
654
655 /// Unit test for the path-normalisation helper. Documents the contract:
656 /// - `..` segments pop a component from the in-progress output.
657 /// - `.` segments are dropped.
658 /// - All other segments (root, prefix, normal) are preserved.
659 ///
660 /// Without this normaliser, `Path::starts_with` would accept paths whose
661 /// `..` segments would otherwise climb past the cwd prefix.
662 #[test]
663 fn normalise_path_segments_pops_dotdot() {
664 let normalised = normalise_path_segments(std::path::Path::new(
665 "/some/base/../../etc/passwd",
666 ));
667 // After two `..` pops from /some/base we have /, then /etc, /etc/passwd.
668 assert_eq!(normalised, std::path::PathBuf::from("/etc/passwd"));
669 }
670
671 #[test]
672 fn normalise_path_segments_drops_curdir() {
673 let normalised = normalise_path_segments(std::path::Path::new(
674 "/some/./base/./file",
675 ));
676 assert_eq!(normalised, std::path::PathBuf::from("/some/base/file"));
677 }
678
679 #[test]
680 fn normalise_path_segments_handles_relative() {
681 let normalised = normalise_path_segments(std::path::Path::new(
682 "foo/bar/../baz",
683 ));
684 assert_eq!(normalised, std::path::PathBuf::from("foo/baz"));
685 }
686}