Skip to main content

provenant/parsers/
go.rs

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