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

Add a test to check the download of the latest version #648

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
12 changes: 12 additions & 0 deletions common/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from 'fs';
import { keccak256 } from 'js-sha3';

/**
* Returns true if and only if the value is null or undefined.
*
Expand All @@ -18,3 +21,12 @@ export function isObject (value: any): boolean {
// to confirm it's just an object.
return typeof value === 'object' && !Array.isArray(value);
}

/**
* Returns the keccak256 hash of a file.
*
* @param path The path to the file to be hashed.
*/
export function hashFile (path: string): string {
return '0x' + keccak256(fs.readFileSync(path, { encoding: 'binary' }));
}
78 changes: 16 additions & 62 deletions downloadCurrentVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,73 +3,27 @@
// This is used to download the correct binary version
// as part of the prepublish step.

import * as fs from 'fs';
import { https } from 'follow-redirects';
import MemoryStream from 'memorystream';
import { keccak256 } from 'js-sha3';
import downloader from './downloader';
const pkg = require('./package.json');
const DEFAULT_HOST = 'https://binaries.soliditylang.org';

function getVersionList (cb) {
console.log('Retrieving available version list...');
async function download (version, host = DEFAULT_HOST) {
try {
const list = JSON.parse(await downloader.getVersionList(host));
const releaseFileName = list.releases[version];
const expectedFile = list.builds.find((entry) => entry.path === releaseFileName);

const mem = new MemoryStream(null, { readable: false });
https.get('https://binaries.soliditylang.org/bin/list.json', function (response) {
if (response.statusCode !== 200) {
console.log('Error downloading file: ' + response.statusCode);
process.exit(1);
if (!expectedFile) {
throw new Error('Requested version not found. Version list is invalid or corrupted.');
}
response.pipe(mem);
response.on('end', function () {
cb(mem.toString());
});
});
}

function downloadBinary (outputName, version, expectedHash) {
console.log('Downloading version', version);

// Remove if existing
if (fs.existsSync(outputName)) {
fs.unlinkSync(outputName);
}

process.on('SIGINT', function () {
console.log('Interrupted, removing file.');
fs.unlinkSync(outputName);
const expectedHash = expectedFile.keccak256;
await downloader.downloadBinary(host, 'soljson.js', releaseFileName, expectedHash);
} catch (err) {
console.log(err.message);
process.exit(1);
});

const file = fs.createWriteStream(outputName, { encoding: 'binary' });
https.get('https://binaries.soliditylang.org/bin/' + version, function (response) {
if (response.statusCode !== 200) {
console.log('Error downloading file: ' + response.statusCode);
process.exit(1);
}
response.pipe(file);
file.on('finish', function () {
file.close(function () {
const hash = '0x' + keccak256(fs.readFileSync(outputName, { encoding: 'binary' }));
if (expectedHash !== hash) {
console.log('Hash mismatch: ' + expectedHash + ' vs ' + hash);
process.exit(1);
}
console.log('Done.');
});
});
});
}
}
};

console.log('Downloading correct solidity binary...');

getVersionList(function (list) {
list = JSON.parse(list);
const wanted = pkg.version.match(/^(\d+\.\d+\.\d+)$/)[1];
const releaseFileName = list.releases[wanted];
const expectedFile = list.builds.filter(function (entry) { return entry.path === releaseFileName; })[0];
if (!expectedFile) {
console.log('Version list is invalid or corrupted?');
process.exit(1);
}
const expectedHash = expectedFile.keccak256;
downloadBinary('soljson.js', releaseFileName, expectedHash);
});
download(pkg.version.match(/^(\d+\.\d+\.\d+)$/)[1]);
64 changes: 64 additions & 0 deletions downloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as fs from 'fs';
import { https } from 'follow-redirects';
import MemoryStream from 'memorystream';
import { hashFile } from './common/helpers';

function getVersionList (host: string): Promise<string> {
console.log('Retrieving available version list...');

return new Promise<string>((resolve, reject) => {
Copy link
Member

Choose a reason for hiding this comment

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

Any reason to directly use promises rather than async/await here?

Copy link
Member Author

@r0qs r0qs Sep 5, 2022

Choose a reason for hiding this comment

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

Yes. As far as I know the https.getmodule from nodejs is not "awaitable" so I needed to wrap it in a promise and explicitly resolve the response.

const mem = new MemoryStream(null, { readable: false });
https.get(`${host}/bin/list.json`, function (response) {
if (response.statusCode !== 200) {
Copy link
Member

Choose a reason for hiding this comment

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

It's something that has already been here before but we should change this so that anything between 200 and 299 is a success. I think we also have the same check in other places in the code

But I'd do it in a separate PR since it's a functional change, not just a refactor and is also unrelated to tests.

Copy link
Contributor

Choose a reason for hiding this comment

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

response.ok?

Copy link
Member Author

@r0qs r0qs Nov 4, 2022

Choose a reason for hiding this comment

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

I don't think the https nodejs module has such ok property. I know that the response object of the fecth API has it, but it is not what we are using here. However, we could mimic its behavior.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, you're right, I was actually looking at the fetch API when I made the comment.

reject(new Error('Error downloading file: ' + response.statusCode));
}
response.pipe(mem);
response.on('end', function () {
resolve(mem.toString());
});
}).on('error', (err) => {
reject(err);
});
});
}

function downloadBinary (host: string, outputName: string, releaseFile: string, expectedHash: string): Promise<void> {
console.log('Downloading version', releaseFile);

return new Promise<void>((resolve, reject) => {
// Remove if existing
if (fs.existsSync(outputName)) {
fs.unlinkSync(outputName);
}

process.on('SIGINT', function () {
fs.unlinkSync(outputName);
reject(new Error('Interrupted... file removed'));
});

const file = fs.createWriteStream(outputName, { encoding: 'binary' });
https.get(`${host}/bin/${releaseFile}`, function (response) {
if (response.statusCode !== 200) {
reject(new Error('Error downloading file: ' + response.statusCode));
}
response.pipe(file);
file.on('finish', function () {
file.close();
const hash = hashFile(outputName);
if (expectedHash !== hash) {
reject(new Error('Hash mismatch: expected ' + expectedHash + ' but got ' + hash));
} else {
console.log('Done.');
resolve();
}
});
}).on('error', (err) => {
reject(err);
});
});
}

export = {
getVersionList,
downloadBinary
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.1",
"nock": "^13.2.9",
"nyc": "^15.1.0",
"tape": "^4.11.0",
"tape-spawn": "^1.4.2",
Expand Down
187 changes: 187 additions & 0 deletions test/downloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import * as tmp from 'tmp';
import tape from 'tape';
import nock from 'nock';
import fs from 'fs';
import path from 'path';
import { https } from 'follow-redirects';
import downloader from '../downloader';
import { keccak256 } from 'js-sha3';
import { hashFile } from '../common/helpers';

const assets = path.resolve(__dirname, 'resources/assets');

tape.onFinish(() => {
if (!nock.isDone()) {
throw Error('Expected download requests were not performed');
}
});

function generateTestFile (t: tape.Test, content: string): tmp.FileResult {
// As the `keep` option is set to true the removeCallback must be called by the caller
// to cleanup the files after the test.
const file = tmp.fileSync({ template: 'soljson-XXXXXX.js', keep: true });
try {
fs.writeFileSync(file.name, content);
} catch (err) {
t.fail(`Error writing test file: ${err.message}`);
}

return file;
}

function versionListMock (host: string): nock.Interceptor {
return nock(host).get('/bin/list.json');
}

function downloadBinaryMock (host: string, filename: string): nock.Interceptor {
return nock(host).get(`/bin/${path.basename(filename)}`);
}

function defaultListener (req: any, res: any): void {
res.writeHead(200);
res.end('OK');
};

async function startMockServer (listener = defaultListener): Promise<any> {
const server = https.createServer({
key: fs.readFileSync(path.resolve(assets, 'key.pem')),
cert: fs.readFileSync(path.resolve(assets, 'cert.pem'))
}, listener);

await new Promise(resolve => server.listen(resolve));
server.port = server.address().port;
server.origin = `https://localhost:${server.port}`;
return server;
}

tape('Download version list', async function (t) {
const server = await startMockServer();

t.teardown(function () {
server.close();
nock.cleanAll();
});

t.test('successfully get version list', async function (st) {
const dummyListPath = path.resolve(assets, 'dummy-list.json');
versionListMock(server.origin).replyWithFile(200, dummyListPath, {
'Content-Type': 'application/json'
});

try {
const list = JSON.parse(
await downloader.getVersionList(server.origin)
);
const expected = require(dummyListPath);
st.deepEqual(list, expected, 'list should match');
st.equal(list.latestRelease, expected.latestRelease, 'latest release should be equal');
} catch (err) {
st.fail(err.message);
}
st.end();
});

t.test('should throw an exception when version list not found', async function (st) {
versionListMock(server.origin).reply(404);

try {
await downloader.getVersionList(server.origin);
st.fail('should throw file not found error');
} catch (err) {
st.equal(err.message, 'Error downloading file: 404', 'should throw file not found error');
}
st.end();
});
});

tape('Download binary', async function (t) {
const server = await startMockServer();
const content = '() => {}';
const tmpDir = tmp.dirSync({ unsafeCleanup: true, prefix: 'solcjs-download-test-' }).name;

t.teardown(function () {
server.close();
nock.cleanAll();
});

t.test('successfully download binary', async function (st) {
const targetFilename = `${tmpDir}/target-success.js`;
const file = generateTestFile(st, content);

st.teardown(function () {
file.removeCallback();
});

downloadBinaryMock(server.origin, file.name)
.replyWithFile(200, file.name, {
'content-type': 'application/javascript',
'content-length': content.length.toString()
});

try {
await downloader.downloadBinary(
server.origin,
targetFilename,
file.name,
hashFile(file.name)
);

if (!fs.existsSync(targetFilename)) {
st.fail('download failed');
}

const got = fs.readFileSync(targetFilename, { encoding: 'binary' });
const expected = fs.readFileSync(file.name, { encoding: 'binary' });
st.equal(got.length, expected.length, 'should download the correct file');
} catch (err) {
st.fail(err.message);
}
st.end();
});

t.test('should throw an exception when file not found', async function (st) {
const targetFilename = `${tmpDir}/target-fail404.js`;
downloadBinaryMock(server.origin, 'test.js').reply(404);

try {
await downloader.downloadBinary(
server.origin,
targetFilename,
'test.js',
`0x${keccak256('something')}`
);
st.fail('should throw file not found error');
} catch (err) {
st.equal(err.message, 'Error downloading file: 404', 'should throw file not found error');
}
st.end();
});

t.test('should throw an exception if hashes do not match', async function (st) {
const targetFilename = `${tmpDir}/target-fail-hash.js`;
const file = generateTestFile(st, content);

st.teardown(function () {
file.removeCallback();
});

downloadBinaryMock(server.origin, file.name)
.replyWithFile(200, file.name, {
'content-type': 'application/javascript',
'content-length': content.length.toString()
});

try {
await downloader.downloadBinary(
server.origin,
targetFilename,
file.name,
`0x${keccak256('something')}`
);
st.fail('should throw hash mismatch error');
} catch (err) {
st.match(err.message, /Hash mismatch/, 'should detect hash mismatch');
}
st.end();
});
});
1 change: 1 addition & 0 deletions test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import('./compiler');
import('./smtcallback');
import('./smtchecker');
import('./abi');
import('./downloader');

// The CLI doesn't support Node 4
if (semver.gte(process.version, '5.0.0')) {
Expand Down
9 changes: 9 additions & 0 deletions test/resources/assets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
The certificates in this folder are only for testing purposes and are **not** valid certificates.

They were generated using the following command:
- Generate an RSA private key:
`openssl genrsa -out key.pem`
- Generate a csr using all default options and common name as "localhost":
`openssl req -new -key key.pem -out csr.pem`
- Self-sign the certificate:
`openssl x509 -req -days 9999 -in csr.pem -signkey key.pem -out cert.pem`
Loading