Skip to content

Commit 6fc36cd

Browse files
v-HaripriyaCCopilot
andcommitted
fix(go_modules): preserve unrelated go.mod checksums in go.sum
When go get updates a dependency, it may remove /go.mod checksum lines from go.sum for unrelated modules that are still in the dependency graph. This causes noisy diffs that go mod tidy would revert. Add reconcile_go_sum to restore missing /go.mod checksum lines when: - The line belongs to a module not being updated - The same module+version still has a zip hash entry in the updated file The restore uses Dependabot::GoModules::Version for semver-aware sorting to produce canonical go.sum ordering. When no lines need restoring, the updated go.sum is returned unchanged to avoid unnecessary rewrites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ef2d6a4 commit 6fc36cd

2 files changed

Lines changed: 185 additions & 2 deletions

File tree

go_modules/lib/dependabot/go_modules/file_updater/go_mod_updater.rb

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
require "dependabot/go_modules/go_work_parser"
1111
require "dependabot/go_modules/replace_stubber"
1212
require "dependabot/go_modules/resolvability_errors"
13+
require "dependabot/go_modules/version"
1314

1415
module Dependabot
1516
module GoModules
@@ -222,10 +223,12 @@ def update_files
222223
substitute_all(substitutions.invert)
223224
end
224225

225-
updated_go_sum = original_go_sum ? File.read("go.sum") : nil
226+
updated_go_sum = T.let(nil, T.nilable(String))
227+
updated_go_sum = reconcile_go_sum(original_go_sum, File.read("go.sum")) if original_go_sum
228+
226229
updated_go_mod = File.read("go.mod")
227230

228-
{ go_mod: updated_go_mod, go_sum: updated_go_sum }
231+
{ go_mod: updated_go_mod, go_sum: updated_go_sum }.compact
229232
end
230233
end
231234

@@ -417,6 +420,115 @@ def build_module_stubs(stub_paths)
417420
end
418421
end
419422

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_module_versions = extract_module_versions(updated_lines)
429+
430+
restored_lines = find_restorable_go_mod_lines(original_lines, updated_set, updated_module_versions)
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_module_versions: T::Set[String]
441+
).returns(T::Array[String])
442+
end
443+
def find_restorable_go_mod_lines(original_lines, updated_set, updated_module_versions)
444+
original_lines.filter_map do |line|
445+
next unless go_mod_checksum_line?(line)
446+
next if updated_set.include?(line)
447+
448+
module_path = go_sum_module_path(line)
449+
next unless module_path
450+
next if updated_dependency_names.include?(module_path)
451+
452+
module_version = extract_module_version_from_go_mod_line(line)
453+
next unless module_version
454+
next unless updated_module_versions.include?(module_version)
455+
456+
line
457+
end
458+
end
459+
460+
sig { params(line: String).returns(T::Boolean) }
461+
def go_mod_checksum_line?(line)
462+
line.include?("/go.mod h1:")
463+
end
464+
465+
sig { params(line: String).returns(T.nilable(String)) }
466+
def go_sum_module_path(line)
467+
line.split(/\s+/, 2).first
468+
end
469+
470+
# Extracts "module/path vX.Y.Z" from a /go.mod checksum line
471+
sig { params(line: String).returns(T.nilable(String)) }
472+
def extract_module_version_from_go_mod_line(line)
473+
match = line.match(%r{^(\S+)\s+(\S+)/go\.mod\s})
474+
return nil unless match
475+
476+
"#{match[1]} #{match[2]}"
477+
end
478+
479+
# Builds a set of "module/path vX.Y.Z" pairs from non-/go.mod lines in go.sum
480+
sig { params(lines: T::Array[String]).returns(T::Set[String]) }
481+
def extract_module_versions(lines)
482+
lines.each_with_object(Set.new) do |line, set|
483+
next if go_mod_checksum_line?(line)
484+
485+
parts = line.split(/\s+/, 3)
486+
next unless parts.length >= 2
487+
488+
set.add("#{parts[0]} #{parts[1]}")
489+
end
490+
end
491+
492+
sig { returns(T::Set[String]) }
493+
def updated_dependency_names
494+
@updated_dependency_names ||= T.let(dependencies.to_set(&:name), T.nilable(T::Set[String]))
495+
end
496+
497+
# Compares two go.sum lines using Go's module-aware sort order:
498+
# sort by module path, then semver version, then /go.mod suffix last.
499+
sig { params(line_a: String, line_b: String).returns(Integer) }
500+
def go_sum_line_compare(line_a, line_b)
501+
path_a, version_rest_a = line_a.split(/\s+/, 2)
502+
path_b, version_rest_b = line_b.split(/\s+/, 2)
503+
504+
path_cmp = T.must((path_a || "") <=> (path_b || ""))
505+
return path_cmp unless path_cmp.zero?
506+
507+
compare_go_versions(version_rest_a || "", version_rest_b || "")
508+
end
509+
510+
# Compares version+suffix portions of go.sum lines using GoModules::Version.
511+
sig { params(ver_a: String, ver_b: String).returns(Integer) }
512+
def compare_go_versions(ver_a, ver_b)
513+
a_is_gomod = ver_a.include?("/go.mod")
514+
b_is_gomod = ver_b.include?("/go.mod")
515+
516+
# Extract raw version token (e.g., "v0.6.0" from "v0.6.0/go.mod h1:...")
517+
raw_a = ver_a.split(%r{(/go\.mod)?\s}, 2).first || ""
518+
raw_b = ver_b.split(%r{(/go\.mod)?\s}, 2).first || ""
519+
520+
ver_cmp = go_version_compare(raw_a, raw_b)
521+
return ver_cmp unless ver_cmp.zero?
522+
523+
# Same version: zip hash line sorts before /go.mod line
524+
(a_is_gomod ? 1 : 0) <=> (b_is_gomod ? 1 : 0)
525+
end
526+
527+
sig { params(ver_a: String, ver_b: String).returns(Integer) }
528+
def go_version_compare(ver_a, ver_b)
529+
T.must(Dependabot::GoModules::Version.new(ver_a) <=> Dependabot::GoModules::Version.new(ver_b))
530+
end
531+
420532
# Given a go.mod file, find all `replace` directives pointing to a path
421533
# on the local filesystem, and return an array of pairs mapping the
422534
# original path to a hash of the path.

