Skip to main content

provenant/parsers/
go.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Go ecosystem dependency files.
7//!
8//! Extracts package metadata and dependencies from Go module management files
9//! and legacy dependency tracking formats.
10//!
11//! # Supported Formats
12//! - go.mod (Go module manifest with dependencies and version constraints)
13//! - go.sum (Go module checksum database for verification)
14//! - Godeps.json (Legacy dependency format from godep tool)
15//!
16//! # Key Features
17//! - go.mod dependency extraction with version constraint parsing
18//! - Direct vs transitive dependency tracking from require/indirect fields
19//! - Checksum extraction from go.sum for integrity verification
20//! - Legacy Godeps.json support for older projects
21//! - Package URL (purl) generation for golang packages
22//! - Module path parsing and namespace detection
23//!
24//! # Implementation Notes
25//! - PURL type: "golang"
26//! - All dependencies are pinned in go.mod/go.sum (`is_pinned: Some(true)`)
27//! - Graceful error handling with `warn!()` logs
28//! - Supports Go 1.11+ module syntax
29
30use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
31use crate::parser_warn as warn;
32use crate::parsers::utils::{
33    CappedIterExt, capped_iteration_limit, read_file_to_string, truncate_field,
34};
35use packageurl::PackageUrl;
36use std::collections::{HashMap, HashSet};
37use std::path::Path;
38
39use super::PackageParser;
40use super::metadata::ParserMetadata;
41
42const PACKAGE_TYPE: PackageType = PackageType::Golang;
43
44/// Go go.mod manifest parser.
45///
46/// Extracts module declaration, require dependencies (with indirect marker
47/// preservation), and exclude directives from go.mod files.
48pub struct GoModParser;
49
50impl PackageParser for GoModParser {
51    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
52
53    fn metadata() -> Vec<ParserMetadata> {
54        vec![ParserMetadata {
55            description: "Go go.mod module manifest",
56            file_patterns: &["**/go.mod"],
57            package_type: "golang",
58            primary_language: "Go",
59            documentation_url: Some("https://go.dev/ref/mod#go-mod-file"),
60        }]
61    }
62
63    fn extract_packages(path: &Path) -> Vec<PackageData> {
64        let content = match read_file_to_string(path, None) {
65            Ok(c) => c,
66            Err(e) => {
67                warn!("Failed to read go.mod at {:?}: {}", path, e);
68                return vec![default_go_mod_package_data()];
69            }
70        };
71
72        vec![parse_go_mod(&content)]
73    }
74
75    fn is_match(path: &Path) -> bool {
76        path.file_name().is_some_and(|name| name == "go.mod")
77    }
78}
79
80#[derive(Debug, Clone, PartialEq)]
81enum BlockState {
82    None,
83    Require,
84    Exclude,
85    Replace,
86    Retract,
87}
88
89pub fn parse_go_mod(content: &str) -> PackageData {
90    let mut namespace: Option<String> = None;
91    let mut name: Option<String> = None;
92    let mut go_version: Option<String> = None;
93    let mut toolchain: Option<String> = None;
94    let mut require_deps: Vec<Dependency> = Vec::new();
95    let mut exclude_deps: Vec<Dependency> = Vec::new();
96    let mut replace_deps: Vec<Dependency> = Vec::new();
97    let mut retracted_versions: Vec<String> = Vec::new();
98    let mut block_state = BlockState::None;
99
100    for line in content.lines().capped("go.mod lines") {
101        let trimmed = line.trim();
102
103        if trimmed.is_empty() || trimmed.starts_with("//") {
104            continue;
105        }
106
107        // Bug #5: Reset block state on closing paren
108        if trimmed == ")" {
109            block_state = BlockState::None;
110            continue;
111        }
112
113        // Inside a block: dispatch by block type
114        if block_state != BlockState::None {
115            match block_state {
116                BlockState::Require => {
117                    if let Some(dep) = parse_dependency_line(trimmed, "require") {
118                        require_deps.push(dep);
119                    }
120                }
121                BlockState::Exclude => {
122                    if let Some(dep) = parse_dependency_line(trimmed, "exclude") {
123                        exclude_deps.push(dep);
124                    }
125                }
126                BlockState::Replace => {
127                    if let Some(dep) = parse_replace_line(trimmed) {
128                        replace_deps.push(dep);
129                    }
130                }
131                BlockState::Retract => {
132                    retracted_versions.extend(parse_retract_value(trimmed));
133                }
134                BlockState::None => {}
135            }
136            continue;
137        }
138
139        // Block openings
140        if trimmed.starts_with("require") && trimmed.contains('(') {
141            block_state = BlockState::Require;
142            continue;
143        }
144        if trimmed.starts_with("exclude") && trimmed.contains('(') {
145            block_state = BlockState::Exclude;
146            continue;
147        }
148        if trimmed.starts_with("replace") && trimmed.contains('(') {
149            block_state = BlockState::Replace;
150            continue;
151        }
152        if trimmed.starts_with("retract") && trimmed.contains('(') {
153            block_state = BlockState::Retract;
154            continue;
155        }
156
157        // Module declaration
158        if let Some(module_path) = trimmed.strip_prefix("module ") {
159            let module_path = strip_comment(module_path).trim();
160            if !module_path.is_empty() {
161                let (ns, n) = split_module_path(module_path);
162                namespace = ns.map(truncate_field);
163                name = Some(truncate_field(n));
164            }
165            continue;
166        }
167
168        // Go version directive
169        if let Some(version) = trimmed.strip_prefix("go ") {
170            let version = strip_comment(version).trim();
171            if !version.is_empty() {
172                go_version = Some(truncate_field(version.to_string()));
173            }
174            continue;
175        }
176
177        // Toolchain directive
178        if let Some(tc) = trimmed.strip_prefix("toolchain ") {
179            let tc = strip_comment(tc).trim();
180            if !tc.is_empty() {
181                toolchain = Some(truncate_field(tc.to_string()));
182            }
183            continue;
184        }
185
186        // Single-line require
187        if let Some(rest) = trimmed.strip_prefix("require ") {
188            if let Some(dep) = parse_dependency_line(rest, "require") {
189                require_deps.push(dep);
190            }
191            continue;
192        }
193
194        // Single-line exclude
195        if let Some(rest) = trimmed.strip_prefix("exclude ") {
196            if let Some(dep) = parse_dependency_line(rest, "exclude") {
197                exclude_deps.push(dep);
198            }
199            continue;
200        }
201
202        // Single-line replace (without opening paren)
203        if let Some(rest) = trimmed.strip_prefix("replace ") {
204            let rest = strip_comment(rest).trim();
205            if !rest.contains('(')
206                && let Some(dep) = parse_replace_line(rest)
207            {
208                replace_deps.push(dep);
209            }
210            continue;
211        }
212
213        // Single-line retract
214        if let Some(rest) = trimmed.strip_prefix("retract ") {
215            let rest = strip_comment(rest).trim();
216            if !rest.contains('(') {
217                retracted_versions.extend(parse_retract_value(rest));
218            }
219            continue;
220        }
221    }
222
223    let full_module = match (&namespace, &name) {
224        (Some(ns), Some(n)) => Some(format!("{}/{}", ns, n)),
225        (None, Some(n)) => Some(n.clone()),
226        _ => None,
227    };
228
229    let homepage_url = full_module
230        .as_ref()
231        .map(|m| truncate_field(format!("https://pkg.go.dev/{}", m)));
232
233    let vcs_url = full_module
234        .as_ref()
235        .map(|m| truncate_field(format!("https://{}.git", m)));
236
237    let repository_homepage_url = homepage_url.clone();
238
239    let purl = full_module
240        .as_ref()
241        .and_then(|m| create_golang_purl(m, None));
242
243    let mut dependencies =
244        Vec::with_capacity(require_deps.len() + exclude_deps.len() + replace_deps.len());
245    dependencies.append(&mut require_deps);
246    dependencies.append(&mut exclude_deps);
247    dependencies.append(&mut replace_deps);
248
249    let mut extra_data_map = std::collections::HashMap::new();
250    if let Some(v) = go_version {
251        extra_data_map.insert("go_version".to_string(), serde_json::Value::String(v));
252    }
253    if let Some(tc) = toolchain {
254        extra_data_map.insert("toolchain".to_string(), serde_json::Value::String(tc));
255    }
256    if !retracted_versions.is_empty() {
257        extra_data_map.insert(
258            "retracted_versions".to_string(),
259            serde_json::json!(retracted_versions),
260        );
261    }
262    let extra_data = if extra_data_map.is_empty() {
263        None
264    } else {
265        Some(extra_data_map)
266    };
267
268    PackageData {
269        package_type: Some(PACKAGE_TYPE),
270        namespace,
271        name,
272        version: None,
273        qualifiers: None,
274        subpath: None,
275        primary_language: Some("Go".to_string()),
276        description: None,
277        release_date: None,
278        parties: Vec::new(),
279        keywords: Vec::new(),
280        homepage_url,
281        download_url: None,
282        size: None,
283        sha1: None,
284        md5: None,
285        sha256: None,
286        sha512: None,
287        bug_tracking_url: None,
288        code_view_url: None,
289        vcs_url,
290        copyright: None,
291        holder: None,
292        declared_license_expression: None,
293        declared_license_expression_spdx: None,
294        license_detections: Vec::new(),
295        other_license_expression: None,
296        other_license_expression_spdx: None,
297        other_license_detections: Vec::new(),
298        extracted_license_statement: None,
299        notice_text: None,
300        source_packages: Vec::new(),
301        file_references: Vec::new(),
302        is_private: false,
303        is_virtual: false,
304        extra_data,
305        dependencies,
306        repository_homepage_url,
307        repository_download_url: None,
308        api_data_url: None,
309        datasource_id: Some(DatasourceId::GoMod),
310        purl,
311    }
312}
313
314/// Parses a single dependency line from a require or exclude block/directive.
315///
316/// Handles:
317/// - Bug #2: Preserves `// indirect` marker as `is_direct = false`
318/// - Bug #8: `+incompatible` suffix in versions
319/// - Bug #10: Pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-hash)
320///
321/// Format: `github.com/foo/bar v1.2.3 // indirect`
322fn parse_dependency_line(line: &str, scope: &str) -> Option<Dependency> {
323    let trimmed = line.trim();
324    if trimmed.is_empty() || trimmed.starts_with("//") {
325        return None;
326    }
327
328    // Bug #2: Check for // indirect BEFORE stripping comments
329    let is_indirect = trimmed.contains("// indirect");
330    let is_direct = !is_indirect;
331
332    // Strip comment for parsing the module path and version
333    let without_comment = strip_comment(trimmed);
334    let without_comment = without_comment.trim();
335
336    // Split into module path and version
337    let parts: Vec<&str> = without_comment.split_whitespace().collect();
338    if parts.len() < 2 {
339        return None;
340    }
341
342    let module_path = parts[0];
343    let version = truncate_field(parts[1].to_string());
344
345    let purl = create_golang_purl(module_path, Some(&version));
346
347    Some(Dependency {
348        purl,
349        extracted_requirement: Some(version),
350        scope: Some(scope.to_string()),
351        is_runtime: Some(true),
352        is_optional: Some(false),
353        is_pinned: Some(false),
354        is_direct: Some(is_direct),
355        resolved_package: None,
356        extra_data: None,
357    })
358}
359
360/// Parses a replace line: `old-module [version] => new-module [version]`
361///
362/// Returns a `Dependency` with scope "replace" and extra_data containing
363/// replace_old, replace_new, replace_version, and optionally replace_old_version.
364fn parse_replace_line(line: &str) -> Option<Dependency> {
365    let line = strip_comment(line).trim();
366
367    let parts: Vec<&str> = line.splitn(2, "=>").collect();
368    if parts.len() != 2 {
369        return None;
370    }
371
372    let old_parts: Vec<&str> = parts[0].split_whitespace().collect();
373    let new_parts: Vec<&str> = parts[1].split_whitespace().collect();
374
375    if old_parts.is_empty() || new_parts.is_empty() {
376        return None;
377    }
378
379    let old_module = old_parts[0];
380    let old_version = old_parts.get(1).copied();
381    let new_module = new_parts[0];
382    let new_version = new_parts.get(1).map(|s| truncate_field(s.to_string()));
383
384    let is_local_path = is_local_go_replace_path(new_module);
385    let purl = if is_local_path {
386        None
387    } else {
388        create_golang_purl(new_module, new_version.as_deref())
389    };
390
391    let mut extra = std::collections::HashMap::new();
392    extra.insert(
393        "replace_old".to_string(),
394        serde_json::Value::String(truncate_field(old_module.to_string())),
395    );
396    extra.insert(
397        "replace_new".to_string(),
398        serde_json::Value::String(truncate_field(new_module.to_string())),
399    );
400    if let Some(ref v) = new_version {
401        extra.insert(
402            "replace_version".to_string(),
403            serde_json::Value::String(v.clone()),
404        );
405    }
406    if let Some(ov) = old_version {
407        extra.insert(
408            "replace_old_version".to_string(),
409            serde_json::Value::String(truncate_field(ov.to_string())),
410        );
411    }
412    if is_local_path {
413        extra.insert(
414            "replace_local_path".to_string(),
415            serde_json::Value::Bool(true),
416        );
417    }
418
419    Some(Dependency {
420        purl,
421        extracted_requirement: new_version,
422        scope: Some("replace".to_string()),
423        is_runtime: Some(true),
424        is_optional: Some(false),
425        is_pinned: Some(false),
426        is_direct: Some(true),
427        resolved_package: None,
428        extra_data: Some(extra),
429    })
430}
431
432/// Parses a retract value which can be a single version or a range `[v1, v2]`.
433fn parse_retract_value(value: &str) -> Vec<String> {
434    let trimmed = value.trim();
435    if trimmed.is_empty() {
436        return Vec::new();
437    }
438
439    if trimmed.starts_with('[') && trimmed.ends_with(']') {
440        let inner = &trimmed[1..trimmed.len() - 1];
441        inner
442            .split(',')
443            .map(|s| s.trim().to_string())
444            .filter(|s| !s.is_empty())
445            .collect()
446    } else {
447        vec![trimmed.to_string()]
448    }
449}
450
451pub(crate) fn split_module_path(path: &str) -> (Option<String>, String) {
452    match path.rfind('/') {
453        Some(idx) => {
454            let namespace = &path[..idx];
455            let name = &path[idx + 1..];
456            (
457                Some(truncate_field(namespace.to_string())),
458                truncate_field(name.to_string()),
459            )
460        }
461        None => (None, truncate_field(path.to_string())),
462    }
463}
464
465/// Strips inline comments (everything after `//`) from a line.
466///
467/// Preserves the content before the comment marker.
468fn strip_comment(line: &str) -> &str {
469    match line.find("//") {
470        Some(idx) => &line[..idx],
471        None => line,
472    }
473}
474
475/// Creates a PURL for a Go module.
476///
477/// Format: `pkg:golang/namespace/name@version`
478/// The module path is split into namespace and name for PURL construction.
479pub(crate) fn create_golang_purl(module_path: &str, version: Option<&str>) -> Option<String> {
480    let (namespace, name) = split_module_path(module_path);
481
482    let mut purl = match PackageUrl::new(PACKAGE_TYPE.as_str(), &name) {
483        Ok(p) => p,
484        Err(e) => {
485            warn!(
486                "Failed to create PURL for golang module '{}': {}",
487                module_path, e
488            );
489            return None;
490        }
491    };
492
493    if let Some(v) = version
494        && let Err(e) = purl.with_version(v)
495    {
496        warn!(
497            "Failed to set version '{}' for golang module '{}': {}",
498            v, module_path, e
499        );
500        return None;
501    }
502
503    // Splice the namespace in manually instead of via `with_namespace`, which
504    // force-lowercases the whole golang namespace; `normalize_purl` then applies
505    // the host-only lowercasing the spec direction (purl-spec#308) calls for.
506    let built = purl.to_string();
507    let built = match &namespace {
508        Some(ns) => built.replacen("pkg:golang/", &format!("pkg:golang/{ns}/"), 1),
509        None => built,
510    };
511
512    Some(crate::models::normalize_purl(&built))
513}
514
515/// Returns a default empty PackageData for Go modules.
516fn default_package_data() -> PackageData {
517    PackageData {
518        package_type: Some(PACKAGE_TYPE),
519        primary_language: Some("Go".to_string()),
520        ..Default::default()
521    }
522}
523
524fn default_go_mod_package_data() -> PackageData {
525    PackageData {
526        datasource_id: Some(DatasourceId::GoMod),
527        ..default_package_data()
528    }
529}
530
531fn default_go_sum_package_data() -> PackageData {
532    PackageData {
533        datasource_id: Some(DatasourceId::GoSum),
534        ..default_package_data()
535    }
536}
537
538fn default_go_work_package_data() -> PackageData {
539    PackageData {
540        datasource_id: Some(DatasourceId::GoWork),
541        ..default_package_data()
542    }
543}
544
545fn default_godeps_package_data() -> PackageData {
546    PackageData {
547        datasource_id: Some(DatasourceId::Godeps),
548        ..default_package_data()
549    }
550}
551
552// ============================================================================
553// GoSumParser
554// ============================================================================
555
556pub struct GoSumParser;
557
558impl PackageParser for GoSumParser {
559    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
560
561    fn metadata() -> Vec<ParserMetadata> {
562        vec![ParserMetadata {
563            description: "Go go.sum checksum database",
564            file_patterns: &["**/go.sum"],
565            package_type: "golang",
566            primary_language: "Go",
567            documentation_url: Some("https://go.dev/ref/mod#go-sum-files"),
568        }]
569    }
570
571    fn extract_packages(path: &Path) -> Vec<PackageData> {
572        let content = match read_file_to_string(path, None) {
573            Ok(c) => c,
574            Err(e) => {
575                warn!("Failed to read go.sum at {:?}: {}", path, e);
576                return vec![default_go_sum_package_data()];
577            }
578        };
579
580        vec![parse_go_sum(&content)]
581    }
582
583    fn is_match(path: &Path) -> bool {
584        path.file_name().is_some_and(|name| name == "go.sum")
585    }
586}
587
588pub fn parse_go_sum(content: &str) -> PackageData {
589    let mut dependencies = Vec::new();
590    let mut seen = HashSet::new();
591
592    for line in content.lines().capped("go.sum lines") {
593        let trimmed = line.trim();
594        if trimmed.is_empty() {
595            continue;
596        }
597
598        let parts: Vec<&str> = trimmed.split_whitespace().collect();
599        if parts.len() < 3 || !parts[2].starts_with("h1:") {
600            continue;
601        }
602
603        let module = parts[0];
604        let raw_version = parts[1];
605
606        let version = raw_version.strip_suffix("/go.mod").unwrap_or(raw_version);
607
608        let key = format!("{}@{}", module, version);
609        if seen.contains(&key) {
610            continue;
611        }
612        seen.insert(key);
613
614        let purl = create_golang_purl(module, Some(version));
615
616        dependencies.push(Dependency {
617            purl,
618            extracted_requirement: Some(truncate_field(version.to_string())),
619            scope: Some("dependency".to_string()),
620            is_runtime: Some(true),
621            is_optional: Some(false),
622            is_pinned: Some(true),
623            is_direct: None,
624            resolved_package: None,
625            extra_data: None,
626        });
627    }
628
629    PackageData {
630        package_type: Some(PACKAGE_TYPE),
631        namespace: None,
632        name: None,
633        version: None,
634        qualifiers: None,
635        subpath: None,
636        primary_language: Some("Go".to_string()),
637        description: None,
638        release_date: None,
639        parties: Vec::new(),
640        keywords: Vec::new(),
641        homepage_url: None,
642        download_url: None,
643        size: None,
644        sha1: None,
645        md5: None,
646        sha256: None,
647        sha512: None,
648        bug_tracking_url: None,
649        code_view_url: None,
650        vcs_url: None,
651        copyright: None,
652        holder: None,
653        declared_license_expression: None,
654        declared_license_expression_spdx: None,
655        license_detections: Vec::new(),
656        other_license_expression: None,
657        other_license_expression_spdx: None,
658        other_license_detections: Vec::new(),
659        extracted_license_statement: None,
660        notice_text: None,
661        source_packages: Vec::new(),
662        file_references: Vec::new(),
663        is_private: false,
664        is_virtual: false,
665        extra_data: None,
666        dependencies,
667        repository_homepage_url: None,
668        repository_download_url: None,
669        api_data_url: None,
670        datasource_id: Some(DatasourceId::GoSum),
671        purl: None,
672    }
673}
674
675pub struct GoWorkParser;
676
677impl PackageParser for GoWorkParser {
678    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
679
680    fn metadata() -> Vec<ParserMetadata> {
681        vec![ParserMetadata {
682            description: "Go go.work workspace file",
683            file_patterns: &["**/go.work"],
684            package_type: "golang",
685            primary_language: "Go",
686            documentation_url: Some("https://go.dev/ref/mod#go-work-files"),
687        }]
688    }
689
690    fn extract_packages(path: &Path) -> Vec<PackageData> {
691        let content = match read_file_to_string(path, None) {
692            Ok(c) => c,
693            Err(e) => {
694                warn!("Failed to read go.work at {:?}: {}", path, e);
695                return vec![default_go_work_package_data()];
696            }
697        };
698
699        vec![parse_go_work(&content, path)]
700    }
701
702    fn is_match(path: &Path) -> bool {
703        path.file_name().is_some_and(|name| name == "go.work")
704    }
705}
706
707pub fn parse_go_work(content: &str, work_path: &Path) -> PackageData {
708    let mut go_version: Option<String> = None;
709    let mut toolchain: Option<String> = None;
710    let mut use_paths: Vec<String> = Vec::new();
711    let mut replace_deps: Vec<Dependency> = Vec::new();
712    let mut unresolved_use_paths: Vec<String> = Vec::new();
713    let mut block_state = BlockState::None;
714
715    for line in content.lines().capped("go.work lines") {
716        let trimmed = line.trim();
717
718        if trimmed.is_empty() || trimmed.starts_with("//") {
719            continue;
720        }
721
722        if trimmed == ")" {
723            block_state = BlockState::None;
724            continue;
725        }
726
727        if block_state != BlockState::None {
728            match block_state {
729                BlockState::Require => {
730                    let use_path = extract_single_go_token(trimmed);
731                    if let Some(use_path) = use_path.filter(|path| !path.is_empty()) {
732                        use_paths.push(truncate_field(use_path));
733                    }
734                }
735                BlockState::Replace => {
736                    if let Some(dep) = parse_workspace_replace_line(trimmed) {
737                        replace_deps.push(dep);
738                    }
739                }
740                _ => {}
741            }
742            continue;
743        }
744
745        if trimmed.starts_with("use") && trimmed.contains('(') {
746            block_state = BlockState::Require;
747            continue;
748        }
749        if trimmed.starts_with("replace") && trimmed.contains('(') {
750            block_state = BlockState::Replace;
751            continue;
752        }
753
754        if let Some(version) = trimmed.strip_prefix("go ") {
755            let version = strip_comment(version).trim();
756            if !version.is_empty() {
757                go_version = Some(truncate_field(version.to_string()));
758            }
759            continue;
760        }
761
762        if let Some(tc) = trimmed.strip_prefix("toolchain ") {
763            let tc = strip_comment(tc).trim();
764            if !tc.is_empty() {
765                toolchain = Some(truncate_field(tc.to_string()));
766            }
767            continue;
768        }
769
770        if let Some(rest) = trimmed.strip_prefix("use ") {
771            let use_path = extract_single_go_token(rest);
772            if let Some(use_path) = use_path.filter(|path| !path.is_empty()) {
773                use_paths.push(truncate_field(use_path));
774            }
775            continue;
776        }
777
778        if let Some(rest) = trimmed.strip_prefix("replace ") {
779            if let Some(dep) = parse_workspace_replace_line(rest) {
780                replace_deps.push(dep);
781            }
782            continue;
783        }
784    }
785
786    if go_version.is_none() || use_paths.is_empty() {
787        warn!("Invalid go.work: missing go directive or use directive");
788        return default_go_work_package_data();
789    }
790
791    let (mut dependencies, unresolved) = resolve_workspace_use_dependencies(work_path, &use_paths);
792    dependencies.extend(replace_deps);
793    unresolved_use_paths.extend(unresolved);
794
795    let mut extra_data = HashMap::new();
796    if let Some(v) = go_version {
797        extra_data.insert("go_version".to_string(), serde_json::Value::String(v));
798    }
799    if let Some(tc) = toolchain {
800        extra_data.insert("toolchain".to_string(), serde_json::Value::String(tc));
801    }
802    extra_data.insert(
803        "use_paths".to_string(),
804        serde_json::Value::Array(
805            use_paths
806                .iter()
807                .cloned()
808                .map(serde_json::Value::String)
809                .collect(),
810        ),
811    );
812    if !unresolved_use_paths.is_empty() {
813        extra_data.insert(
814            "unresolved_use_paths".to_string(),
815            serde_json::Value::Array(
816                unresolved_use_paths
817                    .into_iter()
818                    .map(serde_json::Value::String)
819                    .collect(),
820            ),
821        );
822    }
823
824    PackageData {
825        package_type: Some(PACKAGE_TYPE),
826        namespace: None,
827        name: None,
828        version: None,
829        qualifiers: None,
830        subpath: None,
831        primary_language: Some("Go".to_string()),
832        description: None,
833        release_date: None,
834        parties: Vec::new(),
835        keywords: Vec::new(),
836        homepage_url: None,
837        download_url: None,
838        size: None,
839        sha1: None,
840        md5: None,
841        sha256: None,
842        sha512: None,
843        bug_tracking_url: None,
844        code_view_url: None,
845        vcs_url: None,
846        copyright: None,
847        holder: None,
848        declared_license_expression: None,
849        declared_license_expression_spdx: None,
850        license_detections: Vec::new(),
851        other_license_expression: None,
852        other_license_expression_spdx: None,
853        other_license_detections: Vec::new(),
854        extracted_license_statement: None,
855        notice_text: None,
856        source_packages: Vec::new(),
857        file_references: Vec::new(),
858        is_private: false,
859        is_virtual: false,
860        extra_data: Some(extra_data),
861        dependencies,
862        repository_homepage_url: None,
863        repository_download_url: None,
864        api_data_url: None,
865        datasource_id: Some(DatasourceId::GoWork),
866        purl: None,
867    }
868}
869
870fn resolve_workspace_use_dependencies(
871    work_path: &Path,
872    use_paths: &[String],
873) -> (Vec<Dependency>, Vec<String>) {
874    let Some(base_dir) = work_path.parent() else {
875        return (Vec::new(), use_paths.to_vec());
876    };
877
878    let mut dependencies = Vec::new();
879    let mut unresolved = Vec::new();
880
881    let limit = capped_iteration_limit(use_paths.len(), "go.work use paths");
882    for use_path in use_paths.iter().take(limit) {
883        let go_mod_path = base_dir.join(use_path).join("go.mod");
884        let module_path = read_file_to_string(&go_mod_path, None)
885            .ok()
886            .and_then(|content| extract_module_path_from_go_mod(&content));
887
888        let purl = module_path
889            .as_deref()
890            .and_then(|module_path| create_golang_purl(module_path, None));
891
892        if purl.is_none() {
893            unresolved.push(use_path.clone());
894            continue;
895        }
896
897        let mut extra_data = HashMap::new();
898        extra_data.insert(
899            "workspace_path".to_string(),
900            serde_json::Value::String(truncate_field(use_path.clone())),
901        );
902        if let Some(module_path) = module_path {
903            extra_data.insert(
904                "workspace_module_path".to_string(),
905                serde_json::Value::String(truncate_field(module_path)),
906            );
907        }
908
909        dependencies.push(Dependency {
910            purl,
911            extracted_requirement: Some(truncate_field(use_path.clone())),
912            scope: Some("use".to_string()),
913            is_runtime: Some(true),
914            is_optional: Some(false),
915            is_pinned: Some(false),
916            is_direct: Some(true),
917            resolved_package: None,
918            extra_data: Some(extra_data),
919        });
920    }
921
922    (dependencies, unresolved)
923}
924
925fn extract_module_path_from_go_mod(content: &str) -> Option<String> {
926    for line in content.lines().capped("go.mod module-path lines") {
927        let trimmed = line.trim();
928        if let Some(module_path) = trimmed.strip_prefix("module ") {
929            let module_path = strip_comment(module_path).trim();
930            if !module_path.is_empty() {
931                return Some(truncate_field(module_path.to_string()));
932            }
933        }
934    }
935    None
936}
937
938fn parse_workspace_replace_line(line: &str) -> Option<Dependency> {
939    let line = strip_comment(line).trim();
940    let parts: Vec<&str> = line.splitn(2, "=>").collect();
941    if parts.len() != 2 {
942        return None;
943    }
944
945    let old_parts = parse_go_tokens(parts[0]);
946    let new_parts = parse_go_tokens(parts[1]);
947    if old_parts.is_empty() || new_parts.is_empty() {
948        return None;
949    }
950
951    let old_module = old_parts[0].as_str();
952    let old_version = old_parts.get(1).map(|s| s.as_str());
953    let new_module = new_parts[0].as_str();
954    let new_version = new_parts.get(1).map(|s| truncate_field(s.clone()));
955    let is_local_path = is_local_go_replace_path(new_module);
956
957    let purl = if is_local_path {
958        None
959    } else {
960        create_golang_purl(new_module, new_version.as_deref())
961    };
962
963    let mut extra = std::collections::HashMap::new();
964    extra.insert(
965        "replace_old".to_string(),
966        serde_json::Value::String(truncate_field(old_module.to_string())),
967    );
968    extra.insert(
969        "replace_new".to_string(),
970        serde_json::Value::String(truncate_field(new_module.to_string())),
971    );
972    if let Some(ref v) = new_version {
973        extra.insert(
974            "replace_version".to_string(),
975            serde_json::Value::String(v.clone()),
976        );
977    }
978    if let Some(ov) = old_version {
979        extra.insert(
980            "replace_old_version".to_string(),
981            serde_json::Value::String(truncate_field(ov.to_string())),
982        );
983    }
984    if is_local_path {
985        extra.insert(
986            "replace_local_path".to_string(),
987            serde_json::Value::Bool(true),
988        );
989    }
990
991    Some(Dependency {
992        purl,
993        extracted_requirement: new_version,
994        scope: Some("replace".to_string()),
995        is_runtime: Some(true),
996        is_optional: Some(false),
997        is_pinned: Some(false),
998        is_direct: Some(true),
999        resolved_package: None,
1000        extra_data: Some(extra),
1001    })
1002}
1003
1004fn is_local_go_replace_path(module: &str) -> bool {
1005    module.starts_with("./")
1006        || module.starts_with("../")
1007        || module.starts_with('/')
1008        || module.starts_with('~')
1009}
1010
1011fn extract_single_go_token(value: &str) -> Option<String> {
1012    parse_go_tokens(value).into_iter().next()
1013}
1014
1015fn parse_go_tokens(value: &str) -> Vec<String> {
1016    let mut tokens = Vec::new();
1017    let mut current = String::new();
1018    let mut quote: Option<char> = None;
1019    let mut chars = value.chars().peekable();
1020
1021    while let Some(ch) = chars.next() {
1022        if let Some(active_quote) = quote {
1023            if ch == active_quote {
1024                quote = None;
1025                continue;
1026            }
1027
1028            if active_quote == '"' && ch == '\\' {
1029                if let Some(next) = chars.next() {
1030                    current.push(next);
1031                }
1032                continue;
1033            }
1034
1035            current.push(ch);
1036            continue;
1037        }
1038
1039        match ch {
1040            '"' | '`' => {
1041                quote = Some(ch);
1042            }
1043            c if c.is_whitespace() => {
1044                if !current.is_empty() {
1045                    tokens.push(std::mem::take(&mut current));
1046                }
1047            }
1048            _ => current.push(ch),
1049        }
1050    }
1051
1052    if !current.is_empty() {
1053        tokens.push(current);
1054    }
1055
1056    tokens
1057}
1058
1059// ============================================================================
1060// GodepsParser
1061// ============================================================================
1062
1063pub struct GodepsParser;
1064
1065impl PackageParser for GodepsParser {
1066    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
1067
1068    fn metadata() -> Vec<ParserMetadata> {
1069        vec![ParserMetadata {
1070            description: "Go Godeps.json legacy dependency file",
1071            file_patterns: &["**/Godeps.json"],
1072            package_type: "golang",
1073            primary_language: "Go",
1074            documentation_url: None,
1075        }]
1076    }
1077
1078    fn extract_packages(path: &Path) -> Vec<PackageData> {
1079        let content = match read_file_to_string(path, None) {
1080            Ok(c) => c,
1081            Err(e) => {
1082                warn!("Failed to read Godeps.json at {:?}: {}", path, e);
1083                return vec![default_godeps_package_data()];
1084            }
1085        };
1086
1087        vec![parse_godeps_json(&content)]
1088    }
1089
1090    fn is_match(path: &Path) -> bool {
1091        path.file_name().is_some_and(|name| name == "Godeps.json")
1092    }
1093}
1094
1095pub fn parse_godeps_json(content: &str) -> PackageData {
1096    let json: serde_json::Value = match serde_json::from_str(content) {
1097        Ok(j) => j,
1098        Err(e) => {
1099            warn!("Failed to parse Godeps.json: {}", e);
1100            return default_godeps_package_data();
1101        }
1102    };
1103
1104    let import_path = json
1105        .get("ImportPath")
1106        .and_then(|v| v.as_str())
1107        .map(|s| truncate_field(s.to_string()));
1108
1109    let go_version = json
1110        .get("GoVersion")
1111        .and_then(|v| v.as_str())
1112        .map(|s| truncate_field(s.to_string()));
1113
1114    let (namespace, name) = match &import_path {
1115        Some(ip) => {
1116            let (ns, n) = split_module_path(ip);
1117            (ns, Some(n))
1118        }
1119        None => (None, None),
1120    };
1121
1122    let purl = import_path
1123        .as_deref()
1124        .and_then(|ip| create_golang_purl(ip, None));
1125
1126    let mut dependencies = Vec::new();
1127
1128    if let Some(deps) = json.get("Deps").and_then(|v| v.as_array()) {
1129        let limit = capped_iteration_limit(deps.len(), "go list Deps");
1130        for dep in deps.iter().take(limit) {
1131            let dep_import_path = dep.get("ImportPath").and_then(|v| v.as_str());
1132            let rev = dep.get("Rev").and_then(|v| v.as_str());
1133
1134            if let Some(path) = dep_import_path {
1135                let dep_purl = create_golang_purl(path, None);
1136
1137                dependencies.push(Dependency {
1138                    purl: dep_purl,
1139                    extracted_requirement: rev.map(|s| truncate_field(s.to_string())),
1140                    scope: Some("Deps".to_string()),
1141                    is_runtime: Some(true),
1142                    is_optional: Some(false),
1143                    is_pinned: Some(false),
1144                    is_direct: None,
1145                    resolved_package: None,
1146                    extra_data: None,
1147                });
1148            }
1149        }
1150    }
1151
1152    let extra_data = go_version.map(|v| {
1153        let mut map = HashMap::new();
1154        map.insert("go_version".to_string(), serde_json::Value::String(v));
1155        map
1156    });
1157
1158    let homepage_url = import_path
1159        .as_ref()
1160        .map(|m| truncate_field(format!("https://pkg.go.dev/{}", m)));
1161
1162    let vcs_url = import_path
1163        .as_ref()
1164        .map(|m| truncate_field(format!("https://{}.git", m)));
1165
1166    PackageData {
1167        package_type: Some(PACKAGE_TYPE),
1168        namespace,
1169        name,
1170        version: None,
1171        qualifiers: None,
1172        subpath: None,
1173        primary_language: Some("Go".to_string()),
1174        description: None,
1175        release_date: None,
1176        parties: Vec::new(),
1177        keywords: Vec::new(),
1178        homepage_url,
1179        download_url: None,
1180        size: None,
1181        sha1: None,
1182        md5: None,
1183        sha256: None,
1184        sha512: None,
1185        bug_tracking_url: None,
1186        code_view_url: None,
1187        vcs_url,
1188        copyright: None,
1189        holder: None,
1190        declared_license_expression: None,
1191        declared_license_expression_spdx: None,
1192        license_detections: Vec::new(),
1193        other_license_expression: None,
1194        other_license_expression_spdx: None,
1195        other_license_detections: Vec::new(),
1196        extracted_license_statement: None,
1197        notice_text: None,
1198        source_packages: Vec::new(),
1199        file_references: Vec::new(),
1200        is_private: false,
1201        is_virtual: false,
1202        extra_data,
1203        dependencies,
1204        repository_homepage_url: None,
1205        repository_download_url: None,
1206        api_data_url: None,
1207        datasource_id: Some(DatasourceId::Godeps),
1208        purl,
1209    }
1210}