From c11a5706dc07f2f442957d3b6957360518e32a41 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sat, 3 Oct 2026 18:17:23 +0900 Subject: [PATCH] The relative-link check skips inline code spans, so a regular expression in backticks such as [2-9](\.\d+) is no longer reported as a missing file CommonMark gives a code span precedence over link syntax. The scanner skipped fenced blocks only, so a generated page printing a pattern in backticks was refused, and the only remedy was to exclude the file and lose its real links from the check. Each line now has its code spans removed before link extraction, delimited as CommonMark does: a run of N backticks closes at the next run of exactly N, and an unmatched run stays literal. Closes #287 --- docs/REFERENCE.md | 2 +- src/scan.rs | 42 +++++++++++++++++++++++++++++++++++++++--- tests/scan_cli.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 6a28949..1560653 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -506,7 +506,7 @@ it refuses** so the name predicts the rule list: | `credentials` | credential material a commit scanner does not own — populated environment files, browser profile and session stores. Secret shapes (private keys, service tokens, literal credential values) are gitleaks' job: inheriting it also turns on the gitleaks section of [`uphold supply-chain`](#gitleaks-which-owns-secret-shapes), the tool that owns secret shapes | | `unmanaged-pins` | a version pinned where no manifest holds it — a shell install line, a `releases/download/vX.Y.Z` URL, a versioned `curl` or `wget` | | `host-identity` | the machine the author is standing on — its username, home path, hostname and default route, read at scan time and searched for in content | -| `broken-links` | a markdown link naming a path that does not exist or leaving the repository, and a selection that yields no links at all | +| `broken-links` | a markdown link naming a path that does not exist or leaving the repository, and a selection that yields no links at all. Text inside fenced blocks and inline code spans (any backtick-run length) is literal and not read as a link | | `captured-fixtures` | a test fixture holding non-ASCII content, as the one signal that a capture from a live upstream survives redaction | | `doc-claims` | a document whose anchored fact disagrees with the record it names — a value the record does not hold, a key that is not there, a source or captured artifact that is absent | | `default-token-grant` | a GitHub Actions workflow with no top-level `permissions:` block, whose `GITHUB_TOKEN` is therefore scoped by a repository setting rather than by the workflow | diff --git a/src/scan.rs b/src/scan.rs index b360a63..3b5d34a 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1803,9 +1803,10 @@ fn cfg_test_lines(path: &Path) -> BTreeSet { /// `(line_number, target)` for every resolvable link in a Markdown document. /// -/// Skips fenced code blocks, external schemes, and pure fragments. A link inside -/// a fence is an illustration rather than a reference, and resolving one would -/// fail on every README that documents a link. +/// Skips fenced code blocks, inline code spans, external schemes, and pure +/// fragments. A link inside a fence or a code span is an illustration rather +/// than a reference, and resolving one would fail on every README that +/// documents a link. fn link_targets(text: &str) -> Vec<(u64, String)> { static INLINE: OnceLock = OnceLock::new(); static REFERENCE: OnceLock = OnceLock::new(); @@ -1850,6 +1851,8 @@ fn link_targets(text: &str) -> Vec<(u64, String)> { continue; } + let line = without_code_spans(line); + let line = line.as_str(); let mut targets: Vec = Vec::new(); if let Some(captures) = reference.captures(line) { targets.push(capture_target(&captures)); @@ -1870,6 +1873,39 @@ fn link_targets(text: &str) -> Vec<(u64, String)> { found } +/// `line` with its inline code spans removed, as `CommonMark` delimits them: a +/// run of N backticks opens a span that the next run of exactly N closes. A run +/// with no matching closer is literal text and stays. +fn without_code_spans(line: &str) -> String { + let run_at = |from: usize| line[from..].len() - line[from..].trim_start_matches('`').len(); + let mut kept = String::with_capacity(line.len()); + let mut rest = 0; + while let Some(offset) = line[rest..].find('`') { + let open = rest + offset; + let width = run_at(open); + let mut search = open + width; + let mut close = None; + while let Some(found) = line[search..].find('`') { + let start = search + found; + let run = run_at(start); + if run == width { + close = Some(start + run); + break; + } + search = start + run; + } + if let Some(end) = close { + kept.push_str(&line[rest..open]); + rest = end; + } else { + kept.push_str(&line[rest..open + width]); + rest = open + width; + } + } + kept.push_str(&line[rest..]); + kept +} + fn capture_target(captures: ®ex::Captures<'_>) -> String { captures .name("angled") diff --git a/tests/scan_cli.rs b/tests/scan_cli.rs index df22cca..020d79c 100644 --- a/tests/scan_cli.rs +++ b/tests/scan_cli.rs @@ -234,6 +234,37 @@ fn a_link_rule_separates_a_missing_target_from_one_outside_the_repository() { assert!(!text.contains("here.md ->"), "{text}"); } +#[test] +fn a_link_rule_does_not_read_inside_an_inline_code_span() { + let root = workspace(); + write( + &root, + "policy/principles.toml", + r#" + [rule.links-resolve] + builtin = "links-resolve" + message = "fix the link" + require_any_link = false + + [rule.links-resolve.files] + glob = ["*.md"] +"#, + ); + write( + &root, + "README.md", + "pattern `[2-9](\\.\\d+)` and ``[a](`gone.md`)`` and ```x``[b](gone2.md)```\n", + ); + let quoted = scan(&root); + assert_eq!(code("ed), 0, "{}", stderr("ed)); + + write(&root, "README.md", "pattern [2-9](\\.\\d+) unquoted\n"); + let output = scan(&root); + assert_eq!(code(&output), 1); + let text = stderr(&output); + assert!(text.contains("\\.\\d+ -> no such file"), "{text}"); +} + #[test] fn a_script_rule_admits_a_script_only_under_the_path_it_names() { let root = workspace();