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 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}