|
10 | 10 | require "dependabot/go_modules/go_work_parser" |
11 | 11 | require "dependabot/go_modules/replace_stubber" |
12 | 12 | require "dependabot/go_modules/resolvability_errors" |
| 13 | +require "dependabot/go_modules/version" |
13 | 14 |
|
14 | 15 | module Dependabot |
15 | 16 | module GoModules |
@@ -222,10 +223,12 @@ def update_files |
222 | 223 | substitute_all(substitutions.invert) |
223 | 224 | end |
224 | 225 |
|
225 | | - updated_go_sum = original_go_sum ? File.read("go.sum") : nil |
226 | 226 | updated_go_mod = File.read("go.mod") |
227 | 227 |
|
228 | | - { go_mod: updated_go_mod, go_sum: updated_go_sum } |
| 228 | + result = T.let({ go_mod: updated_go_mod }, T::Hash[Symbol, String]) |
| 229 | + result[:go_sum] = reconcile_go_sum(original_go_sum, File.read("go.sum")) if original_go_sum |
| 230 | + |
| 231 | + result |
229 | 232 | end |
230 | 233 | end |
231 | 234 |
|
@@ -417,6 +420,172 @@ def build_module_stubs(stub_paths) |
417 | 420 | end |
418 | 421 | end |
419 | 422 |
|
| 423 | + sig { params(original_go_sum: String, updated_go_sum: String).returns(String) } |
| 424 | + def reconcile_go_sum(original_go_sum, updated_go_sum) |
| 425 | + original_lines = original_go_sum.lines(chomp: true).reject(&:empty?) |
| 426 | + updated_lines = updated_go_sum.lines(chomp: true).reject(&:empty?) |
| 427 | + updated_set = updated_lines.to_set |
| 428 | + updated_modules = extract_module_path_versions(updated_lines) |
| 429 | + |
| 430 | + restored_lines = find_restorable_go_mod_lines(original_lines, updated_set, updated_modules) |
| 431 | + return updated_go_sum if restored_lines.empty? |
| 432 | + |
| 433 | + (updated_lines + restored_lines).sort! { |a, b| go_sum_line_compare(a, b) }.join("\n") + "\n" |
| 434 | + end |
| 435 | + |
| 436 | + sig do |
| 437 | + params( |
| 438 | + original_lines: T::Array[String], |
| 439 | + updated_set: T::Set[String], |
| 440 | + updated_modules: T::Hash[String, T::Set[String]] |
| 441 | + ).returns(T::Array[String]) |
| 442 | + end |
| 443 | + def find_restorable_go_mod_lines(original_lines, updated_set, updated_modules) |
| 444 | + original_zip_versions = build_original_zip_versions(original_lines) |
| 445 | + |
| 446 | + original_lines.filter_map do |line| |
| 447 | + next unless go_mod_checksum_line?(line) |
| 448 | + next if updated_set.include?(line) |
| 449 | + next unless restorable_line?(line, updated_modules, original_zip_versions) |
| 450 | + |
| 451 | + line |
| 452 | + end |
| 453 | + end |
| 454 | + |
| 455 | + sig do |
| 456 | + params( |
| 457 | + line: String, |
| 458 | + updated_modules: T::Hash[String, T::Set[String]], |
| 459 | + original_zip_versions: T::Hash[String, T::Set[String]] |
| 460 | + ).returns(T::Boolean) |
| 461 | + end |
| 462 | + def restorable_line?(line, updated_modules, original_zip_versions) |
| 463 | + module_path = go_sum_module_path(line) |
| 464 | + return false unless module_path |
| 465 | + return false if updated_dependency_names.include?(module_path) |
| 466 | + |
| 467 | + module_version = extract_module_version_from_go_mod_line(line) |
| 468 | + return false unless module_version |
| 469 | + |
| 470 | + version = T.must(module_version.split(/\s+/, 2).last) |
| 471 | + has_zip_in_original = original_zip_versions.fetch(module_path, nil)&.include?(version) |
| 472 | + |
| 473 | + if has_zip_in_original |
| 474 | + module_version_still_relevant?(module_path, module_version, updated_modules) |
| 475 | + else |
| 476 | + updated_modules.key?(module_path) |
| 477 | + end |
| 478 | + end |
| 479 | + |
| 480 | + # Builds a map of module_path → Set[versions] for entries that have a |
| 481 | + # zip hash (non /go.mod lines) in the original go.sum. |
| 482 | + sig { params(lines: T::Array[String]).returns(T::Hash[String, T::Set[String]]) } |
| 483 | + def build_original_zip_versions(lines) |
| 484 | + lines.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |line, map| |
| 485 | + next if go_mod_checksum_line?(line) |
| 486 | + |
| 487 | + parts = line.split(/\s+/, 3) |
| 488 | + next unless parts.length >= 2 |
| 489 | + |
| 490 | + path = T.must(parts[0]) |
| 491 | + version = T.must(parts[1]) |
| 492 | + map[path].add(version) |
| 493 | + end |
| 494 | + end |
| 495 | + |
| 496 | + sig { params(line: String).returns(T::Boolean) } |
| 497 | + def go_mod_checksum_line?(line) |
| 498 | + line.include?("/go.mod h1:") |
| 499 | + end |
| 500 | + |
| 501 | + sig { params(line: String).returns(T.nilable(String)) } |
| 502 | + def go_sum_module_path(line) |
| 503 | + line.split(/\s+/, 2).first |
| 504 | + end |
| 505 | + |
| 506 | + # Extracts "module/path vX.Y.Z" from a /go.mod checksum line |
| 507 | + sig { params(line: String).returns(T.nilable(String)) } |
| 508 | + def extract_module_version_from_go_mod_line(line) |
| 509 | + match = line.match(%r{^(\S+)\s+(\S+)/go\.mod\s}) |
| 510 | + return nil unless match |
| 511 | + |
| 512 | + "#{match[1]} #{match[2]}" |
| 513 | + end |
| 514 | + |
| 515 | + # Builds a map of module_path → Set[versions] from all lines in go.sum |
| 516 | + sig { params(lines: T::Array[String]).returns(T::Hash[String, T::Set[String]]) } |
| 517 | + def extract_module_path_versions(lines) |
| 518 | + lines.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |line, map| |
| 519 | + parts = line.split(/\s+/, 3) |
| 520 | + next unless parts.length >= 2 |
| 521 | + |
| 522 | + path = T.must(parts[0]) |
| 523 | + version = T.must(parts[1]).sub(%r{/go\.mod$}, "") |
| 524 | + map[path].add(version) |
| 525 | + end |
| 526 | + end |
| 527 | + |
| 528 | + # A module+version is still relevant if it still has an entry in the |
| 529 | + # updated go.sum (zip hash or another /go.mod line for the same version). |
| 530 | + # If the module path has no entry for this version, it was removed from |
| 531 | + # the graph (e.g., a transitive dep that was upgraded to a newer version). |
| 532 | + sig do |
| 533 | + params( |
| 534 | + module_path: String, |
| 535 | + module_version: String, |
| 536 | + updated_modules: T::Hash[String, T::Set[String]] |
| 537 | + ).returns(T::Boolean) |
| 538 | + end |
| 539 | + def module_version_still_relevant?(module_path, module_version, updated_modules) |
| 540 | + versions = updated_modules.fetch(module_path, nil) |
| 541 | + return false unless versions |
| 542 | + |
| 543 | + version = module_version.split(/\s+/, 2).last |
| 544 | + return false unless version |
| 545 | + |
| 546 | + versions.include?(version) |
| 547 | + end |
| 548 | + |
| 549 | + sig { returns(T::Set[String]) } |
| 550 | + def updated_dependency_names |
| 551 | + @updated_dependency_names ||= T.let(dependencies.to_set(&:name), T.nilable(T::Set[String])) |
| 552 | + end |
| 553 | + |
| 554 | + # Compares two go.sum lines using Go's module-aware sort order: |
| 555 | + # sort by module path, then semver version, then /go.mod suffix last. |
| 556 | + sig { params(line_a: String, line_b: String).returns(Integer) } |
| 557 | + def go_sum_line_compare(line_a, line_b) |
| 558 | + path_a, version_rest_a = line_a.split(/\s+/, 2) |
| 559 | + path_b, version_rest_b = line_b.split(/\s+/, 2) |
| 560 | + |
| 561 | + path_cmp = T.must((path_a || "") <=> (path_b || "")) |
| 562 | + return path_cmp unless path_cmp.zero? |
| 563 | + |
| 564 | + compare_go_versions(version_rest_a || "", version_rest_b || "") |
| 565 | + end |
| 566 | + |
| 567 | + # Compares version+suffix portions of go.sum lines using GoModules::Version. |
| 568 | + sig { params(ver_a: String, ver_b: String).returns(Integer) } |
| 569 | + def compare_go_versions(ver_a, ver_b) |
| 570 | + a_is_gomod = ver_a.include?("/go.mod") |
| 571 | + b_is_gomod = ver_b.include?("/go.mod") |
| 572 | + |
| 573 | + # Extract raw version token (e.g., "v0.6.0" from "v0.6.0/go.mod h1:...") |
| 574 | + raw_a = ver_a.split(%r{(/go\.mod)?\s}, 2).first || "" |
| 575 | + raw_b = ver_b.split(%r{(/go\.mod)?\s}, 2).first || "" |
| 576 | + |
| 577 | + ver_cmp = go_version_compare(raw_a, raw_b) |
| 578 | + return ver_cmp unless ver_cmp.zero? |
| 579 | + |
| 580 | + # Same version: zip hash line sorts before /go.mod line |
| 581 | + (a_is_gomod ? 1 : 0) <=> (b_is_gomod ? 1 : 0) |
| 582 | + end |
| 583 | + |
| 584 | + sig { params(ver_a: String, ver_b: String).returns(Integer) } |
| 585 | + def go_version_compare(ver_a, ver_b) |
| 586 | + T.must(Dependabot::GoModules::Version.new(ver_a) <=> Dependabot::GoModules::Version.new(ver_b)) |
| 587 | + end |
| 588 | + |
420 | 589 | # Given a go.mod file, find all `replace` directives pointing to a path |
421 | 590 | # on the local filesystem, and return an array of pairs mapping the |
422 | 591 | # original path to a hash of the path. |
|
0 commit comments