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 is_local_path = is_local_go_replace_path(new_module);
381    let purl = if is_local_path {
382        None
383    } else {
384        create_golang_purl(new_module, new_version.as_deref())
385    };
386
387    let mut extra = std::collections::HashMap::new();
388    extra.insert(
389        "replace_old".to_string(),
390        serde_json::Value::String(truncate_field(old_module.to_string())),
391    );
392    extra.insert(
393        "replace_new".to_string(),
394        serde_json::Value::String(truncate_field(new_module.to_string())),
395    );
396    if let Some(ref v) = new_version {
397        extra.insert(
398            "replace_version".to_string(),
399            serde_json::Value::String(v.clone()),
400        );
401    }
402    if let Some(ov) = old_version {
403        extra.insert(
404            "replace_old_version".to_string(),
405            serde_json::Value::String(truncate_field(ov.to_string())),
406        );
407    }
408    if is_local_path {
409        extra.insert(
410            "replace_local_path".to_string(),
411            serde_json::Value::Bool(true),
412        );
413    }
414
415    Some(Dependency {
416        purl,
417        extracted_requirement: new_version,
418        scope: Some("replace".to_string()),
419        is_runtime: Some(true),
420        is_optional: Some(false),
421        is_pinned: Some(false),
422        is_direct: Some(true),
423        resolved_package: None,
424        extra_data: Some(extra),
425    })
426}
427
428/// Parses a retract value which can be a single version or a range `[v1, v2]`.
429fn parse_retract_value(value: &str) -> Vec<String> {
430    let trimmed = value.trim();
431    if trimmed.is_empty() {
432        return Vec::new();
433    }
434
435    if trimmed.starts_with('[') && trimmed.ends_with(']') {
436        let inner = &trimmed[1..trimmed.len() - 1];
437        inner
438            .split(',')
439            .map(|s| s.trim().to_string())
440            .filter(|s| !s.is_empty())
441            .collect()
442    } else {
443        vec![trimmed.to_string()]
444    }
445}
446
447pub(crate) fn split_module_path(path: &str) -> (Option<String>, String) {
448    match path.rfind('/') {
449        Some(idx) => {
450            let namespace = &path[..idx];
451            let name = &path[idx + 1..];
452            (
453                Some(truncate_field(namespace.to_string())),
454                truncate_field(name.to_string()),
455            )
456        }
457        None => (None, truncate_field(path.to_string())),
458    }
459}
460
461/// Strips inline comments (everything after `//`) from a line.
462///
463/// Preserves the content before the comment marker.
464fn strip_comment(line: &str) -> &str {
465    match line.find("//") {
466        Some(idx) => &line[..idx],
467        None => line,
468    }
469}
470
471/// Creates a PURL for a Go module.
472///
473/// Format: `pkg:golang/namespace/name@version`
474/// The module path is split into namespace and name for PURL construction.
475pub(crate) fn create_golang_purl(module_path: &str, version: Option<&str>) -> Option<String> {
476    let (namespace, name) = split_module_path(module_path);
477
478    let mut purl = match PackageUrl::new(PACKAGE_TYPE.as_str(), &name) {
479        Ok(p) => p,
480        Err(e) => {
481            warn!(
482                "Failed to create PURL for golang module '{}': {}",
483                module_path, e
484            );
485            return None;
486        }
487    };
488
489    if let Some(ns) = &namespace
490        && let Err(e) = purl.with_namespace(ns)
491    {
492        warn!(
493            "Failed to set namespace '{}' for golang module '{}': {}",
494            ns, module_path, e
495        );
496        return None;
497    }
498
499    if let Some(v) = version
500        && let Err(e) = purl.with_version(v)
501    {
502        warn!(
503            "Failed to set version '{}' for golang module '{}': {}",
504            v, module_path, e
505        );
506        return None;
507    }
508
509    Some(purl.to_string())
510}
511
512/// Returns a default empty PackageData for Go modules.
513fn default_package_data() -> PackageData {
514    PackageData {
515        package_type: Some(PACKAGE_TYPE),
516        primary_language: Some("Go".to_string()),
517        ..Default::default()
518    }
519}
520
521fn default_go_mod_package_data() -> PackageData {
522    PackageData {
523        datasource_id: Some(DatasourceId::GoMod),
524        ..default_package_data()
525    }
526}
527
528fn default_go_sum_package_data() -> PackageData {
529    PackageData {
530        datasource_id: Some(DatasourceId::GoSum),
531        ..default_package_data()
532    }
533}
534
535fn default_go_work_package_data() -> PackageData {
536    PackageData {
537        datasource_id: Some(DatasourceId::GoWork),
538        ..default_package_data()
539    }
540}
541
542fn default_godeps_package_data() -> PackageData {
543    PackageData {
544        datasource_id: Some(DatasourceId::Godeps),
545        ..default_package_data()
546    }
547}
548
549// ============================================================================
550// GoSumParser
551// ============================================================================
552
553pub struct GoSumParser;
554
555impl PackageParser for GoSumParser {
556    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
557
558    fn metadata() -> Vec<ParserMetadata> {
559        vec![ParserMetadata {
560            description: "Go go.sum checksum database",
561            file_patterns: &["**/go.sum"],
562            package_type: "golang",
563            primary_language: "Go",
564            documentation_url: Some("https://go.dev/ref/mod#go-sum-files"),
565        }]
566    }
567
568    fn extract_packages(path: &Path) -> Vec<PackageData> {
569        let content = match read_file_to_string(path, None) {
570            Ok(c) => c,
571            Err(e) => {
572                warn!("Failed to read go.sum at {:?}: {}", path, e);
573                return vec![default_go_sum_package_data()];
574            }
575        };
576
577        vec![parse_go_sum(&content)]
578    }
579
580    fn is_match(path: &Path) -> bool {
581        path.file_name().is_some_and(|name| name == "go.sum")
582    }
583}
584
585pub fn parse_go_sum(content: &str) -> PackageData {
586    let mut dependencies = Vec::new();
587    let mut seen = HashSet::new();
588
589    for line in content.lines().take(MAX_ITERATION_COUNT) {
590        let trimmed = line.trim();
591        if trimmed.is_empty() {
592            continue;
593        }
594
595        let parts: Vec<&str> = trimmed.split_whitespace().collect();
596        if parts.len() < 3 || !parts[2].starts_with("h1:") {
597            continue;
598        }
599
600        let module = parts[0];
601        let raw_version = parts[1];
602
603        let version = raw_version.strip_suffix("/go.mod").unwrap_or(raw_version);
604
605        let key = format!("{}@{}", module, version);
606        if seen.contains(&key) {
607            continue;
608        }
609        seen.insert(key);
610
611        let purl = create_golang_purl(module, Some(version));
612
613        dependencies.push(Dependency {
614            purl,
615            extracted_requirement: Some(truncate_field(version.to_string())),
616            scope: Some("dependency".to_string()),
617            is_runtime: Some(true),
618            is_optional: Some(false),
619            is_pinned: Some(true),
620            is_direct: None,
621            resolved_package: None,
622            extra_data: None,
623        });
624    }
625
626    PackageData {
627        package_type: Some(PACKAGE_TYPE),
628        namespace: None,
629        name: None,
630        version: None,
631        qualifiers: None,
632        subpath: None,
633        primary_language: Some("Go".to_string()),
634        description: None,
635        release_date: None,
636        parties: Vec::new(),
637        keywords: Vec::new(),
638        homepage_url: None,
639        download_url: None,
640        size: None,
641        sha1: None,
642        md5: None,
643        sha256: None,
644        sha512: None,
645        bug_tracking_url: None,
646        code_view_url: None,
647        vcs_url: None,
648        copyright: None,
649        holder: None,
650        declared_license_expression: None,
651        declared_license_expression_spdx: None,
652        license_detections: Vec::new(),
653        other_license_expression: None,
654        other_license_expression_spdx: None,
655        other_license_detections: Vec::new(),
656        extracted_license_statement: None,
657        notice_text: None,
658        source_packages: Vec::new(),
659        file_references: Vec::new(),
660        is_private: false,
661        is_virtual: false,
662        extra_data: None,
663        dependencies,
664        repository_homepage_url: None,
665        repository_download_url: None,
666        api_data_url: None,
667        datasource_id: Some(DatasourceId::GoSum),
668        purl: None,
669    }
670}
671
672pub struct GoWorkParser;
673
674impl PackageParser for GoWorkParser {
675    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
676
677    fn metadata() -> Vec<ParserMetadata> {
678        vec![ParserMetadata {
679            description: "Go go.work workspace file",
680            file_patterns: &["**/go.work"],
681            package_type: "golang",
682            primary_language: "Go",
683            documentation_url: Some("https://go.dev/ref/mod#go-work-files"),
684        }]
685    }
686
687    fn extract_packages(path: &Path) -> Vec<PackageData> {
688        let content = match read_file_to_string(path, None) {
689            Ok(c) => c,
690            Err(e) => {
691                warn!("Failed to read go.work at {:?}: {}", path, e);
692                return vec![default_go_work_package_data()];
693            }
694        };
695
696        vec![parse_go_work(&content, path)]
697    }
698
699    fn is_match(path: &Path) -> bool {
700        path.file_name().is_some_and(|name| name == "go.work")
701    }
702}
703
704pub fn parse_go_work(content: &str, work_path: &Path) -> PackageData {
705    let mut go_version: Option<String> = None;
706    let mut toolchain: Option<String> = None;
707    let mut use_paths: Vec<String> = Vec::new();
708    let mut replace_deps: Vec<Dependency> = Vec::new();
709    let mut unresolved_use_paths: Vec<String> = Vec::new();
710    let mut block_state = BlockState::None;
711
712    for line in content.lines().take(MAX_ITERATION_COUNT) {
713        let trimmed = line.trim();
714
715        if trimmed.is_empty() || trimmed.starts_with("//") {
716            continue;
717        }
718
719        if trimmed == ")" {
720            block_state = BlockState::None;
721            continue;
722        }
723
724        if block_state != BlockState::None {
725            match block_state {
726                BlockState::Require => {
727                    let use_path = extract_single_go_token(trimmed);
728                    if let Some(use_path) = use_path.filter(|path| !path.is_empty()) {
729                        use_paths.push(truncate_field(use_path));
730                    }
731                }
732                BlockState::Replace => {
733                    if let Some(dep) = parse_workspace_replace_line(trimmed) {
734                        replace_deps.push(dep);
735                    }
736                }
737                _ => {}
738            }
739            continue;
740        }
741
742        if trimmed.starts_with("use") && trimmed.contains('(') {
743            block_state = BlockState::Require;
744            continue;
745        }
746        if trimmed.starts_with("replace") && trimmed.contains('(') {
747            block_state = BlockState::Replace;
748            continue;
749        }
750
751        if let Some(version) = trimmed.strip_prefix("go ") {
752            let version = strip_comment(version).trim();
753            if !version.is_empty() {
754                go_version = Some(truncate_field(version.to_string()));
755            }
756            continue;
757        }
758
759        if let Some(tc) = trimmed.strip_prefix("toolchain ") {
760            let tc = strip_comment(tc).trim();
761            if !tc.is_empty() {
762                toolchain = Some(truncate_field(tc.to_string()));
763            }
764            continue;
765        }
766
767        if let Some(rest) = trimmed.strip_prefix("use ") {
768            let use_path = extract_single_go_token(rest);
769            if let Some(use_path) = use_path.filter(|path| !path.is_empty()) {
770                use_paths.push(truncate_field(use_path));
771            }
772            continue;
773        }
774
775        if let Some(rest) = trimmed.strip_prefix("replace ") {
776            if let Some(dep) = parse_workspace_replace_line(rest) {
777                replace_deps.push(dep);
778            }
779            continue;
780        }
781    }
782
783    if go_version.is_none() || use_paths.is_empty() {
784        warn!("Invalid go.work: missing go directive or use directive");
785        return default_go_work_package_data();
786    }
787
788    let (mut dependencies, unresolved) = resolve_workspace_use_dependencies(work_path, &use_paths);
789    dependencies.extend(replace_deps);
790    unresolved_use_paths.extend(unresolved);
791
792    let mut extra_data = HashMap::new();
793    if let Some(v) = go_version {
794        extra_data.insert("go_version".to_string(), serde_json::Value::String(v));
795    }
796    if let Some(tc) = toolchain {
797        extra_data.insert("toolchain".to_string(), serde_json::Value::String(tc));
798    }
799    extra_data.insert(
800        "use_paths".to_string(),
801        serde_json::Value::Array(
802            use_paths
803                .iter()
804                .cloned()
805                .map(serde_json::Value::String)
806                .collect(),
807        ),
808    );
809    if !unresolved_use_paths.is_empty() {
810        extra_data.insert(
811            "unresolved_use_paths".to_string(),
812            serde_json::Value::Array(
813                unresolved_use_paths
814                    .into_iter()
815                    .map(serde_json::Value::String)
816                    .collect(),
817            ),
818        );
819    }
820
821    PackageData {
822        package_type: Some(PACKAGE_TYPE),
823        namespace: None,
824        name: None,
825        version: None,
826        qualifiers: None,
827        subpath: None,
828        primary_language: Some("Go".to_string()),
829        description: None,
830        release_date: None,
831        parties: Vec::new(),
832        keywords: Vec::new(),
833        homepage_url: None,
834        download_url: None,
835        size: None,
836        sha1: None,
837        md5: None,
838        sha256: None,
839        sha512: None,
840        bug_tracking_url: None,
841        code_view_url: None,
842        vcs_url: None,
843        copyright: None,
844        holder: None,
845        declared_license_expression: None,
846        declared_license_expression_spdx: None,
847        license_detections: Vec::new(),
848        other_license_expression: None,
849        other_license_expression_spdx: None,
850        other_license_detections: Vec::new(),
851        extracted_license_statement: None,
852        notice_text: None,
853        source_packages: Vec::new(),
854        file_references: Vec::new(),
855        is_private: false,
856        is_virtual: false,
857        extra_data: Some(extra_data),
858        dependencies,
859        repository_homepage_url: None,
860        repository_download_url: None,
861        api_data_url: None,
862        datasource_id: Some(DatasourceId::GoWork),
863        purl: None,
864    }
865}
866
867fn resolve_workspace_use_dependencies(
868    work_path: &Path,
869    use_paths: &[String],
870) -> (Vec<Dependency>, Vec<String>) {
871    let Some(base_dir) = work_path.parent() else {
872        return (Vec::new(), use_paths.to_vec());
873    };
874
875    let mut dependencies = Vec::new();
876    let mut unresolved = Vec::new();
877
878    for use_path in use_paths.iter().take(MAX_ITERATION_COUNT) {
879        let go_mod_path = base_dir.join(use_path).join("go.mod");
880        let module_path = read_file_to_string(&go_mod_path, None)
881            .ok()
882            .and_then(|content| extract_module_path_from_go_mod(&content));
883
884        let purl = module_path
885            .as_deref()
886            .and_then(|module_path| create_golang_purl(module_path, None));
887
888        if purl.is_none() {
889            unresolved.push(use_path.clone());
890            continue;
891        }
892
893        let mut extra_data = HashMap::new();
894        extra_data.insert(
895            "workspace_path".to_string(),
896            serde_json::Value::String(truncate_field(use_path.clone())),
897        );
898        if let Some(module_path) = module_path {
899            extra_data.insert(
900                "workspace_module_path".to_string(),
901                serde_json::Value::String(truncate_field(module_path)),
902            );
903        }
904
905        dependencies.push(Dependency {
906            purl,
907            extracted_requirement: Some(truncate_field(use_path.clone())),
908            scope: Some("use".to_string()),
909            is_runtime: Some(true),
910            is_optional: Some(false),
911            is_pinned: Some(false),
912            is_direct: Some(true),
913            resolved_package: None,
914            extra_data: Some(extra_data),
915        });
916    }
917
918    (dependencies, unresolved)
919}
920
921fn extract_module_path_from_go_mod(content: &str) -> Option<String> {
922    for line in content.lines().take(MAX_ITERATION_COUNT) {
923        let trimmed = line.trim();
924        if let Some(module_path) = trimmed.strip_prefix("module ") {
925            let module_path = strip_comment(module_path).trim();
926            if !module_path.is_empty() {
927                return Some(truncate_field(module_path.to_string()));
928            }
929        }
930    }
931    None
932}
933
934fn parse_workspace_replace_line(line: &str) -> Option<Dependency> {
935    let line = strip_comment(line).trim();
936    let parts: Vec<&str> = line.splitn(2, "=>").collect();
937    if parts.len() != 2 {
938        return None;
939    }
940
941    let old_parts = parse_go_tokens(parts[0]);
942    let new_parts = parse_go_tokens(parts[1]);
943    if old_parts.is_empty() || new_parts.is_empty() {
944        return None;
945    }
946
947    let old_module = old_parts[0].as_str();
948    let old_version = old_parts.get(1).map(|s| s.as_str());
949    let new_module = new_parts[0].as_str();
950    let new_version = new_parts.get(1).map(|s| truncate_field(s.clone()));
951    let is_local_path = is_local_go_replace_path(new_module);
952
953    let purl = if is_local_path {
954        None
955    } else {
956        create_golang_purl(new_module, new_version.as_deref())
957    };
958
959    let mut extra = std::collections::HashMap::new();
960    extra.insert(
961        "replace_old".to_string(),
962        serde_json::Value::String(truncate_field(old_module.to_string())),
963    );
964    extra.insert(
965        "replace_new".to_string(),
966        serde_json::Value::String(truncate_field(new_module.to_string())),
967    );
968    if let Some(ref v) = new_version {
969        extra.insert(
970            "replace_version".to_string(),
971            serde_json::Value::String(v.clone()),
972        );
973    }
974    if let Some(ov) = old_version {
975        extra.insert(
976            "replace_old_version".to_string(),
977            serde_json::Value::String(truncate_field(ov.to_string())),
978        );
979    }
980    if is_local_path {
981        extra.insert(
982            "replace_local_path".to_string(),
983            serde_json::Value::Bool(true),
984        );
985    }
986
987    Some(Dependency {
988        purl,
989        extracted_requirement: new_version,
990        scope: Some("replace".to_string()),
991        is_runtime: Some(true),
992        is_optional: Some(false),
993        is_pinned: Some(false),
994        is_direct: Some(true),
995        resolved_package: None,
996        extra_data: Some(extra),
997    })
998}
999
1000fn is_local_go_replace_path(module: &str) -> bool {
1001    module.starts_with("./")
1002        || module.starts_with("../")
1003        || module.starts_with('/')
1004        || module.starts_with('~')
1005}
1006
1007fn extract_single_go_token(value: &str) -> Option<String> {
1008    parse_go_tokens(value).into_iter().next()
1009}
1010
1011fn parse_go_tokens(value: &str) -> Vec<String> {
1012    let mut tokens = Vec::new();
1013    let mut current = String::new();
1014    let mut quote: Option<char> = None;
1015    let mut chars = value.chars().peekable();
1016
1017    while let Some(ch) = chars.next() {
1018        if let Some(active_quote) = quote {
1019            if ch == active_quote {
1020                quote = None;
1021                continue;
1022            }
1023
1024            if active_quote == '"' && ch == '\\' {
1025                if let Some(next) = chars.next() {
1026                    current.push(next);
1027                }
1028                continue;
1029            }
1030
1031            current.push(ch);
1032            continue;
1033        }
1034
1035        match ch {
1036            '"' | '`' => {
1037                quote = Some(ch);
1038            }
1039            c if c.is_whitespace() => {
1040                if !current.is_empty() {
1041                    tokens.push(std::mem::take(&mut current));
1042                }
1043            }
1044            _ => current.push(ch),
1045        }
1046    }
1047
1048    if !current.is_empty() {
1049        tokens.push(current);
1050    }
1051
1052    tokens
1053}
1054
1055// ============================================================================
1056// GodepsParser
1057// ============================================================================
1058
1059pub struct GodepsParser;
1060
1061impl PackageParser for GodepsParser {
1062    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
1063
1064    fn metadata() -> Vec<ParserMetadata> {
1065        vec![ParserMetadata {
1066            description: "Go Godeps.json legacy dependency file",
1067            file_patterns: &["**/Godeps.json"],
1068            package_type: "golang",
1069            primary_language: "Go",
1070            documentation_url: None,
1071        }]
1072    }
1073
1074    fn extract_packages(path: &Path) -> Vec<PackageData> {
1075        let content = match read_file_to_string(path, None) {
1076            Ok(c) => c,
1077            Err(e) => {
1078                warn!("Failed to read Godeps.json at {:?}: {}", path, e);
1079                return vec![default_godeps_package_data()];
1080            }
1081        };
1082
1083        vec![parse_godeps_json(&content)]
1084    }
1085
1086    fn is_match(path: &Path) -> bool {
1087        path.file_name().is_some_and(|name| name == "Godeps.json")
1088    }
1089}
1090
1091pub fn parse_godeps_json(content: &str) -> PackageData {
1092    let json: serde_json::Value = match serde_json::from_str(content) {
1093        Ok(j) => j,
1094        Err(e) => {
1095            warn!("Failed to parse Godeps.json: {}", e);
1096            return default_godeps_package_data();
1097        }
1098    };
1099
1100    let import_path = json
1101        .get("ImportPath")
1102        .and_then(|v| v.as_str())
1103        .map(|s| truncate_field(s.to_string()));
1104
1105    let go_version = json
1106        .get("GoVersion")
1107        .and_then(|v| v.as_str())
1108        .map(|s| truncate_field(s.to_string()));
1109
1110    let (namespace, name) = match &import_path {
1111        Some(ip) => {
1112            let (ns, n) = split_module_path(ip);
1113            (ns, Some(n))
1114        }
1115        None => (None, None),
1116    };
1117
1118    let purl = import_path
1119        .as_deref()
1120        .and_then(|ip| create_golang_purl(ip, None));
1121
1122    let mut dependencies = Vec::new();
1123
1124    if let Some(deps) = json.get("Deps").and_then(|v| v.as_array()) {
1125        for dep in deps.iter().take(MAX_ITERATION_COUNT) {
1126            let dep_import_path = dep.get("ImportPath").and_then(|v| v.as_str());
1127            let rev = dep.get("Rev").and_then(|v| v.as_str());
1128
1129            if let Some(path) = dep_import_path {
1130                let dep_purl = create_golang_purl(path, None);
1131
1132                dependencies.push(Dependency {
1133                    purl: dep_purl,
1134                    extracted_requirement: rev.map(|s| truncate_field(s.to_string())),
1135                    scope: Some("Deps".to_string()),
1136                    is_runtime: Some(true),
1137                    is_optional: Some(false),
1138                    is_pinned: Some(false),
1139                    is_direct: None,
1140                    resolved_package: None,
1141                    extra_data: None,
1142                });
1143            }
1144        }
1145    }
1146
1147    let extra_data = go_version.map(|v| {
1148        let mut map = HashMap::new();
1149        map.insert("go_version".to_string(), serde_json::Value::String(v));
1150        map
1151    });
1152
1153    let homepage_url = import_path
1154        .as_ref()
1155        .map(|m| truncate_field(format!("https://pkg.go.dev/{}", m)));
1156
1157    let vcs_url = import_path
1158        .as_ref()
1159        .map(|m| truncate_field(format!("https://{}.git", m)));
1160
1161    PackageData {
1162        package_type: Some(PACKAGE_TYPE),
1163        namespace,
1164        name,
1165        version: None,
1166        qualifiers: None,
1167        subpath: None,
1168        primary_language: Some("Go".to_string()),
1169        description: None,
1170        release_date: None,
1171        parties: Vec::new(),
1172        keywords: Vec::new(),
1173        homepage_url,
1174        download_url: None,
1175        size: None,
1176        sha1: None,
1177        md5: None,
1178        sha256: None,
1179        sha512: None,
1180        bug_tracking_url: None,
1181        code_view_url: None,
1182        vcs_url,
1183        copyright: None,
1184        holder: None,
1185        declared_license_expression: None,
1186        declared_license_expression_spdx: None,
1187        license_detections: Vec::new(),
1188        other_license_expression: None,
1189        other_license_expression_spdx: None,
1190        other_license_detections: Vec::new(),
1191        extracted_license_statement: None,
1192        notice_text: None,
1193        source_packages: Vec::new(),
1194        file_references: Vec::new(),
1195        is_private: false,
1196        is_virtual: false,
1197        extra_data,
1198        dependencies,
1199        repository_homepage_url: None,
1200        repository_download_url: None,
1201        api_data_url: None,
1202        datasource_id: Some(DatasourceId::Godeps),
1203        purl,
1204    }
1205}