From 9fa7bca1a32649cbed38fddbb7bc1feb5b903a17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:59:05 +0000 Subject: [PATCH 1/2] Start refactor for #781 Assisted-by: Claude Code:claude-opus-5-5 From b4865324f13ee8f338b5b5c6abbd2f3b3b573f0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:15:19 +0000 Subject: [PATCH 2/2] Read go.mod module through go_mod_edit VEX --product auto-detection read the go.mod module directive with its own line scanner, which misread Go's block form `module ( example.com/blk )` as `pkg:golang/(`. The crawler kept a third reader, parse_go_mod_module, that nothing in production called. go_mod_edit::module_path now reads the directive through the same directive walker as require and replace (single-line and block forms, quoted tokens, BOM, trailing comments). product.rs keeps only the purl formatting, and parse_go_mod_module and its tests are deleted; its cases move to module_path's unit tests. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/go_crawler.rs | 116 ------------------ .../src/vendor/go_mod_edit.rs | 63 ++++++++++ crates/socket-patch-core/src/vex/product.rs | 41 ++++--- .../socket-patch-core/tests/crawler_go_e2e.rs | 48 +------- 4 files changed, 87 insertions(+), 181 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index fd1aaa66d..7a11ac8d4 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -57,54 +57,6 @@ pub fn decode_module_path(encoded: &str) -> String { decoded } -/// Parse the `module` directive from a go.mod file. -/// -/// Returns the module path, e.g., `"github.com/gin-gonic/gin"`. -pub fn parse_go_mod_module(content: &str) -> Option { - for line in content.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("module") { - // `module` must be a whole token: the directive is followed by - // whitespace. Without this guard, lines like `modulepath = x` - // would be misparsed as a module declaration. - if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { - continue; - } - // Strip a trailing line comment (`module foo // note`). Module - // paths never contain `//`, so the first occurrence is the comment. - let rest = match rest.find("//") { - Some(idx) => &rest[..idx], - None => rest, - }; - let rest = rest.trim(); - // Handle quoted module paths - if rest.len() >= 2 && rest.starts_with('"') && rest.ends_with('"') { - let inner = &rest[1..rest.len() - 1]; - // A quoted-but-empty path (`module ""`) is malformed: Go - // module paths are never empty. Treat it as absent rather - // than returning `Some("")`, which would later build a - // bogus PURL like `pkg:golang/@`. A go.mod has at - // most one `module` directive, so skipping here falls - // through to `None`. - if inner.is_empty() { - continue; - } - return Some(inner.to_string()); - } - // Unquoted module path. The `module` directive takes a SINGLE - // token, so a line like `module foo bar` is malformed (Go rejects - // it outright). Return only the first whitespace-delimited token - // rather than the whole remainder (`"foo bar"`), which would build - // a bogus PURL with a space in the module path and break the later - // `split_module_path` namespace/name split. - if let Some(token) = rest.split_whitespace().next() { - return Some(token.to_string()); - } - } - } - None -} - // --------------------------------------------------------------------------- // GoCrawler // --------------------------------------------------------------------------- @@ -531,57 +483,6 @@ mod tests { ); } - #[test] - fn test_parse_go_mod_module_basic() { - let content = "module github.com/gin-gonic/gin\n\ngo 1.21\n"; - assert_eq!( - parse_go_mod_module(content), - Some("github.com/gin-gonic/gin".to_string()) - ); - } - - #[test] - fn test_parse_go_mod_module_quoted() { - let content = "module \"github.com/gin-gonic/gin\"\n\ngo 1.21\n"; - assert_eq!( - parse_go_mod_module(content), - Some("github.com/gin-gonic/gin".to_string()) - ); - } - - #[test] - fn test_parse_go_mod_module_missing() { - let content = "go 1.21\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n)\n"; - assert_eq!(parse_go_mod_module(content), None); - } - - #[test] - fn test_parse_go_mod_module_empty_quoted_path() { - // A quoted-but-empty module path is malformed and must not yield - // `Some("")` (which would later build a bogus `pkg:golang/@...` - // PURL). Mirrors the bare-`module` empty-path regression test. - assert_eq!(parse_go_mod_module("module \"\"\n\ngo 1.21\n"), None); - // Whitespace-padded variant is equally malformed. - assert_eq!(parse_go_mod_module(" module \"\" \n"), None); - } - - #[test] - fn test_parse_go_mod_module_multi_token_unquoted() { - // `module` takes a single token; a multi-token unquoted line is - // malformed. We must not return the whole remainder (`"foo bar"`), - // which would build a bogus PURL with an embedded space. Take the - // first token only. - assert_eq!( - parse_go_mod_module("module github.com/foo/bar extra junk\ngo 1.21\n"), - Some("github.com/foo/bar".to_string()) - ); - // Trailing whitespace alone must not be treated as a second token. - assert_eq!( - parse_go_mod_module("module github.com/foo/bar \n"), - Some("github.com/foo/bar".to_string()) - ); - } - #[test] fn test_decode_module_path_lone_trailing_bang_preserved() { // A lone trailing `!` is not a valid Go escape. Decoding must not @@ -814,23 +715,6 @@ mod tests { // -- Regression tests ------------------------------------------------- - #[test] - fn test_parse_go_mod_module_trailing_comment() { - // A trailing line comment must not leak into the module path. - let content = "module github.com/gin-gonic/gin // indirect note\n\ngo 1.21\n"; - assert_eq!( - parse_go_mod_module(content), - Some("github.com/gin-gonic/gin".to_string()) - ); - } - - #[test] - fn test_parse_go_mod_module_word_boundary() { - // `module` must be a whole token; `modulepath` is not the directive. - let content = "modulepath github.com/should/not/match\ngo 1.21\n"; - assert_eq!(parse_go_mod_module(content), None); - } - #[tokio::test] async fn test_crawl_finds_module_with_cache_path_component() { // The `cache` skip must only apply at the cache root, not to a diff --git a/crates/socket-patch-core/src/vendor/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs index 8f7ecc71f..1a9e9114f 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -383,6 +383,28 @@ pub fn parse_required_versions(content: &str) -> HashMap { out } +/// The module path the go.mod `module` directive declares, in its +/// single-line (`module example.com/m`) or block (`module ( … )`) form, +/// quoted or bare, with a trailing `//` comment and a leading BOM allowed. +/// `None` when the directive is missing, takes other than exactly one +/// token (`module foo bar`), or names an empty, still-quoted (`module ""`) +/// or unsafe path. +pub fn module_path(text: &str) -> Option { + let text = normalize_for_read(text); + let mut first = None; + let _ = for_each_directive_body(&text, "module", |_, body| { + first.get_or_insert_with(|| body.to_string()); + Ok(()) + }); + let body = first?; + let mut toks = body.split_whitespace(); + let module = toks.next()?; + let plausible = toks.next().is_none() + && !module.contains(['"', '`']) + && crate::patch::path_safety::is_safe_multi_segment(module); + plausible.then(|| module.to_string()) +} + /// A go.mod-grammar file (go.mod / go.work) as the go command tokenizes it, /// for the read-only parsers: a leading UTF-8 BOM dropped and every Go /// string literal whose contents need no unescaping (no `\`, no whitespace @@ -926,6 +948,47 @@ mod tests { } // ── parse ──────────────────────────────────────────────────────── + #[test] + fn module_path_reads_every_directive_form() { + let m = |t: &str| module_path(t); + let gin = Some("github.com/gin-gonic/gin".to_string()); + assert_eq!(m("module github.com/gin-gonic/gin\n\ngo 1.21\n"), gin); + assert_eq!(m("module \"github.com/gin-gonic/gin\"\n"), gin); + assert_eq!(m("module `github.com/gin-gonic/gin`\n"), gin); + assert_eq!(m("module\tgithub.com/gin-gonic/gin\n"), gin); + assert_eq!(m("module github.com/gin-gonic/gin // note\n"), gin); + assert_eq!(m("module github.com/gin-gonic/gin \n"), gin); + assert_eq!(m("\u{feff}module github.com/gin-gonic/gin\r\n"), gin); + assert_eq!(m("// header\n\nmodule github.com/gin-gonic/gin\n"), gin); + assert_eq!( + m("module (\n\tgithub.com/gin-gonic/gin\n)\n\ngo 1.21\n"), + gin + ); + assert_eq!(m("module (\n\t\"github.com/gin-gonic/gin\" // c\n)\n"), gin); + } + + #[test] + fn module_path_rejects_malformed_directives() { + for text in [ + "", + "go 1.21\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n)\n", + "module\n", + "module \"\"\n\ngo 1.21\n", + " module \"\" \n", + "module \"foo bar\"\n", + "module github.com/foo/bar extra junk\n", + "modulepath github.com/should/not/match\n", + "modulepath = x\n", + "module ()\n", + "module (\n)\n", + "module ../x\n", + "module /abs/path\n", + "module C:/x\n", + ] { + assert_eq!(module_path(text), None, "{text:?}"); + } + } + #[test] fn test_parse_single_and_block() { let gomod = "\ diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 13a58f2e8..3273b6e6e 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -169,25 +169,12 @@ fn parse_cargo_toml(content: &str) -> Option { Some(format!("pkg:cargo/{name}@{version}")) } -/// `go.mod` → `pkg:golang/` from the `module` directive (quoted or -/// bare, trailing `//` comment allowed). go.mod records no version, so the -/// purl carries none. +/// `go.mod` → `pkg:golang/` from the `module` directive, read by +/// [`go_mod_edit::module_path`](crate::vendor::go_mod_edit::module_path). +/// go.mod records no version, so the purl carries none. fn parse_go_mod(content: &str) -> Option { - for raw in strip_bom(content).lines() { - let line = raw.split("//").next().unwrap_or("").trim(); - let Some(rest) = line.strip_prefix("module") else { - continue; - }; - if !rest.starts_with([' ', '\t']) { - continue; - } - let module = rest.trim().trim_matches('"'); - let plausible = !module.is_empty() - && !module.contains(char::is_whitespace) - && crate::patch::path_safety::is_safe_multi_segment(module); - return plausible.then(|| format!("pkg:golang/{module}")); - } - None + let module = crate::vendor::go_mod_edit::module_path(content)?; + Some(format!("pkg:golang/{module}")) } /// `composer.json` → `pkg:composer//[@]` (composer @@ -2138,6 +2125,24 @@ mod tests { .is_none()); } + /// Go accepts the block form `module ( … )` (`go list -m` prints the + /// path inside). The former line reader returned `pkg:golang/(`. + #[tokio::test] + async fn detect_go_mod_block_form_module() { + let r = detect_in(&[("go.mod", "module (\n\texample.com/blk\n)\n\ngo 1.21\n")]).await; + assert_eq!(r.purl.as_deref(), Some("pkg:golang/example.com/blk")); + // A multi-token directive is malformed, and so is a BOM-led file's + // `module ""`. + assert!(detect_in(&[("go.mod", "module foo bar\n")]) + .await + .purl + .is_none()); + assert!(detect_in(&[("go.mod", "\u{feff}module \"\"\n")]) + .await + .purl + .is_none()); + } + #[tokio::test] async fn detect_composer_json_name_and_optional_version() { let r = detect_in(&[("composer.json", r#"{"name":"Acme/App","version":"1.2.0"}"#)]).await; diff --git a/crates/socket-patch-core/tests/crawler_go_e2e.rs b/crates/socket-patch-core/tests/crawler_go_e2e.rs index 157ca1f07..4ee2eb610 100644 --- a/crates/socket-patch-core/tests/crawler_go_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_go_e2e.rs @@ -3,9 +3,7 @@ use std::path::Path; use serial_test::serial; -use socket_patch_core::crawlers::go_crawler::{ - decode_module_path, encode_module_path, parse_go_mod_module, -}; +use socket_patch_core::crawlers::go_crawler::{decode_module_path, encode_module_path}; use socket_patch_core::crawlers::types::CrawlerOptions; use socket_patch_core::crawlers::GoCrawler; @@ -65,31 +63,6 @@ fn decode_module_path_no_bang_passthrough() { ); } -// ── parse_go_mod_module ──────────────────────────────────────── - -#[test] -#[serial_test::parallel] -fn parse_go_mod_well_formed() { - let content = "module github.com/gin-gonic/gin\n\ngo 1.21\n"; - assert_eq!( - parse_go_mod_module(content), - Some("github.com/gin-gonic/gin".to_string()) - ); -} - -#[test] -#[serial_test::parallel] -fn parse_go_mod_missing_module_returns_none() { - let content = "go 1.21\n"; - assert_eq!(parse_go_mod_module(content), None); -} - -#[test] -#[serial_test::parallel] -fn parse_go_mod_empty_returns_none() { - assert_eq!(parse_go_mod_module(""), None); -} - // ── find_by_purls ────────────────────────────────────────────── #[tokio::test] @@ -266,25 +239,6 @@ async fn go_crawler_default_and_new_construct_cleanly() { ); } -/// A `module` directive with no path (`module`) must not match — the -/// `!rest.is_empty()` guard in `parse_go_mod_module` keeps it from being -/// returned. -#[test] -#[serial_test::parallel] -fn parse_go_mod_module_directive_with_empty_path_returns_none() { - assert_eq!(parse_go_mod_module("module\n"), None); -} - -/// Quoted module path with whitespace — the strip-quotes branch. -#[test] -#[serial_test::parallel] -fn parse_go_mod_module_quoted_path() { - assert_eq!( - parse_go_mod_module(r#"module "github.com/foo/bar""#), - Some("github.com/foo/bar".to_string()) - ); -} - /// `!` at the end of an encoded path with no following character. Go's /// encoder never emits a lone trailing `!`, so it is not a valid escape; /// `decode_module_path` preserves it rather than silently dropping a byte,