Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 0 additions & 116 deletions crates/socket-patch-core/src/crawlers/go_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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/@<version>`. 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions crates/socket-patch-core/src/vendor/go_mod_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,28 @@ pub fn parse_required_versions(content: &str) -> HashMap<String, String> {
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<String> {
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
Expand Down Expand Up @@ -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 = "\
Expand Down
41 changes: 23 additions & 18 deletions crates/socket-patch-core/src/vex/product.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,25 +169,12 @@ fn parse_cargo_toml(content: &str) -> Option<String> {
Some(format!("pkg:cargo/{name}@{version}"))
}

/// `go.mod` → `pkg:golang/<module>` from the `module` directive (quoted or
/// bare, trailing `//` comment allowed). go.mod records no version, so the
/// purl carries none.
/// `go.mod` → `pkg:golang/<module>` 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<String> {
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/<vendor>/<name>[@<version>]` (composer
Expand Down Expand Up @@ -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;
Expand Down
48 changes: 1 addition & 47 deletions crates/socket-patch-core/tests/crawler_go_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down
Loading