diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9df8ef9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm test diff --git a/lib/client.js b/lib/client.js index 7900023..3d7f646 100644 --- a/lib/client.js +++ b/lib/client.js @@ -27,7 +27,7 @@ class Client { this.subscribe = new EventEmitter(); this.mp = new util.MessageParser((body, n) => { this.onMessage(body, n); - }); + }, '\n', options && options.maxBuffer); this._protocol = protocol; // saving defaults this._options = options; @@ -195,7 +195,14 @@ class Client { } onRecv(chunk) { - this.mp.run(chunk); + try { + this.mp.run(chunk); + + } catch (err) { + // the server is sending something we refuse to hold: report it and drop the connection + this.onError(err); + this.conn.destroy(); + } } onError(e) { diff --git a/lib/util.js b/lib/util.js index 3de9c54..0d39e0a 100644 --- a/lib/util.js +++ b/lib/util.js @@ -9,26 +9,6 @@ const makeRequest = (exports.makeRequest = (method, params, id) => { }); }); -const createRecuesiveParser = (exports.createRecuesiveParser = (max_depth, delimiter) => { - const MAX_DEPTH = max_depth; - const DELIMITER = delimiter; - const recursiveParser = (n, buffer, callback) => { - if (buffer.length === 0) { - return { code: 0, buffer: buffer }; - } - if (n > MAX_DEPTH) { - return { code: 1, buffer: buffer }; - } - const xs = buffer.split(DELIMITER); - if (xs.length === 1) { - return { code: 0, buffer: buffer }; - } - callback(xs.shift(), n); - return recursiveParser(n + 1, xs.join(DELIMITER), callback); - }; - return recursiveParser; -}); - const createPromiseResult = (exports.createPromiseResult = (resolve, reject) => { return (err, result) => { if (err) reject(err); @@ -49,21 +29,41 @@ const createPromiseResultBatch = (exports.createPromiseResultBatch = (resolve, r }; }); +const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024; + class MessageParser { - constructor(callback) { + constructor(callback, delimiter = '\n', maxBuffer = DEFAULT_MAX_BUFFER) { this.buffer = ''; this.callback = callback; - this.recursiveParser = createRecuesiveParser(20, '\n'); + this.delimiter = delimiter; + this.maxBuffer = maxBuffer; } + + // Feed received text; the callback gets each complete message. Each message + // is cut out with a single indexOf, so a large reply costs time linear in its size. run(chunk) { this.buffer += chunk; - while (true) { - const res = this.recursiveParser(0, this.buffer, this.callback); - this.buffer = res.buffer; - if (res.code === 0) { - break; + + let start = 0; + let end; + let n = 0; + while ((end = this.buffer.indexOf(this.delimiter, start)) !== -1) { + const body = this.buffer.slice(start, end); + start = end + this.delimiter.length; + + if (body.length > 0) { + this.callback(body, n++); } } + + this.buffer = start > 0 ? this.buffer.slice(start) : this.buffer; + + if (this.buffer.length > this.maxBuffer) { + // a message that never ends: stop holding on to it + this.buffer = ''; + + throw new Error(`Received more than ${this.maxBuffer} bytes without a message delimiter`); + } } } exports.MessageParser = MessageParser; diff --git a/package.json b/package.json index d6bf75e..7e2cdbe 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,16 @@ "description": "Electrum protocol client for React Native & Node.js", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test test/*.test.js" }, "dependencies": {}, "devDependencies": {}, "repository": { "type": "git", - "url": "git://github.com/janoside/electrum-client.git" + "url": "git+https://github.com/2rdzy/electrum-client.git" }, "bugs": { - "url": "https://github.com/janoside/electrum-client/issues" + "url": "https://github.com/2rdzy/electrum-client/issues" }, "keywords": [ "bitcoin", @@ -21,13 +21,26 @@ "electrumx" ], "engines": { - "node": ">=6" + "node": ">=18" }, "contributors": [ - { "name": "Yuki Akiyama" }, - { "name": "7kharov" }, - { "name": "overtorment" }, - { "name": "janoside" } + { + "name": "Yuki Akiyama" + }, + { + "name": "7kharov" + }, + { + "name": "overtorment" + }, + { + "name": "janoside" + } ], - "license": "MIT" + "license": "MIT", + "homepage": "https://github.com/2rdzy/electrum-client#readme", + "files": [ + "index.js", + "lib" + ] } diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..36653c9 --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,67 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { after, before, describe, test } = require('node:test'); + +const { startServer } = require('./helpers/server.js'); +const { withClient } = require('./helpers/client.js'); + +const handler = request => { + switch (request.method) { + case 'server.version': return ['test-server 1.0', '1.4']; + case 'echo': return request.params; + case 'blockchain.scripthash.get_balance': return { confirmed: request.params[0].length, unconfirmed: 0 }; + case 'fail': throw { code: 2, message: 'nope' }; + default: throw { code: -32601, message: `unknown method ${request.method}` }; + } +}; + +for (const [protocol, useTls] of [['tcp', false], ['tls', true]]) { + describe(`over ${protocol}`, () => { + let server; + + before(async () => { server = await startServer({ useTls, handler }); }); + after(() => server.close()); + + test('negotiates the protocol version', () => withClient(server, protocol, client => { + assert.deepEqual(client.versionInfo, ['test-server 1.0', '1.4']); + })); + + test('resolves a request with its result', () => withClient(server, protocol, async client => { + assert.deepEqual(await client.request('echo', ['a', 1]), ['a', 1]); + })); + + test('rejects a request with the server error', () => withClient(server, protocol, async client => { + await assert.rejects(client.request('fail', []), { code: 2, message: 'nope' }); + })); + + test('keeps concurrent requests apart', () => withClient(server, protocol, async client => { + const results = await Promise.all([1, 2, 3, 4, 5].map(n => client.request('echo', [n]))); + + assert.deepEqual(results, [[1], [2], [3], [4], [5]]); + })); + + test('resolves a batch with one entry per parameter, tagged with it', () => withClient(server, protocol, async client => { + const results = await client.blockchainScripthash_getBalanceBatch(['aa', 'bbbb', 'cccccc']); + + assert.deepEqual(results.map(r => r.param), ['aa', 'bbbb', 'cccccc']); + assert.deepEqual(results.map(r => r.result.confirmed), [2, 4, 6]); + })); + + test('emits subscription notifications', () => withClient(server, protocol, async client => { + const notified = new Promise(resolve => client.subscribe.once('blockchain.headers.subscribe', resolve)); + + server.broadcast(JSON.stringify({ jsonrpc: '2.0', method: 'blockchain.headers.subscribe', params: [{ height: 7 }] })); + + assert.deepEqual(await notified, [{ height: 7 }]); + })); + + test('refuses requests after close', async () => { + await withClient(server, protocol, async client => { + client.close(); + + await assert.rejects(client.request('echo', []), /Connection to server lost/); + }); + }); + }); +} diff --git a/test/fixtures/cert.pem b/test/fixtures/cert.pem new file mode 100644 index 0000000..a2ae783 --- /dev/null +++ b/test/fixtures/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUC8QQdK2UWC7Gzx1+oiCJs2ear6wwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAwNTIwMTMyNVoYDzIxMjYw +OTExMjAxMzI1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCpRxHcBqjb/VShru2qOjUGM+B9owDhiwKzM47xQ/iF +G7k0mtbFv9lHTL9NWTTEHy7prg461Z2XCP3QeNYYjV4udy9G2U9YuJuHHT+gx7br +Cr1Q6WT6kqc7RXe0R79GggqHpi02s3g/qbVO/OalBSvVBFpxxw9uMdG9Q7V5y7Jn +WI9NmYB/WRkBQS4dygw+wjS4igUF3j6vwR40gXm3QA66RxdSDNTOLnWLp6On92wj +DpmGNPciXCxhXquwW0o7ylRnQYsXpafHEMHl1uGjJQpXBuvgV/q1VmLFNKbp00T0 +YxiXV2gQA+IgnFMSQ7XKoKHPVJx1gPr97ERTiLYe/ci7AgMBAAGjbzBtMB0GA1Ud +DgQWBBT+7ktr7S5GLg39hICE95f36RZXzTAfBgNVHSMEGDAWgBT+7ktr7S5GLg39 +hICE95f36RZXzTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAhSP9Gxcxde1s8jfTStMn+VNmKGG3 +cWIVnw1IYf8PCTUx5z2gi1MjBpEV4QyL0tWNvfk0yfDCZJ24kXqLbgo338T7A5pQ +SfJJ1XMP/INRWtcsrnQZA4MdflD+9LzDjSaTNw8rLTQqemrRGLcDXfwmk+rAsGSJ +N20d/Txy1Ixlu7bBE95vMS5nHMK3P6XtlbZPGdDyZnN7JoomUYghWUkmtM00w1Ck +WPqNBCVsTRJZgyhgzvIXEBD7XcN4vMR6wWoTUhRxpW9QqlSQyzVr/Ik9DBh+QZ/n +DVdsD2+EMFtg0jzOJ9+fn+dMYpNJOjis8D1hAJZiku500/R2xjlmVdwYeg== +-----END CERTIFICATE----- diff --git a/test/fixtures/key.pem b/test/fixtures/key.pem new file mode 100644 index 0000000..03bc7b3 --- /dev/null +++ b/test/fixtures/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCpRxHcBqjb/VSh +ru2qOjUGM+B9owDhiwKzM47xQ/iFG7k0mtbFv9lHTL9NWTTEHy7prg461Z2XCP3Q +eNYYjV4udy9G2U9YuJuHHT+gx7brCr1Q6WT6kqc7RXe0R79GggqHpi02s3g/qbVO +/OalBSvVBFpxxw9uMdG9Q7V5y7JnWI9NmYB/WRkBQS4dygw+wjS4igUF3j6vwR40 +gXm3QA66RxdSDNTOLnWLp6On92wjDpmGNPciXCxhXquwW0o7ylRnQYsXpafHEMHl +1uGjJQpXBuvgV/q1VmLFNKbp00T0YxiXV2gQA+IgnFMSQ7XKoKHPVJx1gPr97ERT +iLYe/ci7AgMBAAECggEABiIzx3aGT9JSFWRdQDnJChHUeeOaIzys5bQMjeQZ8S4b ++91QbZc8rD9fqdHS/k2S9EIA9KjLg67TuOYSpo5uHIjnsaLWI9oskyzAIsyZwtJ/ +dvBotEZJa6BiF1Tc3WqA2dbE9KJ0Ik+9PhYegAQ787AAsaFg3Gv5tD3r1B9JYO00 +I2nQ36zxw1YlOwHS5LBgYSePHlVP2BYo6M21C/JUs6oECL0DshNRkbE0qLv95trL +JHq0PRx/0ABwpcfgjEDftvA/kFsxDrP3HEnoHIdvGawO/wiURQQiIEEIwtGkMmTP +/7hXSUOf8XWrRk1+9ljxHpIhMEizbVgW0vjvQi9a4QKBgQDQSQhmzef9zM677qvs +xmjdP4S5XQMQXxCRXRYBVs15OURM+MRvWG+zhuhnMMI8tNe9jNCci3jpk5O5NKUN +Bn3P/SLGkr9odzD2wZ+9dw/KHpaCwuGqnngTGKgTaBWA0yyl6Kk59lUbABz0T9vN +87xREtUF2x752Vugpb7wZjt6RwKBgQDQDmr6fA/tUlrPxyb5QINXmFzNrv2WYCQn +L4mZi8fAaSUIb04+LGaKSEr8JhGKir5Vgy7OLQQT5AuPWeBkJcP324jddvJ03cdO +WSVGQ2r1pOjKz+N8egFVGCgi+YE21H2g6LbpXRH4vm/pLv1m7Agp+qy/mJkWe7kn +4GkVKStD7QKBgBhLLGuxQIndxY8t+2eWjMyhh/2G18ClFe2eid6GHVkyUMEZku/Z +xM2PwrOmgEFMZo15Syu3KNc1m1lSxp2KUEpeIBxBKYopvNBLLTKQHItanfuKd3/L +E/5GNfRtg3SPLPn89SAJl0ua68WjOuP+tcxPAgG42RgTPhiyvF6VzgIpAoGBAMJf +pewr+YlDNloFedDFR6+PRaxXPsy9NylwzQGpSE3nLVTq/9MwEgBAt+VH/31KT7V+ +ID04ayXWGEKtueh2K9YkwIH+vVcXVztIJ320QHoLvfy0KZhCN3pzXhQGu4QpEtFA +eJoefErCR7U3VEBSZPIrIjbBbsxFfF7X1fSSCLylAoGBAKZ652nLK3Tctg5F1PVm +PMO0K4Vs4rcXBURFq6vLB5QEs0pSC3kjYhrbT8cW6L8zThueFnGrK9SopGing/e8 +d6zhhkx5C+OzkDxbyirJKOzCtjrssVTEZId17dDPfr06YDXsD6+6pvFYxeDRxL5J +WhiL3lHrR0+npjrBWe9zJjpy +-----END PRIVATE KEY----- diff --git a/test/helpers/client.js b/test/helpers/client.js new file mode 100644 index 0000000..7f3b678 --- /dev/null +++ b/test/helpers/client.js @@ -0,0 +1,23 @@ +'use strict'; + +// The client expects net and tls to be injected as globals (see lib/client.js). +global.net = require('net'); +global.tls = require('tls'); + +const ElectrumClient = require('../../index.js'); + +// Connect a client to `server`, run `fn(client)`, and always close the client. +async function withClient(server, protocol, fn, { options, callbacks } = {}) { + const client = new ElectrumClient(server.port, '127.0.0.1', protocol, options, callbacks); + + await client.initElectrum({ client: 'electrum-client-test', version: '1.4' }, { retryPeriod: 10, maxRetry: 0, callback: () => {} }); + + try { + return await fn(client); + + } finally { + client.close(); + } +} + +module.exports = { ElectrumClient, withClient }; diff --git a/test/helpers/server.js b/test/helpers/server.js new file mode 100644 index 0000000..439b5d8 --- /dev/null +++ b/test/helpers/server.js @@ -0,0 +1,78 @@ +'use strict'; + +// A minimal Electrum-protocol server for tests: newline-delimited JSON-RPC over +// TCP or TLS. `handler(request)` returns the result for a request, or throws +// `{code, message}` to send an error. + +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const tls = require('tls'); + +const fixtures = path.join(__dirname, '..', 'fixtures'); + +const tlsOptions = { + key: fs.readFileSync(path.join(fixtures, 'key.pem')), + cert: fs.readFileSync(path.join(fixtures, 'cert.pem')), +}; + +function reply(request, handler) { + try { + return { jsonrpc: '2.0', id: request.id, result: handler(request) }; + + } catch (err) { + return { jsonrpc: '2.0', id: request.id, error: { code: err.code || -1, message: err.message || String(err) } }; + } +} + +async function startServer({ useTls = false, handler = req => req.params } = {}) { + const sockets = new Set(); + const received = []; + + const onConnection = socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => {}); + socket.setEncoding('utf8'); + + let buffer = ''; + socket.on('data', chunk => { + buffer += chunk; + + let index; + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + + const message = JSON.parse(line); + received.push(message); + + const out = Array.isArray(message) + ? message.map(request => reply(request, handler)) + : reply(message, handler); + + socket.write(JSON.stringify(out) + '\n'); + } + }); + }; + + const server = useTls ? tls.createServer(tlsOptions, onConnection) : net.createServer(onConnection); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + + return { + port: server.address().port, + received, + sockets, + // send a raw line to every connected client (for notifications and malformed input) + broadcast: line => sockets.forEach(socket => socket.write(line + '\n')), + // send raw text with no line ending + write: text => sockets.forEach(socket => socket.write(text)), + close: () => new Promise(resolve => { + sockets.forEach(socket => socket.destroy()); + server.close(resolve); + }), + }; +} + +module.exports = { startServer, tlsOptions }; diff --git a/test/limits.test.js b/test/limits.test.js new file mode 100644 index 0000000..168ecf4 --- /dev/null +++ b/test/limits.test.js @@ -0,0 +1,29 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); + +const { startServer } = require('./helpers/server.js'); +const { withClient } = require('./helpers/client.js'); + +describe('limits', () => { + test('drops the connection and reports an error when a message grows past maxBuffer', async () => { + const server = await startServer({ handler: request => request.method === 'server.version' ? ['t', '1.4'] : request.params }); + const errors = []; + let closed; + const closedPromise = new Promise(resolve => { closed = resolve; }); + + try { + await withClient(server, 'tcp', async client => { + server.write('x'.repeat(5000)); + + await closedPromise; + + assert.match(errors[0].message, /without a message delimiter/); + }, { options: { maxBuffer: 1000 }, callbacks: { onError: e => errors.push(e), onClose: () => closed() } }); + + } finally { + await server.close(); + } + }); +}); diff --git a/test/util.test.js b/test/util.test.js new file mode 100644 index 0000000..822dc7c --- /dev/null +++ b/test/util.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); + +const { MessageParser } = require('../lib/util.js'); + +function collect(parser, ...chunks) { + const bodies = []; + parser.callback = body => bodies.push(body); + chunks.forEach(chunk => parser.run(chunk)); + + return bodies; +} + +describe('MessageParser', () => { + test('splits on newlines and joins messages split across chunks', () => { + assert.deepEqual(collect(new MessageParser(), 'a\nb', 'c\nd\n'), ['a', 'bc', 'd']); + }); + + test('skips empty lines', () => { + assert.deepEqual(collect(new MessageParser(), 'a\n\n\nb\n'), ['a', 'b']); + }); + + test('keeps an incomplete message until its end arrives', () => { + const parser = new MessageParser(); + + assert.deepEqual(collect(parser, 'abc'), []); + assert.deepEqual(collect(parser, 'def\n'), ['abcdef']); + }); + + test('handles more than twenty messages in one chunk', () => { + const lines = Array.from({ length: 5000 }, (_, i) => `m${i}`); + + assert.deepEqual(collect(new MessageParser(), lines.join('\n') + '\n'), lines); + }); + + test('stays fast on a large chunk of many messages', () => { + const line = JSON.stringify({ id: 1, result: 'y'.repeat(200) }); + const chunk = (line + '\n').repeat(20000); + + const started = Date.now(); + const bodies = collect(new MessageParser(), chunk); + + assert.equal(bodies.length, 20000); + assert.ok(Date.now() - started < 2000, 'parsing took too long'); + }); + + test('refuses a message that never ends', () => { + const parser = new MessageParser(() => {}, '\n', 1000); + + assert.throws(() => parser.run('x'.repeat(2000)), /without a message delimiter/); + assert.equal(parser.buffer, ''); + }); +});