Skip to content

Commit 29b0fb1

Browse files
v-HaripriyaCCopilot
authored 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 58e3d21 commit 29b0fb1

2 files changed

Lines changed: 284 additions & 11 deletions

File tree

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

Lines changed: 171 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
226226
updated_go_mod = File.read("go.mod")
227227

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
229232
end
230233
end
231234

@@ -417,6 +420,172 @@ 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_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+
420589
# Given a go.mod file, find all `replace` directives pointing to a path
421590
# on the local filesystem, and return an array of pairs mapping the
422591
# original path to a hash of the path.

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

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -258,24 +258,128 @@ module github.com/dependabot/vgotest
258258
end
259259

260260
context "with a go.sum" do
261-
subject(:updated_go_mod_content) { updater.updated_go_sum_content }
261+
subject(:updated_go_sum_content) { updater.updated_go_sum_content }
262262

263263
let(:project_name) { "go_sum" }
264264

265265
it "adds new entries to the go.sum" do
266-
expect(updated_go_mod_content)
266+
expect(updated_go_sum_content)
267267
.to include(%(rsc.io/quote v1.5.2 h1:))
268-
expect(updated_go_mod_content)
268+
expect(updated_go_sum_content)
269269
.to include(%(rsc.io/quote v1.5.2/go.mod h1:))
270270
end
271271

272272
it "removes old entries from the go.sum" do
273-
expect(updated_go_mod_content)
273+
expect(updated_go_sum_content)
274274
.not_to include(%(rsc.io/quote v1.4.0 h1:))
275-
expect(updated_go_mod_content)
275+
expect(updated_go_sum_content)
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_sum_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:somethingoldchecksum=" }
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_sum_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_sum_content).not_to include(removed_line)
347+
end
348+
end
349+
350+
context "when a go.mod-only entry (no zip hash) is pruned" do
351+
let(:removed_line) do
352+
"golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ="
353+
end
354+
355+
it "restores the go.mod-only checksum line" do
356+
allow(File).to receive(:read).and_call_original
357+
358+
original_go_sum = <<~GOSUM
359+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
360+
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
361+
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
362+
rsc.io/quote v1.4.0 h1:oldchecksum=
363+
rsc.io/quote v1.4.0/go.mod h1:oldgomod=
364+
GOSUM
365+
366+
updated_go_sum = <<~GOSUM
367+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
368+
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
369+
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
370+
rsc.io/quote v1.5.2 h1:newchecksum=
371+
rsc.io/quote v1.5.2/go.mod h1:newgomodchecksum=
372+
GOSUM
373+
374+
# Simulate go tooling pruning the go.mod-only line
375+
pruned_go_sum = updated_go_sum.lines.reject { |line| line.chomp == removed_line }.join
376+
377+
allow(File).to receive(:read).with("go.sum").and_return(original_go_sum, pruned_go_sum)
378+
379+
expect(updated_go_sum_content).to include(removed_line)
380+
end
381+
end
382+
279383
describe "a non-existent dependency with a pseudo-version" do
280384
let(:project_name) { "non_existent_dependency" }
281385

@@ -322,16 +426,16 @@ module github.com/dependabot/vgotest
322426
end
323427

324428
it "adds new entries to the go.sum" do
325-
expect(updated_go_mod_content)
429+
expect(updated_go_sum_content)
326430
.to include(%(rsc.io/quote v1.5.2 h1:))
327-
expect(updated_go_mod_content)
431+
expect(updated_go_sum_content)
328432
.to include(%(rsc.io/quote v1.5.2/go.mod h1:))
329433
end
330434

331435
it "removes old entries from the go.sum" do
332-
expect(updated_go_mod_content)
436+
expect(updated_go_sum_content)
333437
.not_to include(%(rsc.io/quote v1.4.0 h1:))
334-
expect(updated_go_mod_content)
438+
expect(updated_go_sum_content)
335439
.not_to include(%(rsc.io/quote v1.4.0/go.mod h1:))
336440
end
337441

0 commit comments

Comments
 (0)