Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(releasing): validate tarball sha1 before publishing to homebrew MONGOSH-2059 #2407

Merged
merged 3 commits into from
Mar 26, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .evergreen/verify-packaged-artifact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ verify_using_gpg() {
verify_using_powershell() {
echo "Verifying $1 using powershell"
powershell Get-AuthenticodeSignature -FilePath $ARTIFACTS_DIR/$1 > "$TMP_FILE" 2>&1

# Get-AuthenticodeSignature just outputs text, it doesn't exit with a non-zero
# code if the file is not signed
if grep -q NotSigned "$TMP_FILE"; then
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unrelated to MONGOSH-2059, but I noticed we don't correctly validate the signature of the package on windows, so figured I'll fix it as a drive by. Happy to move to a different PR if folks prefer a cleaner separation of changes.

echo "File $1 is not signed"
exit 1
fi
}

verify_using_codesign() {
Expand Down Expand Up @@ -91,4 +98,4 @@ else
(cd "$ARTIFACTS_DIR" && bash "$BASEDIR/retry-with-backoff.sh" curl -sSfLO --url "$(cat "$ARTIFACT_URL_FILE").sig")
verify_using_gpg $ARTIFACT_FILE_NAME
fi
fi
fi
28 changes: 11 additions & 17 deletions packages/build/src/homebrew/publish-to-homebrew.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('HomebrewPublisher', function () {
let homebrewCore: GithubRepo;
let homebrewCoreFork: GithubRepo;
let createPullRequest: sinon.SinonStub;
let httpsSha256: sinon.SinonStub;
let npmPackageSha256: sinon.SinonStub;
let generateFormula: sinon.SinonStub;
let updateHomebrewFork: sinon.SinonStub;

Expand Down Expand Up @@ -41,7 +41,7 @@ describe('HomebrewPublisher', function () {
homebrewCoreFork,
});

httpsSha256 = sinon.stub(testPublisher, 'httpsSha256');
npmPackageSha256 = sinon.stub(testPublisher, 'npmPackageSha256');
generateFormula = sinon.stub(testPublisher, 'generateFormula');
updateHomebrewFork = sinon.stub(testPublisher, 'updateHomebrewFork');
};
Expand All @@ -61,11 +61,9 @@ describe('HomebrewPublisher', function () {
isDryRun: false,
});

httpsSha256
npmPackageSha256
.rejects()
.withArgs(
'https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-1.0.0.tgz'
)
.withArgs('https://registry.npmjs.org/@mongosh/cli-repl/1.0.0')
.resolves('sha');

generateFormula
Expand Down Expand Up @@ -97,7 +95,7 @@ describe('HomebrewPublisher', function () {

await testPublisher.publish();

expect(httpsSha256).to.have.been.called;
expect(npmPackageSha256).to.have.been.called;
expect(generateFormula).to.have.been.called;
expect(updateHomebrewFork).to.have.been.called;
expect(createPullRequest).to.have.been.called;
Expand All @@ -110,11 +108,9 @@ describe('HomebrewPublisher', function () {
isDryRun: false,
});

httpsSha256
npmPackageSha256
.rejects()
.withArgs(
'https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-1.0.0.tgz'
)
.withArgs('https://registry.npmjs.org/@mongosh/cli-repl/1.0.0')
.resolves('sha');

generateFormula
Expand All @@ -136,18 +132,16 @@ describe('HomebrewPublisher', function () {

await testPublisher.publish();

expect(httpsSha256).to.have.been.called;
expect(npmPackageSha256).to.have.been.called;
expect(generateFormula).to.have.been.called;
expect(updateHomebrewFork).to.have.been.called;
expect(createPullRequest).to.not.have.been.called;
});

it('silently ignores an error while deleting the PR branch', async function () {
httpsSha256
npmPackageSha256
.rejects()
.withArgs(
'https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-1.0.0.tgz'
)
.withArgs('https://registry.npmjs.org/@mongosh/cli-repl/1.0.0')
.resolves('sha');

generateFormula
Expand Down Expand Up @@ -179,7 +173,7 @@ describe('HomebrewPublisher', function () {

await testPublisher.publish();

expect(httpsSha256).to.have.been.called;
expect(npmPackageSha256).to.have.been.called;
expect(generateFormula).to.have.been.called;
expect(updateHomebrewFork).to.have.been.called;
expect(createPullRequest).to.have.been.called;
Expand Down
12 changes: 6 additions & 6 deletions packages/build/src/homebrew/publish-to-homebrew.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { GithubRepo } from '@mongodb-js/devtools-github-repo';
import { generateUpdatedFormula as generateUpdatedFormulaFn } from './generate-formula';
import { updateHomebrewFork as updateHomebrewForkFn } from './update-homebrew-fork';
import { httpsSha256 as httpsSha256Fn } from './utils';
import { npmPackageSha256 as npmPackageSha256Fn } from './utils';

export type HomebrewPublisherConfig = {
homebrewCore: GithubRepo;
Expand All @@ -12,19 +12,19 @@ export type HomebrewPublisherConfig = {
};

export class HomebrewPublisher {
readonly httpsSha256: typeof httpsSha256Fn;
readonly npmPackageSha256: typeof npmPackageSha256Fn;
readonly generateFormula: typeof generateUpdatedFormulaFn;
readonly updateHomebrewFork: typeof updateHomebrewForkFn;

constructor(
public config: HomebrewPublisherConfig,
{
httpsSha256 = httpsSha256Fn,
npmPackageSha256 = npmPackageSha256Fn,
generateFormula = generateUpdatedFormulaFn,
updateHomebrewFork = updateHomebrewForkFn,
} = {}
) {
this.httpsSha256 = httpsSha256;
this.npmPackageSha256 = npmPackageSha256;
this.generateFormula = generateFormula;
this.updateHomebrewFork = updateHomebrewFork;
}
Expand All @@ -38,10 +38,10 @@ export class HomebrewPublisher {
githubReleaseLink,
} = this.config;

const cliReplPackageUrl = `https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-${packageVersion}.tgz`;
const cliReplPackageUrl = `https://registry.npmjs.org/@mongosh/cli-repl/${packageVersion}`;
const packageSha = isDryRun
? `dryRun-fakesha256-${Date.now()}`
: await this.httpsSha256(cliReplPackageUrl);
: await this.npmPackageSha256(cliReplPackageUrl);

const homebrewFormula = await this.generateFormula(
{ version: packageVersion, sha: packageSha },
Expand Down
91 changes: 86 additions & 5 deletions packages/build/src/homebrew/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,96 @@
import { expect } from 'chai';
import { httpsSha256 } from './utils';
import { npmPackageSha256 } from './utils';
import sinon from 'sinon';
import crypto from 'crypto';

describe('Homebrew utils', function () {
describe('httpsSha256', function () {
describe('npmPackageSha256', function () {
it('computes the correct sha', async function () {
const url =
'https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-0.6.1.tgz';
const url = 'https://registry.npmjs.org/@mongosh/cli-repl/0.6.1';
const expectedSha =
'3721ea662cd3775373d4d70f7593993564563d9379704896478db1d63f6c8470';

expect(await httpsSha256(url)).to.equal(expectedSha);
expect(await npmPackageSha256(url)).to.equal(expectedSha);
});

describe('when response sha mismatches', function () {
const fakeTarball = Buffer.from('mongosh-2.4.2.tgz');
const fakeTarballShasum = crypto
.createHash('sha1')
.update(fakeTarball)
.digest('hex');

it('retries', async function () {
const httpGet = sinon.stub();
httpGet
.withArgs(
'https://registry.npmjs.org/@mongosh/cli-repl/2.4.2',
'json'
)
.resolves({
dist: {
tarball:
'https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-2.4.2.tgz',
shasum: fakeTarballShasum,
},
});

httpGet
.withArgs(
'https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-2.4.2.tgz',
'binary'
)
.onFirstCall()
.resolves(Buffer.from('mongosh-2.4.2-incomplete.tgz')) // Simulate incomplete/wrong binary download
.onSecondCall()
.resolves(fakeTarball);

const sha = await npmPackageSha256(
'https://registry.npmjs.org/@mongosh/cli-repl/2.4.2',
httpGet
);

expect(sha).to.equal(
crypto.createHash('sha256').update(fakeTarball).digest('hex')
);
});

it('throws if retries are exhausted', async function () {
const httpGet = sinon.stub();
httpGet
.withArgs(
'https://registry.npmjs.org/@mongosh/cli-repl/2.4.2',
'json'
)
.resolves({
dist: {
tarball:
'https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-2.4.2.tgz',
shasum: fakeTarballShasum,
},
});

httpGet
.withArgs(
'https://registry.npmjs.org/@mongosh/cli-repl/-/cli-repl-2.4.2.tgz',
'binary'
)
.resolves(Buffer.from('mongosh-2.4.2-incomplete.tgz')); // Simulate incomplete/wrong binary download

const incompleteTarballShasum = crypto
.createHash('sha1')
.update(Buffer.from('mongosh-2.4.2-incomplete.tgz'))
.digest('hex');

const err = await npmPackageSha256(
'https://registry.npmjs.org/@mongosh/cli-repl/2.4.2',
httpGet
).catch((e) => e);

expect(err.message).to.equal(
`shasum mismatch: expected '${fakeTarballShasum}', got '${incompleteTarballShasum}'`
);
});
});
});
});
69 changes: 64 additions & 5 deletions packages/build/src/homebrew/utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,72 @@
import crypto from 'crypto';
import https from 'https';

export function httpsSha256(url: string): Promise<string> {
return new Promise((resolve, reject) => {
export async function npmPackageSha256(
packageUrl: string,
httpGetFn: typeof httpGet = httpGet
): Promise<string> {
const json = await httpGetFn(packageUrl, 'json');
const tarballUrl = json.dist.tarball;
const shasum = json.dist.shasum;

const tarball = await getTarballWithRetries(tarballUrl, shasum, httpGetFn);
const hash = crypto.createHash('sha256');
hash.update(tarball);
return hash.digest('hex');
}

async function getTarballWithRetries(
url: string,
shasum: string,
httpGetFn: typeof httpGet,
attempts = 3
): Promise<Buffer> {
try {
const tarball = await httpGetFn(url, 'binary');
const hash = crypto.createHash('sha1').update(tarball).digest('hex');
if (hash !== shasum) {
throw new Error(`shasum mismatch: expected '${shasum}', got '${hash}'`);
}

return tarball;
} catch (err) {
if (attempts === 0) {
throw err;
}

return getTarballWithRetries(url, shasum, httpGetFn, attempts - 1);
}
}

export function httpGet(url: string, response: 'json'): Promise<any>;
export function httpGet(url: string, response: 'binary'): Promise<Buffer>;

export async function httpGet(
url: string,
responseType: 'json' | 'binary'
): Promise<any | Buffer> {
const response = await new Promise<string | Buffer[]>((resolve, reject) => {
https.get(url, (stream) => {
const hash = crypto.createHash('sha256');
if (responseType === 'json') {
stream.setEncoding('utf8');
}

let data: string | Buffer[] = responseType === 'json' ? '' : [];
stream.on('error', (err) => reject(err));
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('data', (chunk) => {
if (typeof data === 'string') {
data += chunk;
} else {
data.push(chunk);
}
});
stream.on('end', () => resolve(data));
});
});

if (typeof response === 'string') {
return JSON.parse(response);
}

return Buffer.concat(response);
}