go_modules/spec/dependabot/go_modules/file_updater/go_mod_updater_spec.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,77 @@ module github.com/dependabot/vgotest
276276
.not_to include(%(rsc.io/quote v1.4.0/go.mod h1:))
277277
end
278278

279+
context "when go tooling removes an unrelated go.mod checksum line" do
280+
let(:removed_line) do
281+
"gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E="
282+
end
283+
let(:zip_line) do
284+
"gonum.org/v1/gonum v0.16.0 h1:JKbmSgVMFkFMDpGCixMRJCMEMmNhrsJuJqVDPMGPnQY="
285+
end
286+
287+
it "restores the unrelated checksum line" do
288+
allow(File).to receive(:read).and_call_original
289+
290+
original_go_sum = fixture("projects", project_name, "go.sum") +
291+
"#{zip_line}\n#{removed_line}\n"
292+
updated_go_sum = original_go_sum.lines.reject { |line| line.chomp == removed_line }.join
293+
294+
allow(File).to receive(:read).with("go.sum").and_return(original_go_sum, updated_go_sum)
295+
296+
expect(updated_go_mod_content).to include(removed_line)
297+
end
298+
end
299+
300+
context "when the removed checksum belongs to the updated dependency" do
301+
let(:removed_line) { "rsc.io/quote v1.4.0/go.mod h1:omethingoldchecksum=" }
302+
303+
it "does not restore the removed checksum line" do
304+
allow(File).to receive(:read).and_call_original
305+
306+
original_go_sum = <<~GOSUM
307+
rsc.io/quote v1.4.0 h1:oldchecksum=
308+
#{removed_line}
309+
rsc.io/sampler v1.3.0/go.mod h1:anexistingchecksum=
310+
GOSUM
311+
312+
updated_go_sum = <<~GOSUM
313+
rsc.io/quote v1.5.2 h1:newchecksum=
314+
rsc.io/quote v1.5.2/go.mod h1:newgomodchecksum=
315+
rsc.io/sampler v1.3.0/go.mod h1:anexistingchecksum=
316+
GOSUM
317+
318+
allow(File).to receive(:read).with("go.sum").and_return(original_go_sum, updated_go_sum)
319+
320+
expect(updated_go_mod_content).not_to include(removed_line)
321+
end
322+
end
323+
324+
context "when a transitive dependency version is legitimately upgraded" do
325+
let(:removed_line) { "golang.org/x/sys v0.0.0-20200116001909/go.mod h1:oldtransitivechecksum=" }
326+
327+
it "does not restore the removed checksum line" do
328+
allow(File).to receive(:read).and_call_original
329+
330+
original_go_sum = <<~GOSUM
331+
golang.org/x/sys v0.0.0-20200116001909 h1:oldziphash=
332+
#{removed_line}
333+
rsc.io/quote v1.4.0 h1:oldchecksum=
334+
rsc.io/quote v1.4.0/go.mod h1:oldgomod=
335+
GOSUM
336+
337+
updated_go_sum = <<~GOSUM
338+
golang.org/x/sys v0.0.0-20220731174439 h1:newziphash=
339+
golang.org/x/sys v0.0.0-20220731174439/go.mod h1:newtransitivechecksum=
340+
rsc.io/quote v1.5.2 h1:newchecksum=
341+
rsc.io/quote v1.5.2/go.mod h1:newgomodchecksum=
342+
GOSUM
343+
344+
allow(File).to receive(:read).with("go.sum").and_return(original_go_sum, updated_go_sum)
345+
346+
expect(updated_go_mod_content).not_to include(removed_line)
347+
end
348+
end
349+
279350
describe "a non-existent dependency with a pseudo-version" do
280351
let(:project_name) { "non_existent_dependency" }
281352

0 commit comments

Comments
 (0)