Skip to main content

provenant/parsers/
go.rs

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