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