|
| 1 | +package lockfile |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "bytes" |
| 6 | + "context" |
| 7 | + "fmt" |
| 8 | + "io" |
| 9 | + "os" |
| 10 | + "path/filepath" |
| 11 | + "strings" |
| 12 | +) |
| 13 | + |
| 14 | +// GoModParser handles Go modules (go.mod + go.sum). |
| 15 | +// Implements FileParser because go.sum lives alongside go.mod on disk. |
| 16 | +type GoModParser struct{} |
| 17 | + |
| 18 | +func (p *GoModParser) Type() LockfileType { return TypeGoMod } |
| 19 | +func (p *GoModParser) Filenames() []string { return []string{"go.mod"} } |
| 20 | + |
| 21 | +// Parse parses go.mod from a reader. Integrity hashes are not populated |
| 22 | +// because go.sum is a separate file; use ParseFile for full results. |
| 23 | +func (p *GoModParser) Parse(ctx context.Context, r io.Reader) (*LockfileResult, error) { |
| 24 | + data, err := io.ReadAll(r) |
| 25 | + if err != nil { |
| 26 | + return nil, err |
| 27 | + } |
| 28 | + pkgs, err := parseGoMod(data) |
| 29 | + if err != nil { |
| 30 | + return nil, err |
| 31 | + } |
| 32 | + return &LockfileResult{Type: TypeGoMod, Packages: pkgs}, nil |
| 33 | +} |
| 34 | + |
| 35 | +// ParseFile parses go.mod and the sibling go.sum to populate integrity hashes. |
| 36 | +func (p *GoModParser) ParseFile(ctx context.Context, path string) (*LockfileResult, error) { |
| 37 | + data, err := os.ReadFile(path) |
| 38 | + if err != nil { |
| 39 | + return nil, fmt.Errorf("reading go.mod: %w", err) |
| 40 | + } |
| 41 | + pkgs, err := parseGoMod(data) |
| 42 | + if err != nil { |
| 43 | + return nil, err |
| 44 | + } |
| 45 | + |
| 46 | + // Attempt to read go.sum in the same directory. |
| 47 | + sumPath := filepath.Join(filepath.Dir(path), "go.sum") |
| 48 | + if sumData, err := os.ReadFile(sumPath); err == nil { |
| 49 | + hashes := parseGoSum(sumData) |
| 50 | + for i := range pkgs { |
| 51 | + key := pkgs[i].Name + " " + pkgs[i].Version |
| 52 | + if h, ok := hashes[key]; ok { |
| 53 | + pkgs[i].Integrity = h |
| 54 | + } |
| 55 | + } |
| 56 | + } |
| 57 | + |
| 58 | + return &LockfileResult{Type: TypeGoMod, Packages: pkgs}, nil |
| 59 | +} |
| 60 | + |
| 61 | +type goModRequire struct { |
| 62 | + path string |
| 63 | + version string |
| 64 | +} |
| 65 | + |
| 66 | +type goModReplace struct { |
| 67 | + oldPath string |
| 68 | + oldVersion string // may be empty (matches any version) |
| 69 | + newPath string |
| 70 | + newVersion string // empty for local path replacements |
| 71 | +} |
| 72 | + |
| 73 | +func parseGoMod(data []byte) ([]Package, error) { |
| 74 | + scanner := bufio.NewScanner(bytes.NewReader(data)) |
| 75 | + // Support larger go.mod files (some are big). |
| 76 | + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| 77 | + |
| 78 | + var requires []goModRequire |
| 79 | + var replaces []goModReplace |
| 80 | + |
| 81 | + inRequire := false |
| 82 | + inReplace := false |
| 83 | + |
| 84 | + for scanner.Scan() { |
| 85 | + raw := scanner.Text() |
| 86 | + line := stripGoModComment(raw) |
| 87 | + line = strings.TrimSpace(line) |
| 88 | + if line == "" { |
| 89 | + continue |
| 90 | + } |
| 91 | + |
| 92 | + if inRequire { |
| 93 | + if line == ")" { |
| 94 | + inRequire = false |
| 95 | + continue |
| 96 | + } |
| 97 | + if req, ok := parseGoModRequireLine(line); ok { |
| 98 | + requires = append(requires, req) |
| 99 | + } |
| 100 | + continue |
| 101 | + } |
| 102 | + if inReplace { |
| 103 | + if line == ")" { |
| 104 | + inReplace = false |
| 105 | + continue |
| 106 | + } |
| 107 | + if rep, ok := parseGoModReplaceLine(line); ok { |
| 108 | + replaces = append(replaces, rep) |
| 109 | + } |
| 110 | + continue |
| 111 | + } |
| 112 | + |
| 113 | + switch { |
| 114 | + case strings.HasPrefix(line, "require ("): |
| 115 | + inRequire = true |
| 116 | + case strings.HasPrefix(line, "require "): |
| 117 | + if req, ok := parseGoModRequireLine(strings.TrimPrefix(line, "require ")); ok { |
| 118 | + requires = append(requires, req) |
| 119 | + } |
| 120 | + case strings.HasPrefix(line, "replace ("): |
| 121 | + inReplace = true |
| 122 | + case strings.HasPrefix(line, "replace "): |
| 123 | + if rep, ok := parseGoModReplaceLine(strings.TrimPrefix(line, "replace ")); ok { |
| 124 | + replaces = append(replaces, rep) |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + if err := scanner.Err(); err != nil { |
| 129 | + return nil, err |
| 130 | + } |
| 131 | + |
| 132 | + // Apply replacements. |
| 133 | + result := make([]Package, 0, len(requires)) |
| 134 | + for _, req := range requires { |
| 135 | + path, version := req.path, req.version |
| 136 | + for _, rep := range replaces { |
| 137 | + if rep.oldPath != path { |
| 138 | + continue |
| 139 | + } |
| 140 | + if rep.oldVersion != "" && rep.oldVersion != version { |
| 141 | + continue |
| 142 | + } |
| 143 | + // Skip local path replacements (no version on new side). |
| 144 | + if rep.newVersion == "" { |
| 145 | + continue |
| 146 | + } |
| 147 | + path = rep.newPath |
| 148 | + version = rep.newVersion |
| 149 | + break |
| 150 | + } |
| 151 | + result = append(result, Package{Name: path, Version: version}) |
| 152 | + } |
| 153 | + return result, nil |
| 154 | +} |
| 155 | + |
| 156 | +// stripGoModComment removes // ... comments from a line, preserving content before. |
| 157 | +func stripGoModComment(line string) string { |
| 158 | + if idx := strings.Index(line, "//"); idx >= 0 { |
| 159 | + return line[:idx] |
| 160 | + } |
| 161 | + return line |
| 162 | +} |
| 163 | + |
| 164 | +// parseGoModRequireLine parses e.g. `github.com/foo/bar v1.2.3`. |
| 165 | +func parseGoModRequireLine(line string) (goModRequire, bool) { |
| 166 | + fields := strings.Fields(line) |
| 167 | + if len(fields) < 2 { |
| 168 | + return goModRequire{}, false |
| 169 | + } |
| 170 | + return goModRequire{path: fields[0], version: fields[1]}, true |
| 171 | +} |
| 172 | + |
| 173 | +// parseGoModReplaceLine parses replace directive lines: |
| 174 | +// |
| 175 | +// old => new v1.2.3 |
| 176 | +// old v1.0.0 => new v1.2.3 |
| 177 | +// old => ../local/path |
| 178 | +func parseGoModReplaceLine(line string) (goModReplace, bool) { |
| 179 | + parts := strings.SplitN(line, "=>", 2) |
| 180 | + if len(parts) != 2 { |
| 181 | + return goModReplace{}, false |
| 182 | + } |
| 183 | + left := strings.Fields(strings.TrimSpace(parts[0])) |
| 184 | + right := strings.Fields(strings.TrimSpace(parts[1])) |
| 185 | + if len(left) == 0 || len(right) == 0 { |
| 186 | + return goModReplace{}, false |
| 187 | + } |
| 188 | + |
| 189 | + rep := goModReplace{oldPath: left[0]} |
| 190 | + if len(left) >= 2 { |
| 191 | + rep.oldVersion = left[1] |
| 192 | + } |
| 193 | + rep.newPath = right[0] |
| 194 | + if len(right) >= 2 { |
| 195 | + rep.newVersion = right[1] |
| 196 | + } |
| 197 | + return rep, true |
| 198 | +} |
| 199 | + |
| 200 | +// parseGoSum parses go.sum and returns a map of "<module> <version>" -> hash. |
| 201 | +// Only the module-zip hashes (not the /go.mod lines) are used. |
| 202 | +func parseGoSum(data []byte) map[string]string { |
| 203 | + out := make(map[string]string) |
| 204 | + scanner := bufio.NewScanner(bytes.NewReader(data)) |
| 205 | + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| 206 | + for scanner.Scan() { |
| 207 | + fields := strings.Fields(scanner.Text()) |
| 208 | + if len(fields) != 3 { |
| 209 | + continue |
| 210 | + } |
| 211 | + path, version, hash := fields[0], fields[1], fields[2] |
| 212 | + if strings.HasSuffix(version, "/go.mod") { |
| 213 | + continue |
| 214 | + } |
| 215 | + out[path+" "+version] = hash |
| 216 | + } |
| 217 | + return out |
| 218 | +} |
0 commit comments