Skip to main content

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}