Skip to content

Commit 696eba2

Browse files
committed
Verify testgen downloads against a SHA-512 asset table
releaseAsset now looks a build up in a table of version, platform, file name and SHA-512, and Install hashes every byte off the wire, including the tail of a tarball past the binary, and discards a download whose digest does not match. A version missing from the table cannot be installed. The tarball digests are the ones ClickHouse publishes in its .sha512 sidecar files; the macOS binaries have no published digest, so theirs were computed from the downloads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
1 parent bfa8998 commit 696eba2

2 files changed

Lines changed: 83 additions & 31 deletions

File tree

internal/engine/clickhouse/testgen/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ go run . analyze --schema schema.sql --fixture fixture.sql query.sql
1616
```
1717

1818
The binary is looked up in the `CLICKHOUSE` environment variable first, then
19-
in the cache populated by `install`. The version is pinned in `install.go`;
20-
bumping it can change the query tree format and type inference, so regenerate
21-
and review the goldens afterwards.
19+
in the cache populated by `install`. The version is pinned in `install.go`,
20+
whose asset table lists each platform's download and its SHA-512; a download
21+
that does not match is discarded. Bumping the version means adding the new
22+
release's assets and checksums to the table, and since a release can change
23+
the query tree format and type inference, regenerating and reviewing the
24+
goldens afterwards.
2225

2326
## How it works
2427

internal/engine/clickhouse/testgen/install.go

Lines changed: 77 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"archive/tar"
55
"compress/gzip"
66
"context"
7+
"crypto/sha512"
8+
"encoding/hex"
79
"errors"
810
"fmt"
911
"io"
@@ -17,9 +19,34 @@ import (
1719
// DefaultVersion is the ClickHouse release the goldens are generated with.
1820
// Bumping it is a deliberate change: the query tree format and type
1921
// inference can shift between releases, so regenerate and review the goldens
20-
// after changing it.
22+
// after changing it, and add the new release's assets to the table below.
2123
const DefaultVersion = "25.8.2.29"
2224

25+
// asset is one downloadable build of ClickHouse. Linux builds are published
26+
// as clickhouse-common-static tarballs holding the binary at
27+
// usr/bin/clickhouse; macOS builds are published as bare binaries.
28+
type asset struct {
29+
Version string
30+
OS string
31+
Arch string
32+
Name string
33+
SHA512 string
34+
}
35+
36+
// assets lists every build Install knows how to fetch, with the SHA-512 of
37+
// the download. A version that is not in this table cannot be installed:
38+
// verifying the download is the point of the table.
39+
//
40+
// The tarball checksums are the ones in the .sha512 files ClickHouse
41+
// publishes next to them. ClickHouse publishes no checksum for the macOS
42+
// binaries, so those were computed from the downloads.
43+
var assets = []asset{
44+
{"25.8.2.29", "linux", "amd64", "clickhouse-common-static-25.8.2.29-amd64.tgz", "6ff0aa1ffac6e564970174422ecde0d645cdb96812247a6e544d39cad6d78a514265f90a2bc7b4bad49903cea96eddd16a415a45b2aeaf9164461be76331bdee"},
45+
{"25.8.2.29", "linux", "arm64", "clickhouse-common-static-25.8.2.29-arm64.tgz", "68204ca4d4e472790f808ee376251fae82e58066a31f35a40d15d442ce5988d697f18a1208d28b8bb8e2dfad4b20b7fcb5107e2178472abcd97251b8de7f058e"},
46+
{"25.8.2.29", "darwin", "amd64", "clickhouse-macos", "2805805ad2506e37a3e71b4ae9e797bdc010a9368dc28e99bcaaa2c70a72cfdd031c0fce8fc304248fad73211d28d645f75c6432cfae1e1e54d72d04e8626cd4"},
47+
{"25.8.2.29", "darwin", "arm64", "clickhouse-macos-aarch64", "4c9237e85c8d4e1aced2b339b32e086b4f23fa14f99754b056a7e33dda88c4fb5d52e09e29d20181ec5b580caadb00f36613802e71755ff34927535eb8babf79"},
48+
}
49+
2350
// releaseTag returns the GitHub release tag for a version. ClickHouse tags
2451
// its March and August releases as LTS and everything else as stable.
2552
func releaseTag(version string) (string, error) {
@@ -38,27 +65,33 @@ func releaseTag(version string) (string, error) {
3865
return "v" + version + suffix, nil
3966
}
4067

41-
// releaseAsset returns the download URL for a platform and whether it is a
42-
// tarball holding the binary at usr/bin/clickhouse rather than the bare
43-
// binary. Linux builds are only published as tarballs; macOS builds only as
44-
// bare binaries.
45-
func releaseAsset(version, goos, goarch string) (url string, tarball bool, err error) {
46-
tag, err := releaseTag(version)
68+
// releaseAsset finds the build for a platform in the table.
69+
func releaseAsset(version, goos, goarch string) (asset, error) {
70+
for _, a := range assets {
71+
if a.Version == version && a.OS == goos && a.Arch == goarch {
72+
return a, nil
73+
}
74+
}
75+
for _, a := range assets {
76+
if a.Version == version {
77+
return asset{}, fmt.Errorf("no ClickHouse %s build is listed for %s/%s", version, goos, goarch)
78+
}
79+
}
80+
return asset{}, fmt.Errorf("ClickHouse %s is not in the asset table; add its downloads and checksums to install.go", version)
81+
}
82+
83+
// url is the asset's download address on GitHub.
84+
func (a asset) url() (string, error) {
85+
tag, err := releaseTag(a.Version)
4786
if err != nil {
48-
return "", false, err
49-
}
50-
base := "https://github.com/ClickHouse/ClickHouse/releases/download/" + tag + "/"
51-
switch goos + "/" + goarch {
52-
case "linux/amd64":
53-
return base + "clickhouse-common-static-" + version + "-amd64.tgz", true, nil
54-
case "linux/arm64":
55-
return base + "clickhouse-common-static-" + version + "-arm64.tgz", true, nil
56-
case "darwin/amd64":
57-
return base + "clickhouse-macos", false, nil
58-
case "darwin/arm64":
59-
return base + "clickhouse-macos-aarch64", false, nil
60-
}
61-
return "", false, fmt.Errorf("no ClickHouse build is published for %s/%s", goos, goarch)
87+
return "", err
88+
}
89+
return "https://github.com/ClickHouse/ClickHouse/releases/download/" + tag + "/" + a.Name, nil
90+
}
91+
92+
// tarball reports whether the download is an archive rather than the binary.
93+
func (a asset) tarball() bool {
94+
return strings.HasSuffix(a.Name, ".tgz")
6295
}
6396

6497
// cachedBinary is where Install puts the binary for a version.
@@ -87,7 +120,8 @@ func Locate() (string, error) {
87120
}
88121

89122
// Install downloads the clickhouse binary for a version into the cache and
90-
// returns its path. It is a no-op when the version is already cached.
123+
// returns its path. It is a no-op when the version is already cached. The
124+
// download is checked against the table's SHA-512 before it is installed.
91125
func Install(ctx context.Context, version, goos, goarch string, progress io.Writer) (string, error) {
92126
dest, err := cachedBinary(version)
93127
if err != nil {
@@ -96,7 +130,11 @@ func Install(ctx context.Context, version, goos, goarch string, progress io.Writ
96130
if _, err := os.Stat(dest); err == nil {
97131
return dest, nil
98132
}
99-
url, tarball, err := releaseAsset(version, goos, goarch)
133+
a, err := releaseAsset(version, goos, goarch)
134+
if err != nil {
135+
return "", err
136+
}
137+
url, err := a.url()
100138
if err != nil {
101139
return "", err
102140
}
@@ -118,17 +156,21 @@ func Install(ctx context.Context, version, goos, goarch string, progress io.Writ
118156
return "", fmt.Errorf("downloading %s: %s", url, resp.Status)
119157
}
120158

121-
// Write next to the destination and rename so a partial download never
122-
// masquerades as an installed binary.
159+
// Write next to the destination and rename so a partial or corrupt
160+
// download never masquerades as an installed binary.
123161
tmp, err := os.CreateTemp(filepath.Dir(dest), "clickhouse-*.partial")
124162
if err != nil {
125163
return "", err
126164
}
127165
defer os.Remove(tmp.Name())
128166

129-
var src io.Reader = resp.Body
130-
if tarball {
131-
src, err = binaryInTarball(resp.Body)
167+
// Hash every byte that comes off the wire, including the parts of a
168+
// tarball after the binary, which extraction would otherwise not read.
169+
sum := sha512.New()
170+
body := io.TeeReader(resp.Body, sum)
171+
var src io.Reader = body
172+
if a.tarball() {
173+
src, err = binaryInTarball(body)
132174
if err != nil {
133175
return "", fmt.Errorf("downloading %s: %w", url, err)
134176
}
@@ -137,9 +179,16 @@ func Install(ctx context.Context, version, goos, goarch string, progress io.Writ
137179
tmp.Close()
138180
return "", err
139181
}
182+
if _, err := io.Copy(io.Discard, body); err != nil {
183+
tmp.Close()
184+
return "", err
185+
}
140186
if err := tmp.Close(); err != nil {
141187
return "", err
142188
}
189+
if got := hex.EncodeToString(sum.Sum(nil)); got != a.SHA512 {
190+
return "", fmt.Errorf("downloading %s: SHA-512 mismatch: got %s, want %s", url, got, a.SHA512)
191+
}
143192
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
144193
return "", err
145194
}

0 commit comments

Comments
 (0)