diff --git a/common/helpers.ts b/common/helpers.ts index c21a91c6..78543649 100644 --- a/common/helpers.ts +++ b/common/helpers.ts @@ -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. * @@ -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' })); +} diff --git a/downloadCurrentVersion.ts b/downloadCurrentVersion.ts index 6b524a49..f668f7a5 100755 --- a/downloadCurrentVersion.ts +++ b/downloadCurrentVersion.ts @@ -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]); diff --git a/downloader.ts b/downloader.ts new file mode 100755 index 00000000..6e151bc7 --- /dev/null +++ b/downloader.ts @@ -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 { + console.log('Retrieving available version list...'); + + return new Promise((resolve, reject) => { + const mem = new MemoryStream(null, { readable: false }); + https.get(`${host}/bin/list.json`, function (response) { + if (response.statusCode !== 200) { + 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 { + console.log('Downloading version', releaseFile); + + return new Promise((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 +}; diff --git a/package.json b/package.json index 8a85ef3f..1df545d6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/downloader.ts b/test/downloader.ts new file mode 100644 index 00000000..0710e1d0 --- /dev/null +++ b/test/downloader.ts @@ -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 { + 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(); + }); +}); diff --git a/test/index.ts b/test/index.ts index 9815fa16..1e45c4a5 100644 --- a/test/index.ts +++ b/test/index.ts @@ -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')) { diff --git a/test/resources/assets/README.md b/test/resources/assets/README.md new file mode 100644 index 00000000..643c87e3 --- /dev/null +++ b/test/resources/assets/README.md @@ -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` \ No newline at end of file diff --git a/test/resources/assets/cert.pem b/test/resources/assets/cert.pem new file mode 100644 index 00000000..ca6a5f25 --- /dev/null +++ b/test/resources/assets/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOzCCAiMCFHpFewtcvLRCZbPPcl04e2vNaFtBMA0GCSqGSIb3DQEBCwUAMFkx +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMjA5 +MTYxNjExMzRaGA8yMDUwMDEzMTE2MTEzNFowWTELMAkGA1UEBhMCQVUxEzARBgNV +BAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0 +ZDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAqx1uPfkuqDqphLOwAMhvQqYWec7037WOrpO/5mHees9fVAX2QyGxV+Dg +zTCHJ4Jn4ATkw3i4yfgEwa4cuHhBuopmK7WCR3F0x8HLR6XA3nupWUbplT6L8BqH +9YOrT8XRnbQuIV5+y7p9AkTh4WaLFQXUfnlU9Pvu3qIp9eYt1QDMY9NVF2IMPXsk +2CigcqwBPVya6PHNbPYZiEW7B6Hh+rIk1yoG/bNdh3ZCLPBjX6tfpmxH8KZgfAZ4 +Q83naav7ABoTL2FQIwwMBKYinvVkzissY/s9cwDmSS3q1pc1CIRLwjuEr9OfdUIk +JNYqM6Bu2J8mpL3tyJTMTsx6OdtrNQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCT +X/hdb5S7s4Nzg8m/DsfaazftqwAtJVP7ZhXDewJ9/QKpA85mqcQDAZg+1EOVXRmW +VVSrEeOPm88T1y/aNBEYOL86koKHmvu5KwVvkVZGbebDKdSljdp8B1FEncGeQjXF +3lusP8DLCkzR0Lk4fORiVn9cdbgo8qM5rQMlF2DOJSVXgtEsp6HUJ3CZ9kdJ8QHO +/nL5R1ADTIkQHL73JmIxrAU6GQdMU0YqYybaZa0uOcYHBm8F9c/nGRj2Whm8SQ84 +WOA9cA/vdbdqSr80LOQ2kvx9Na9tGBRvOaHZT3q6CfysmQdvoogqLFLIwof8Sz2O +PfL6Z1Y7lAFnd+ThVK/z +-----END CERTIFICATE----- diff --git a/test/resources/assets/csr.pem b/test/resources/assets/csr.pem new file mode 100644 index 00000000..4e0e9f99 --- /dev/null +++ b/test/resources/assets/csr.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICnjCCAYYCAQAwWTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx +ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAwwJbG9j +YWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqx1uPfkuqDqp +hLOwAMhvQqYWec7037WOrpO/5mHees9fVAX2QyGxV+DgzTCHJ4Jn4ATkw3i4yfgE +wa4cuHhBuopmK7WCR3F0x8HLR6XA3nupWUbplT6L8BqH9YOrT8XRnbQuIV5+y7p9 +AkTh4WaLFQXUfnlU9Pvu3qIp9eYt1QDMY9NVF2IMPXsk2CigcqwBPVya6PHNbPYZ +iEW7B6Hh+rIk1yoG/bNdh3ZCLPBjX6tfpmxH8KZgfAZ4Q83naav7ABoTL2FQIwwM +BKYinvVkzissY/s9cwDmSS3q1pc1CIRLwjuEr9OfdUIkJNYqM6Bu2J8mpL3tyJTM +Tsx6OdtrNQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAH49b7ktMCNIScGNIgy1 +XeF+sUrXtOYv67vhIPxF8y9ep7Lm6nHOGTzEmoHqf0ogHTb4cGclgakdaRAH65eU +BZvIaxEUFdV7G787ESkXdyvtvZB6cc6/ZJSUPsnpoGkS08kNrzZBXfMBapoawyTN +g31zyerxjRWTEKEBB7u74cTyamjNEgml0cOa7VB6CkPI9PM2/VkYLCpqZNLTbzSE +GHXzouzE7ajZ4dxx3aEsXXjZLypWimjZJA3zcvl9Jv1p6zEkDwnjofOcGiIDmdgu +sdlj3v9Efv/0A03Q7z/rr/Gl83lskFQB5VTU1DBrkFJ4D6VueWjwBrqff3rGKKhm +CvQ= +-----END CERTIFICATE REQUEST----- diff --git a/test/resources/assets/dummy-list.json b/test/resources/assets/dummy-list.json new file mode 100644 index 00000000..579a9cc4 --- /dev/null +++ b/test/resources/assets/dummy-list.json @@ -0,0 +1,33 @@ +{ + "builds": [ + { + "path": "soljson-v0.8.16+commit.07a7930e.js", + "version": "0.8.16", + "build": "commit.07a7930e", + "longVersion": "0.8.16+commit.07a7930e", + "keccak256": "0x331f4bc6de3d44d87b68629e83f711105325b482da7e9ca9bdbdd01371fee438", + "sha256": "0x27b2820ef93805a65c76b7945a49432582d306fd17a28985709a51e6403677c2", + "urls": [ + "bzzr://af0d70945c85865298732ac2bfdacdf2774fb4daf793c94fafe135b839a60a5c", + "dweb:/ipfs/QmWzBJ8gdccvRSSB5YsMKiF2qt3RFmAP2X25QEWqqQnR4y" + ] + }, + { + "path": "soljson-v0.8.17+commit.8df45f5f.js", + "version": "0.8.17", + "build": "commit.8df45f5f", + "longVersion": "0.8.17+commit.8df45f5f", + "keccak256": "0x3f2be218cf4545b4d2e380417c6da1e008fdc4255ab38c9ee12f64c0e3f55ea9", + "sha256": "0x617828e63be485c7cc2dbcbdd5a22b582b40fafaa41016ad595637b83c90656c", + "urls": [ + "bzzr://fe8da5b2531d31e4b67acdce09c81eccba1100550a7222722152ffdb16ea85ef", + "dweb:/ipfs/QmTedx1wBKSUaLatuqXYngjfKQLD2cGqPKjdLYCnbMYwiz" + ] + } + ], + "releases": { + "0.8.17": "soljson-v0.8.17+commit.8df45f5f.js", + "0.8.16": "soljson-v0.8.16+commit.07a7930e.js" + }, + "latestRelease": "0.8.17" +} \ No newline at end of file diff --git a/test/resources/assets/key.pem b/test/resources/assets/key.pem new file mode 100644 index 00000000..ac62aa32 --- /dev/null +++ b/test/resources/assets/key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAqx1uPfkuqDqphLOwAMhvQqYWec7037WOrpO/5mHees9fVAX2 +QyGxV+DgzTCHJ4Jn4ATkw3i4yfgEwa4cuHhBuopmK7WCR3F0x8HLR6XA3nupWUbp +lT6L8BqH9YOrT8XRnbQuIV5+y7p9AkTh4WaLFQXUfnlU9Pvu3qIp9eYt1QDMY9NV +F2IMPXsk2CigcqwBPVya6PHNbPYZiEW7B6Hh+rIk1yoG/bNdh3ZCLPBjX6tfpmxH +8KZgfAZ4Q83naav7ABoTL2FQIwwMBKYinvVkzissY/s9cwDmSS3q1pc1CIRLwjuE +r9OfdUIkJNYqM6Bu2J8mpL3tyJTMTsx6OdtrNQIDAQABAoIBAQCiRYi0UMiEVQ1J +gTXZqDqK1ALghPkH5Z9nw0nq6skXYe0fO4AsbZlGo0XMkMa7GxnzAfEHxkZHkmLA +YaNu9OwwQOtfbAQeSGPcw9VJHtFmq5MPzQXauLPwgNiKZdFiF1EP4z0T/A72mOXP +7w7euGYRC7RWNC3zHa+LhfGQ9jZklY1nIFBThGXs5yGUENnmH6AF5wTXRmHev0Fd +qmNfKVff3Hf7ndqwNBQ5zglkR43/CpsHrp410lGyKKpDgmum/OCfMBJr2VP7/BKv +fouvBOc48Pl8Iq3rFia4VU8XOTgXvfGat/l9ZmDDIfWTTrC92H6prgrZHC421Vl3 +SNTVt9XJAoGBAN+qCy5fcawLcJ4CIVl3d2sChmS3fo1HLSuAi+L1ZDYR41Wrd1l8 +n8v9lEtVLbyaIUyMu1g+2UCpz+wLR4icMm2QDZPNp+WuNd+/u99oNsrF3DBesHMM +XFsX/rb37mt4X2sNFAHSmmgn+OJvFPWn2hl9O28HZYu9fgH/axrIzg8DAoGBAMPa +gT91VKcSLg5IUpDS6ieaFO8MJJR10mtvfHttU7ZmWwf7AuIJ3hOYMPRrZF1omjzP +EDgQkhEuFVA4xOrtb8Rm+cxzJiKbMXDBLIzKWEIj9HQvjZfrK1hYKKwR9xPAca9A +WxbbR6Psd/jjTWB8aeyXiIGu3Q+XhrqSmBTvBMtnAoGAHwI2NqGo4s9bN6zX7s1v +UviwpToDY5bgnk1eF4Mp940XVYNGMrPie6eWbbNLegBsaW6BcsST3jEs0G7n5fqx +yxrTpxA2HNPASFsOsvSB1C8GTfZyxCCMlVyIRdmtxlyT7gWMJoyKN7KDAPGhmEVD +KZGBMe12mJ8W3zQIJ1h7qP0CgYBIiRR1PC8t8T29bGun13WBVT+LiSPDGB/mSlQA +Im1ukFoGiqB2ttDY0PTQkTWKosL84nNPUFPIqRibrWrA108dnkXFwp/NEU0uRV0K +h6ZIWKignzwBl8dl4MCvebfYSN3R2jPiMzRFMb79HrH5mhpg1X5pCSo2LBhp4QES +P7O24QKBgQCOcF9xYV7hqPci4EW8GDLe27GDP4qJsMm5tbwgMXKz8+bCOaVFHkqM +XvDk+6RQ0TUEMicZlO69EPlQ5Ev1jd+gO17/hchhjVw3kCstSknFlMTkHJMPdu5B +Gn1UbFJkNnpM3po0nKgGCx7M78aw1WBYqQI9k97Zw07nwGoBp4yZfA== +-----END RSA PRIVATE KEY-----