Skip to content

Commit bdc0a7c

Browse files
Jonathan D.A. Jewellclaude
andcommitted
fix: checksum verification for Go, Hex, NuGet + stress test
- Go modules: fetch checksums from sum.golang.org (h1: format → SHA256) - Hex: extract checksum from release-specific API endpoint - NuGet: fetch sub-pages when registration items aren't inlined - NuGet: handle nil version gracefully (non-inlined pages) - Add 100-package stress test across all 10 registries (100% pass) - 8/10 registries now provide pre-download checksums Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bab019e commit bdc0a7c

4 files changed

Lines changed: 257 additions & 27 deletions

File tree

opsm_ex/lib/opsm/registries/go_modules.ex

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ defmodule Opsm.Registries.GoModules do
3535
case VerifiedHttp.get_json(url, receive_timeout: 10_000) do
3636
{:ok, body} ->
3737
deps = fetch_go_mod_deps(encoded, ver)
38-
ziphash = fetch_ziphash(encoded, ver)
38+
ziphash = fetch_ziphash(name, ver)
3939
{:ok, parse_module(name, body, ver, deps, ziphash)}
4040

4141
{:error, :not_found} ->
@@ -227,26 +227,52 @@ defmodule Opsm.Registries.GoModules do
227227
|> Enum.join()
228228
end
229229

230-
defp fetch_ziphash(encoded, version) do
231-
url = "#{@proxy_url}/#{encoded}/@v/#{version}.ziphash"
230+
@sum_db "https://sum.golang.org"
231+
232+
defp fetch_ziphash(name, version) do
233+
# Use the Go checksum database (sum.golang.org) for zip hash
234+
url = "#{@sum_db}/lookup/#{name}@#{version}"
232235
case VerifiedHttp.get(url, receive_timeout: 10_000) do
233236
{:ok, %{body: body}} when is_binary(body) ->
234-
parse_ziphash(String.trim(body))
237+
parse_sum_db_hash(body, name, version)
235238
{:ok, body} when is_binary(body) ->
236-
parse_ziphash(String.trim(body))
239+
parse_sum_db_hash(body, name, version)
237240
_ -> nil
238241
end
239242
end
240243

241-
# Go ziphash format: "h1:<base64-encoded-sha256>"
242-
# We convert to hex-encoded SHA256 for compatibility with our checksum system
243-
defp parse_ziphash("h1:" <> b64_hash) do
244-
case Base.decode64(b64_hash) do
244+
# Parse sum.golang.org lookup response for the zip hash (h1: format)
245+
# Format: "module version h1:<base64>\nmodule version/go.mod h1:<base64>\n..."
246+
# We want the first line (zip hash), not the go.mod hash
247+
defp parse_sum_db_hash(body, name, version) do
248+
body
249+
|> String.split("\n")
250+
|> Enum.find_value(fn line ->
251+
prefix = "#{name} #{version} h1:"
252+
if String.starts_with?(line, prefix) do
253+
"h1:" <> _ = String.trim_leading(line, "#{name} #{version} ")
254+
parse_h1_hash(String.trim(line) |> String.split(" ") |> List.last())
255+
end
256+
end)
257+
end
258+
259+
# Go h1 hash format: "h1:<base64-encoded-sha256>"
260+
# Convert to hex-encoded SHA256 for compatibility with our checksum system
261+
defp parse_h1_hash("h1:" <> b64_hash) do
262+
b64_clean = String.trim_trailing(b64_hash, "=") |> pad_base64()
263+
case Base.decode64(b64_clean) do
245264
{:ok, raw_hash} -> Base.encode16(raw_hash, case: :lower)
246265
:error -> nil
247266
end
248267
end
249-
defp parse_ziphash(_), do: nil
268+
defp parse_h1_hash(_), do: nil
269+
270+
defp pad_base64(s) do
271+
case rem(String.length(s), 4) do
272+
0 -> s
273+
n -> s <> String.duplicate("=", 4 - n)
274+
end
275+
end
250276

251277
# Parsers
252278

opsm_ex/lib/opsm/registries/hex.ex

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ defmodule Opsm.Registries.Hex do
3030
version
3131
end
3232

