Skip to main content

provenant/parsers/
go.rs

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