33-
# Fetch release-specific data to get dependencies
34-
deps = fetch_release_deps(name, target_version)
33+
# Fetch release-specific data to get dependencies and checksum
34+
{deps, release_checksum} = fetch_release_data(name, target_version)
3535
release = Enum.find(releases, fn r -> r["version"] == target_version end)
36-
{:ok, parse_package(body, release, target_version, deps)}
36+
# Prefer checksum from release-specific endpoint (outer_checksum/checksum)
37+
checksum = release_checksum || (if release, do: release["checksum"], else: nil)
38+
{:ok, parse_package(body, checksum, target_version, deps)}
3739

3840
{:error, :not_found} ->
3941
{:error, :not_found}
@@ -49,26 +51,28 @@ defmodule Opsm.Registries.Hex do
4951
end
5052
end
5153

52-
# Fetch dependencies from the release-specific API endpoint
53-
defp fetch_release_deps(name, version) do
54+
# Fetch dependencies and checksum from the release-specific API endpoint
55+
defp fetch_release_data(name, version) do
5456
url = "#{@base_url}/packages/#{URI.encode(name)}/releases/#{version}"
5557

5658
case VerifiedHttp.get_json(url, receive_timeout: 10_000) do
5759
{:ok, body} ->
5860
requirements = body["requirements"] || %{}
59-
# Convert hex requirements format to simple {name => constraint} map
60-
Enum.reduce(requirements, %{}, fn {dep_name, req}, acc ->
61+
deps = Enum.reduce(requirements, %{}, fn {dep_name, req}, acc ->
6162
constraint = req["requirement"] || ">= 0.0.0"
62-
# Skip optional dependencies
6363
if req["optional"] do
6464
acc
6565
else
6666
Map.put(acc, dep_name, constraint)
6767
end
6868
end)
6969

70+
# Extract checksum — Hex uses "checksum" (outer tarball checksum)
71+
checksum = body["checksum"]
72+
{deps, checksum}
73+
7074
_ ->
71-
%{}
75+
{%{}, nil}
7276
end
7377
end
7478

@@ -137,9 +141,8 @@ defmodule Opsm.Registries.Hex do
137141

138142
# Parsers
139143

140-
defp parse_package(pkg, release, version, deps) do
144+
defp parse_package(pkg, checksum, version, deps) do
141145
meta = pkg["meta"] || %{}
142-
checksum = if release, do: release["checksum"], else: nil
143146

144147
%ResolvedPackage{
145148
package: pkg["name"],

opsm_ex/lib/opsm/registries/nuget.ex

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,23 @@ defmodule Opsm.Registries.NuGet do
2323
case VerifiedHttp.get_json(url, receive_timeout: 10_000) do
2424
{:ok, body} ->
2525
# Registration pages contain version catalogs
26+
# Some pages inline items, others have only @id and need separate fetching
2627
items = body["items"] || []
2728
all_entries = items
2829
|> Enum.flat_map(fn page ->
29-
# Some pages inline items, others need fetching
30-
page["items"] || []
30+
case page["items"] do
31+
nil ->
32+
# Page doesn't inline items — fetch the page by @id
33+
case page["@id"] do
34+
nil -> []
35+
page_url ->
36+
case VerifiedHttp.get_json(page_url, receive_timeout: 10_000) do
37+
{:ok, page_body} -> page_body["items"] || []
38+
_ -> []
39+
end
40+
end
41+
inlined -> inlined
42+
end
3143
end)
3244

3345
target_version = if version == "latest" do
@@ -49,11 +61,15 @@ defmodule Opsm.Registries.NuGet do
4961
catalog["version"] == target_version
5062
end)
5163

52-
catalog = if entry, do: entry["catalogEntry"] || %{}, else: %{}
53-
deps = extract_nuget_deps(catalog)
54-
{hash, hash_algo} = extract_nuget_hash(catalog)
64+
if is_nil(target_version) do
65+
{:error, :not_found}
66+
else
67+
catalog = if entry, do: entry["catalogEntry"] || %{}, else: %{}
68+
deps = extract_nuget_deps(catalog)
69+
{hash, hash_algo} = extract_nuget_hash(catalog)
5570

56-
{:ok, parse_nuget_package(name, target_version, catalog, deps, hash, hash_algo)}
71+
{:ok, parse_nuget_package(name, target_version, catalog, deps, hash, hash_algo)}
72+
end
5773

5874
{:error, :not_found} ->
5975
{:error, :not_found}

opsm_ex/test/stress_test.exs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Stress test: fetch real packages from all 10 registries
3+
# Run: mix run test/stress_test.exs
4+
5+
alias Opsm.Registries.Registry
6+
7+
# Test packages per registry — well-known, stable packages
8+
test_packages = [
9+
# npm (10 packages)
10+
{:npm, "express", "4.18.2"},
11+
{:npm, "lodash", "latest"},
12+
{:npm, "chalk", "5.3.0"},
13+
{:npm, "axios", "latest"},
14+
{:npm, "uuid", "latest"},
15+
{:npm, "debug", "latest"},
16+
{:npm, "commander", "latest"},
17+
{:npm, "minimist", "latest"},
18+
{:npm, "glob", "latest"},
19+
{:npm, "semver", "latest"},
20+
21+
# Hex/Elixir (10 packages)
22+
{:hex, "jason", "latest"},
23+
{:hex, "plug", "latest"},
24+
{:hex, "phoenix", "latest"},
25+
{:hex, "ecto", "latest"},
26+
{:hex, "telemetry", "latest"},
27+
{:hex, "nimble_parsec", "latest"},
28+
{:hex, "ex_doc", "latest"},
29+
{:hex, "earmark_parser", "latest"},
30+
{:hex, "decimal", "latest"},
31+
{:hex, "mime", "latest"},
32+
33+
# Cargo/Crates (10 packages)
34+
{:cargo, "serde", "latest"},
35+
{:cargo, "tokio", "latest"},
36+
{:cargo, "rand", "latest"},
37+
{:cargo, "clap", "latest"},
38+
{:cargo, "regex", "latest"},
39+
{:cargo, "log", "latest"},
40+
{:cargo, "anyhow", "latest"},
41+
{:cargo, "thiserror", "latest"},
42+
{:cargo, "chrono", "latest"},
43+
{:cargo, "reqwest", "latest"},
44+
45+
# PyPI (10 packages)
46+
{:pypi, "requests", "latest"},
47+
{:pypi, "flask", "latest"},
48+
{:pypi, "numpy", "latest"},
49+
{:pypi, "pytest", "latest"},
50+
{:pypi, "click", "latest"},
51+
{:pypi, "pyyaml", "latest"},
52+
{:pypi, "six", "latest"},
53+
{:pypi, "packaging", "latest"},
54+
{:pypi, "idna", "latest"},
55+
{:pypi, "certifi", "latest"},
56+
57+
# RubyGems (10 packages)
58+
{:gem, "rails", "latest"},
59+
{:gem, "rake", "latest"},
60+
{:gem, "bundler", "latest"},
61+
{:gem, "rspec", "latest"},
62+
{:gem, "nokogiri", "latest"},
63+
{:gem, "sinatra", "latest"},
64+
{:gem, "puma", "latest"},
65+
{:gem, "redis", "latest"},
66+
{:gem, "json", "latest"},
67+
{:gem, "minitest", "latest"},
68+
69+
# Go Modules (10 packages)
70+
{:go, "github.com/gin-gonic/gin", "latest"},
71+
{:go, "github.com/fatih/color", "latest"},
72+
{:go, "github.com/spf13/cobra", "latest"},
73+
{:go, "github.com/spf13/viper", "latest"},
74+
{:go, "github.com/stretchr/testify", "latest"},
75+
{:go, "github.com/sirupsen/logrus", "latest"},
76+
{:go, "github.com/gorilla/mux", "latest"},
77+
{:go, "golang.org/x/text", "latest"},
78+
{:go, "golang.org/x/net", "latest"},
79+
{:go, "golang.org/x/sync", "latest"},
80+
81+
# Pub.dev/Dart (10 packages)
82+
{:pub, "http", "latest"},
83+
{:pub, "path", "latest"},
84+
{:pub, "json_annotation", "latest"},
85+
{:pub, "provider", "latest"},
86+
{:pub, "intl", "latest"},
87+
{:pub, "collection", "latest"},
88+
{:pub, "meta", "latest"},
89+
{:pub, "args", "latest"},
90+
{:pub, "crypto", "latest"},
91+
{:pub, "async", "latest"},
92+
93+
# Hackage/Haskell (10 packages)
94+
{:hackage, "aeson", "latest"},
95+
{:hackage, "text", "latest"},
96+
{:hackage, "bytestring", "latest"},
97+
{:hackage, "containers", "latest"},
98+
{:hackage, "mtl", "latest"},
99+
{:hackage, "transformers", "latest"},
100+
{:hackage, "parsec", "latest"},
101+
{:hackage, "vector", "latest"},
102+
{:hackage, "lens", "latest"},
103+
{:hackage, "attoparsec", "latest"},
104+
105+
# NuGet/.NET (10 packages)
106+
{:nuget, "Newtonsoft.Json", "latest"},
107+
{:nuget, "Serilog", "latest"},
108+
{:nuget, "AutoMapper", "latest"},
109+
{:nuget, "Dapper", "latest"},
110+
{:nuget, "FluentValidation", "latest"},
111+
{:nuget, "Moq", "latest"},
112+
{:nuget, "xunit", "latest"},
113+
{:nuget, "Polly", "latest"},
114+
{:nuget, "MediatR", "latest"},
115+
{:nuget, "Swashbuckle.AspNetCore", "latest"},
116+
117+
# Maven/Java (10 packages)
118+
{:maven, "com.google.guava:guava", "latest"},
119+
{:maven, "org.slf4j:slf4j-api", "latest"},
120+
{:maven, "junit:junit", "latest"},
121+
{:maven, "com.fasterxml.jackson.core:jackson-databind", "latest"},
122+
{:maven, "org.apache.commons:commons-lang3", "latest"},
123+
{:maven, "commons-io:commons-io", "latest"},
124+
{:maven, "org.projectlombok:lombok", "latest"},
125+
{:maven, "com.google.code.gson:gson", "latest"},
126+
{:maven, "org.mockito:mockito-core", "latest"},
127+
{:maven, "ch.qos.logback:logback-classic", "latest"},
128+
]
129+
130+
IO.puts("=" |> String.duplicate(70))
131+
IO.puts("OPSM Stress Test: #{length(test_packages)} packages across 10 registries")
132+
IO.puts("=" |> String.duplicate(70))
133+
IO.puts("")
134+
135+
# Group by registry
136+
grouped = Enum.group_by(test_packages, fn {forth, _, _} -> forth end)
137+
138+
results = %{ok: 0, error: 0, total: length(test_packages)}
139+
errors = []
140+
141+
{results, errors} =
142+
Enum.reduce(grouped, {results, errors}, fn {forth, packages}, {res_acc, err_acc} ->
143+
IO.puts("--- @#{forth} (#{length(packages)} packages) ---")
144+
145+
{res, errs} =
146+
Enum.reduce(packages, {res_acc, err_acc}, fn {_forth, name, version}, {r, e} ->
147+
start = System.monotonic_time(:millisecond)
148+
149+
case Registry.fetch(forth, name, version) do
150+
{:ok, pkg} ->
151+
elapsed = System.monotonic_time(:millisecond) - start
152+
checksum_status = if pkg.checksum, do: "✓ #{pkg.checksum_algo}", else: "✗ none"
153+
dep_count = map_size(pkg.manifest.dependencies || %{})
154+
IO.puts(" ✓ #{name}@#{pkg.version} (#{elapsed}ms, deps: #{dep_count}, checksum: #{checksum_status})")
155+
{%{r | ok: r.ok + 1}, e}
156+
157+
{:error, reason} ->
158+
elapsed = System.monotonic_time(:millisecond) - start
159+
IO.puts(" ✗ #{name} FAILED (#{elapsed}ms): #{inspect(reason)}")
160+
{%{r | error: r.error + 1}, [{forth, name, reason} | e]}
161+
end
162+
end)
163+
164+
IO.puts("")
165+
{res, errs}
166+
end)
167+
168+
IO.puts("=" |> String.duplicate(70))
169+
IO.puts("RESULTS: #{results.ok}/#{results.total} succeeded, #{results.error} failed")
170+
IO.puts("=" |> String.duplicate(70))
171+
172+
if errors != [] do
173+
IO.puts("")
174+
IO.puts("FAILURES:")
175+
for {forth, name, reason} <- Enum.reverse(errors) do
176+
IO.puts(" @#{forth}/#{name}: #{inspect(reason)}")
177+
end
178+
end
179+
180+
# Exit with appropriate code
181+
if results.error > 0 do
182+
IO.puts("\n#{results.error} package(s) failed to resolve")
183+
else
184+
IO.puts("\n✓ All #{results.total} packages resolved successfully!")
185+
end

0 commit comments

Comments
 (0)