From c78b720a530f9668868f5404f4d023e74dc11faf Mon Sep 17 00:00:00 2001 From: Kevin Elliott Date: Fri, 27 Mar 2026 03:25:40 -0700 Subject: [PATCH 1/3] Replace Node.js built-ins with Web APIs for browser compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate from Node.js-specific `Buffer`, `minizlib`, and `base85` packages to cross-platform alternatives (`Uint8Array`, `pako`, pure JS ASCII85 decoder). This eliminates the need for `vite-plugin-node-polyfills` or equivalent in browser environments and removes all transitive production vulnerabilities from `base85` → `crypto-browserify` → `elliptic`. Changes: - Replace `Buffer` with `Uint8Array` + `TextEncoder`/`TextDecoder` - Replace `minizlib` with `pako` (pure JS zlib, works everywhere) - Replace `base85` npm package with pure JS ASCII85 decoder - Replace `Buffer.from(data, 'base64')` with `atob()`-based utility Closes #379 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/plugins/Label_H1_OHMA.ts | 53 +++++++++++-- lib/utils/ascii85.ts | 51 +++++++++++++ lib/utils/miam.ts | 109 +++++++++++++++------------ package-lock.json | 139 ++++------------------------------- package.json | 4 +- 5 files changed, 178 insertions(+), 178 deletions(-) create mode 100644 lib/utils/ascii85.ts diff --git a/lib/plugins/Label_H1_OHMA.ts b/lib/plugins/Label_H1_OHMA.ts index 1c6f8a2..96bb287 100644 --- a/lib/plugins/Label_H1_OHMA.ts +++ b/lib/plugins/Label_H1_OHMA.ts @@ -2,8 +2,45 @@ import { DecoderPlugin } from '../DecoderPlugin'; import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; import { ResultFormatter } from '../utils/result_formatter'; -import * as zlib from 'minizlib'; -import { Buffer } from 'node:buffer'; +import * as pako from 'pako'; + +const textDecoder = new TextDecoder(); + +function base64ToUint8Array(base64: string): Uint8Array { + // Match Buffer.from(str, 'base64') behavior: strip non-base64 chars, handle missing padding + const cleaned = base64.replace(/[^A-Za-z0-9+/]/g, ''); + const padded = cleaned + '='.repeat((4 - (cleaned.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Inflate compressed data with support for partial/truncated streams. + */ +function inflateData(data: Uint8Array): Uint8Array | undefined { + const chunks: Uint8Array[] = []; + const inflator = new pako.Inflate({ windowBits: 15 }); + inflator.onData = (chunk: Uint8Array) => { + chunks.push(chunk); + }; + inflator.push(data, 2); // Z_SYNC_FLUSH + + if (chunks.length === 0) return undefined; + if (chunks.length === 1) return chunks[0]; + + const totalLen = chunks.reduce((sum, c) => sum + c.length, 0); + const result = new Uint8Array(totalLen); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} export class Label_H1_OHMA extends DecoderPlugin { name = 'label-h1-ohma'; @@ -23,12 +60,12 @@ export class Label_H1_OHMA extends DecoderPlugin { const data = message.text.split('OHMA')[1]; // throw out '/RTNOCR.' - even though it means something try { - const compressedBuffer = Buffer.from(data, 'base64'); - const decompress = new zlib.Inflate({}); - decompress.write(compressedBuffer); - decompress.flush(zlib.constants.Z_SYNC_FLUSH); - const result = decompress.read(); - const jsonText = result?.toString() || ''; + const compressedBuffer = base64ToUint8Array(data); + const result = inflateData(compressedBuffer); + if (!result || result.length === 0) { + throw new Error('Decompression produced no output'); + } + const jsonText = textDecoder.decode(result); let formattedMsg; let jsonMessage; diff --git a/lib/utils/ascii85.ts b/lib/utils/ascii85.ts new file mode 100644 index 0000000..5a3a7fe --- /dev/null +++ b/lib/utils/ascii85.ts @@ -0,0 +1,51 @@ +/** + * Pure JavaScript ASCII85 (Adobe variant) decoder. + * Replaces the `base85` npm package to eliminate Node.js Buffer dependency. + * Works in browsers, Node.js 18+, Deno, Bun, and edge runtimes. + */ +export function ascii85Decode(input: string): Uint8Array | null { + let str = input; + if (str.startsWith('<~')) str = str.slice(2); + if (str.endsWith('~>')) str = str.slice(0, -2); + str = str.replace(/\s/g, ''); + if (str.length === 0) return new Uint8Array(0); + + const output: number[] = []; + let i = 0; + + while (i < str.length) { + if (str[i] === 'z') { + output.push(0, 0, 0, 0); + i++; + continue; + } + + const remaining = str.length - i; + const chunkLen = Math.min(5, remaining); + if (chunkLen === 1) break; // trailing single char from truncated input; ignore it + + let padded = str.slice(i, i + chunkLen); + while (padded.length < 5) padded += 'u'; + + let value = 0; + for (let j = 0; j < 5; j++) { + const digit = padded.charCodeAt(j) - 33; + if (digit < 0 || digit > 84) return null; + value = value * 85 + digit; + } + + if (chunkLen === 5 && value > 0xffffffff) return null; + + // Use >>> 0 to simulate uint32 overflow for padded groups + const v = value >>> 0; + const numBytes = chunkLen === 5 ? 4 : chunkLen - 1; + if (numBytes >= 1) output.push((v >>> 24) & 0xff); + if (numBytes >= 2) output.push((v >>> 16) & 0xff); + if (numBytes >= 3) output.push((v >>> 8) & 0xff); + if (numBytes >= 4) output.push(v & 0xff); + + i += chunkLen; + } + + return new Uint8Array(output); +} diff --git a/lib/utils/miam.ts b/lib/utils/miam.ts index 16e3f62..23057a8 100644 --- a/lib/utils/miam.ts +++ b/lib/utils/miam.ts @@ -1,6 +1,33 @@ -import * as Base85 from 'base85'; -import * as zlib from 'minizlib'; -import { Buffer } from 'node:buffer'; +import { ascii85Decode } from './ascii85'; +import * as pako from 'pako'; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +/** + * Inflate compressed data with support for partial/truncated streams. + * Captures output chunks via onData to handle Z_SYNC_FLUSH correctly. + */ +function inflateData(data: Uint8Array, raw: boolean): Uint8Array | undefined { + const chunks: Uint8Array[] = []; + const inflator = new pako.Inflate({ windowBits: raw ? -15 : 15 }); + inflator.onData = (chunk: Uint8Array) => { + chunks.push(chunk); + }; + inflator.push(data, 2); // Z_SYNC_FLUSH + + if (chunks.length === 0) return undefined; + if (chunks.length === 1) return chunks[0]; + + const totalLen = chunks.reduce((sum, c) => sum + c.length, 0); + const result = new Uint8Array(totalLen); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} enum MIAMVersion { V1 = 1, @@ -155,7 +182,7 @@ export class MIAMCoreUtils { }; } - let hdr = Base85.decode('<~' + rawHdr + '~>', 'ascii85'); + let hdr = ascii85Decode('<~' + rawHdr + '~>'); if (!hdr || hdr.length < hpad) { return { decoded: false, @@ -163,26 +190,26 @@ export class MIAMCoreUtils { }; } - let body: Buffer | undefined = undefined; + let body: Uint8Array | undefined = undefined; const rawBody = txt.substring(delimIdx + 1); if (rawBody.length > 0) { if ('0123'.indexOf(bpad) >= 0) { const bpadValue = parseInt(bpad); - body = Base85.decode('<~' + rawBody + '~>', 'ascii85') || undefined; + body = ascii85Decode('<~' + rawBody + '~>') || undefined; if (body && body.length >= bpadValue) { body = body.subarray(0, body.length - bpadValue); } } else if (bpad === '-') { - body = Buffer.from(rawBody); + body = textEncoder.encode(rawBody); } } hdr = hdr.subarray(0, hdr.length - hpad); - const version = hdr.readUInt8(0) & 0xf; - const pduType = (hdr.readUInt8(0) >> 4) & 0xf; + const version = hdr[0] & 0xf; + const pduType = (hdr[0] >> 4) & 0xf; if (isMIAMVersion(version) && isMIAMCorePdu(pduType)) { const versionPduHandler = @@ -225,7 +252,7 @@ export class MIAMCoreUtils { }, }; - private static arincCrc16(buf: Buffer, seed?: number) { + private static arincCrc16(buf: Uint8Array, seed?: number) { const crctable = [ 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, 0x1231, 0x0210, @@ -263,14 +290,14 @@ export class MIAMCoreUtils { for (let i = 0; i < buf.length; i++) { crc = (((crc << 8) >>> 0) ^ - crctable[(((crc >>> 8) ^ buf.readUInt8(i)) >>> 0) & 0xff]) >>> + crctable[(((crc >>> 8) ^ buf[i]) >>> 0) & 0xff]) >>> 0; } return crc & 0xffff; } - private static arinc665Crc32(buf: Buffer, seed?: number) { + private static arinc665Crc32(buf: Uint8Array, seed?: number) { const crctable = [ 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005, 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, @@ -321,9 +348,7 @@ export class MIAMCoreUtils { for (let i = 0; i < buf.length; i++) { crc = - (((crc << 8) >>> 0) ^ - crctable[((crc >>> 24) ^ buf.readUInt8(i)) >>> 0]) >>> - 0; + (((crc << 8) >>> 0) ^ crctable[((crc >>> 24) ^ buf[i]) >>> 0]) >>> 0; } return crc; @@ -347,8 +372,8 @@ export class MIAMCoreUtils { version: MIAMVersion, minHdrSize: number, crcLen: number, - hdr: Buffer, - body?: Buffer, + hdr: Uint8Array, + body?: Uint8Array, ): PduDecodingResult { if (hdr.length < minHdrSize) { return { @@ -369,7 +394,7 @@ export class MIAMCoreUtils { let pduAppType: number = 0; let pduAppId: string = ''; let pduCrc: number = 0; - let pduData: Buffer | null = null; + let pduData: Uint8Array | null = null; let pduCrcIsOk: boolean = false; let pduIsComplete: boolean = true; @@ -380,8 +405,7 @@ export class MIAMCoreUtils { let ackOptions: number = 0; if (version === MIAMVersion.V1) { - pduSize = - (hdr.readUInt8(1) << 16) | (hdr.readUInt8(2) << 8) | hdr.readUInt8(3); + pduSize = (hdr[1] << 16) | (hdr[2] << 8) | hdr[3]; const msgSize = hdr.length + (body === undefined ? 0 : body.length); if (pduSize > msgSize) { @@ -392,20 +416,19 @@ export class MIAMCoreUtils { } hdr = hdr.subarray(4); - tail = hdr.subarray(0, 7).toString('ascii'); + tail = textDecoder.decode(hdr.subarray(0, 7)); hdr = hdr.subarray(7); } else if (version === MIAMVersion.V2) { hdr = hdr.subarray(1); } - msgNum = (hdr.readUInt8(0) >> 1) & 0x7f; - ackOptions = hdr.readUInt8(0) & 1; + msgNum = (hdr[0] >> 1) & 0x7f; + ackOptions = hdr[0] & 1; hdr = hdr.subarray(1); - pduCompression = - ((hdr.readUInt8(0) << 2) | ((hdr.readUInt8(1) >> 6) & 0x3)) & 0x7; - pduEncoding = (hdr.readUInt8(1) >> 4) & 0x3; - pduAppType = hdr.readUInt8(1) & 0xf; + pduCompression = ((hdr[0] << 2) | ((hdr[1] >> 6) & 0x3)) & 0x7; + pduEncoding = (hdr[1] >> 4) & 0x3; + pduAppType = hdr[1] & 0xf; hdr = hdr.subarray(2); let appIdLen; @@ -440,17 +463,13 @@ export class MIAMCoreUtils { }; } - pduAppId = hdr.subarray(0, appIdLen).toString('ascii'); + pduAppId = textDecoder.decode(hdr.subarray(0, appIdLen)); hdr = hdr.subarray(appIdLen); if (crcLen === 4) { - pduCrc = - (hdr.readUInt8(0) << 24) | - (hdr.readUInt8(1) << 16) | - (hdr.readUInt8(2) << 8) | - hdr.readUInt8(3); // crc + pduCrc = (hdr[0] << 24) | (hdr[1] << 16) | (hdr[2] << 8) | hdr[3]; // crc } else if (crcLen === 2) { - pduCrc = (hdr.readUInt8(0) << 8) | hdr.readUInt8(1); // crc + pduCrc = (hdr[0] << 8) | hdr[1]; // crc } hdr = hdr.subarray(crcLen); @@ -461,10 +480,7 @@ export class MIAMCoreUtils { ) >= 0 ) { try { - const decompress = new zlib.InflateRaw({}); - decompress.write(body); - decompress.flush(zlib.constants.Z_SYNC_FLUSH); - pduData = decompress.read(); + pduData = inflateData(body, true) || null; } catch (e) { pduErrors.push('Inflation failed for body: ' + e); } @@ -483,9 +499,9 @@ export class MIAMCoreUtils { if (pduData !== null) { const crcAlgoHandlerByVersion: Record< MIAMVersion, - (buf: Buffer, seed?: number) => number + (buf: Uint8Array, seed?: number) => number > = { - [MIAMVersion.V1]: (buf: Buffer, seed?: number) => { + [MIAMVersion.V1]: (buf: Uint8Array, seed?: number) => { return ~this.arinc665Crc32(buf, seed); }, [MIAMVersion.V2]: this.arincCrc16, @@ -537,12 +553,12 @@ export class MIAMCoreUtils { label, ...(sublabel ? { sublabel } : {}), ...(mfi ? { mfi } : {}), - ...(pduData ? { text: pduData.toString('ascii') } : {}), + ...(pduData ? { text: textDecoder.decode(pduData) } : {}), }; } else { pdu.non_acars = { appId: pduAppId, - ...(pduData ? { text: pduData.toString('ascii') } : {}), + ...(pduData ? { text: textDecoder.decode(pduData) } : {}), }; } @@ -556,10 +572,13 @@ export class MIAMCoreUtils { static VersionPduHandlerTable: Record< MIAMVersion, - Record PduDecodingResult> + Record< + MIAMCorePdu, + (hdr: Uint8Array, body?: Uint8Array) => PduDecodingResult + > > = { [MIAMVersion.V1]: { - [MIAMCorePdu.Data]: (hdr: Buffer, body?: Buffer) => { + [MIAMCorePdu.Data]: (hdr: Uint8Array, body?: Uint8Array) => { return this.corePduDataHandler( MIAMVersion.V1, 20, @@ -579,7 +598,7 @@ export class MIAMCoreUtils { }, }, [MIAMVersion.V2]: { - [MIAMCorePdu.Data]: (hdr: Buffer, body?: Buffer) => { + [MIAMCorePdu.Data]: (hdr: Uint8Array, body?: Uint8Array) => { return this.corePduDataHandler( MIAMVersion.V2, 7, diff --git a/package-lock.json b/package-lock.json index 0d349bd..6dca437 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,7 @@ "version": "1.8.14", "license": "UNLICENSED", "dependencies": { - "base85": "^3.1.0", - "minizlib": "^3.0.1" + "pako": "^1.0.11" }, "bin": { "acars-decoder": "dist/bin/acars-decoder.js", @@ -24,6 +23,7 @@ "@stylistic/eslint-plugin": "^5.7.1", "@types/jest": "^30.0.0", "@types/node": "^25.0.9", + "@types/pako": "^1.0.7", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "babel-jest": "^30.2.0", @@ -3744,6 +3744,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pako": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", + "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4765,39 +4772,6 @@ "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/base85": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/base85/-/base85-3.2.0.tgz", - "integrity": "sha512-RvIzDQR2HtIf0t2uspIRrrhH7DpfhogquxYIdoH40XACixnGsRknP1JeLiCeKh/y30Hc2sTbd9p6/rzjQvj91A==", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "ip-address": "^5.8.9" - }, - "engines": { - "node": ">=10.24.1" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -4876,30 +4850,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -6657,26 +6607,6 @@ "node": ">=10.17.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -6778,20 +6708,6 @@ "node": ">= 0.4" } }, - "node_modules/ip-address": { - "version": "5.9.4", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz", - "integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==", - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "lodash": "^4.17.15", - "sprintf-js": "1.1.2" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8096,12 +8012,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8233,12 +8143,6 @@ "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -8380,23 +8284,12 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -8727,6 +8620,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9502,12 +9401,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", - "license": "BSD-3-Clause" - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index 796e281..6714637 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,7 @@ "author": "Kevin Elliott ", "license": "UNLICENSED", "dependencies": { - "base85": "^3.1.0", - "minizlib": "^3.0.1" + "pako": "^1.0.11" }, "devDependencies": { "@babel/core": "^7.26.9", @@ -41,6 +40,7 @@ "@eslint/js": "^9.39.2", "@stylistic/eslint-plugin": "^5.7.1", "@types/jest": "^30.0.0", + "@types/pako": "^1.0.7", "@types/node": "^25.0.9", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", From d065ba43038e14861ba7de0fdf4a984af5c3b92d Mon Sep 17 00:00:00 2001 From: Kevin Elliott Date: Fri, 27 Mar 2026 16:01:22 -0700 Subject: [PATCH 2/3] Address PR review feedback - Extract shared `inflateData` and `base64ToUint8Array` into `lib/utils/compression.ts` to eliminate duplication between miam.ts and Label_H1_OHMA.ts (makrsmark) - Add explicit error when inflate produces no output instead of silently skipping CRC validation (Copilot) - Fix base64url support: convert `-`/`_` to `+`/`/` before decoding instead of stripping them (chatgpt-codex-connector) Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/plugins/Label_H1_OHMA.ts | 41 ++------------------------ lib/utils/compression.ts | 56 ++++++++++++++++++++++++++++++++++++ lib/utils/miam.ts | 34 +++++----------------- 3 files changed, 65 insertions(+), 66 deletions(-) create mode 100644 lib/utils/compression.ts diff --git a/lib/plugins/Label_H1_OHMA.ts b/lib/plugins/Label_H1_OHMA.ts index 96bb287..708b03f 100644 --- a/lib/plugins/Label_H1_OHMA.ts +++ b/lib/plugins/Label_H1_OHMA.ts @@ -1,47 +1,10 @@ import { DecoderPlugin } from '../DecoderPlugin'; import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; import { ResultFormatter } from '../utils/result_formatter'; - -import * as pako from 'pako'; +import { base64ToUint8Array, inflateData } from '../utils/compression'; const textDecoder = new TextDecoder(); -function base64ToUint8Array(base64: string): Uint8Array { - // Match Buffer.from(str, 'base64') behavior: strip non-base64 chars, handle missing padding - const cleaned = base64.replace(/[^A-Za-z0-9+/]/g, ''); - const padded = cleaned + '='.repeat((4 - (cleaned.length % 4)) % 4); - const binary = atob(padded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -/** - * Inflate compressed data with support for partial/truncated streams. - */ -function inflateData(data: Uint8Array): Uint8Array | undefined { - const chunks: Uint8Array[] = []; - const inflator = new pako.Inflate({ windowBits: 15 }); - inflator.onData = (chunk: Uint8Array) => { - chunks.push(chunk); - }; - inflator.push(data, 2); // Z_SYNC_FLUSH - - if (chunks.length === 0) return undefined; - if (chunks.length === 1) return chunks[0]; - - const totalLen = chunks.reduce((sum, c) => sum + c.length, 0); - const result = new Uint8Array(totalLen); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} - export class Label_H1_OHMA extends DecoderPlugin { name = 'label-h1-ohma'; @@ -61,7 +24,7 @@ export class Label_H1_OHMA extends DecoderPlugin { const data = message.text.split('OHMA')[1]; // throw out '/RTNOCR.' - even though it means something try { const compressedBuffer = base64ToUint8Array(data); - const result = inflateData(compressedBuffer); + const result = inflateData(compressedBuffer, false); if (!result || result.length === 0) { throw new Error('Decompression produced no output'); } diff --git a/lib/utils/compression.ts b/lib/utils/compression.ts new file mode 100644 index 0000000..ddd25d9 --- /dev/null +++ b/lib/utils/compression.ts @@ -0,0 +1,56 @@ +import * as pako from 'pako'; + +/** + * Inflate compressed data with support for partial/truncated streams. + * Uses Z_SYNC_FLUSH to extract as much data as possible, even from + * incomplete deflate streams. Captures output via onData callback. + * + * @param data - The compressed input bytes + * @param raw - If true, use raw deflate (no zlib header); if false, expect zlib header + * @returns The decompressed bytes, or undefined if no output was produced + */ +export function inflateData( + data: Uint8Array, + raw: boolean, +): Uint8Array | undefined { + const chunks: Uint8Array[] = []; + const inflator = new pako.Inflate({ windowBits: raw ? -15 : 15 }); + inflator.onData = (chunk: Uint8Array) => { + chunks.push(chunk); + }; + inflator.push(data, 2); // Z_SYNC_FLUSH + + if (chunks.length === 0) return undefined; + if (chunks.length === 1) return chunks[0]; + + const totalLen = chunks.reduce((sum, c) => sum + c.length, 0); + const result = new Uint8Array(totalLen); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + +/** + * Decode a base64 or base64url string to Uint8Array. + * Handles missing padding and converts base64url chars (- and _) to + * standard base64 chars (+ and /), matching Buffer.from(str, 'base64') behavior. + * + * @param base64 - The base64 or base64url encoded string + * @returns The decoded bytes + */ +export function base64ToUint8Array(base64: string): Uint8Array { + const cleaned = base64 + .replace(/-/g, '+') + .replace(/_/g, '/') + .replace(/[^A-Za-z0-9+/]/g, ''); + const padded = cleaned + '='.repeat((4 - (cleaned.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/lib/utils/miam.ts b/lib/utils/miam.ts index 23057a8..97597f1 100644 --- a/lib/utils/miam.ts +++ b/lib/utils/miam.ts @@ -1,34 +1,9 @@ import { ascii85Decode } from './ascii85'; -import * as pako from 'pako'; +import { inflateData } from './compression'; const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); -/** - * Inflate compressed data with support for partial/truncated streams. - * Captures output chunks via onData to handle Z_SYNC_FLUSH correctly. - */ -function inflateData(data: Uint8Array, raw: boolean): Uint8Array | undefined { - const chunks: Uint8Array[] = []; - const inflator = new pako.Inflate({ windowBits: raw ? -15 : 15 }); - inflator.onData = (chunk: Uint8Array) => { - chunks.push(chunk); - }; - inflator.push(data, 2); // Z_SYNC_FLUSH - - if (chunks.length === 0) return undefined; - if (chunks.length === 1) return chunks[0]; - - const totalLen = chunks.reduce((sum, c) => sum + c.length, 0); - const result = new Uint8Array(totalLen); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} - enum MIAMVersion { V1 = 1, V2 = 2, @@ -480,7 +455,12 @@ export class MIAMCoreUtils { ) >= 0 ) { try { - pduData = inflateData(body, true) || null; + const inflated = inflateData(body, true); + if (inflated === undefined) { + pduErrors.push('Inflation produced no output for body'); + } else { + pduData = inflated; + } } catch (e) { pduErrors.push('Inflation failed for body: ' + e); } From f84d1de803d245395f1cd908bb59f82d6be69f8a Mon Sep 17 00:00:00 2001 From: Kevin Elliott Date: Fri, 27 Mar 2026 17:16:16 -0700 Subject: [PATCH 3/3] Return error on malformed ASCII85 body instead of treating as empty When ascii85Decode() returns null for a malformed body, return an explicit decode error instead of collapsing to undefined (which silently sets pduCrcIsOk=true in corePduDataHandler). (coderabbitai) Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/utils/miam.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/utils/miam.ts b/lib/utils/miam.ts index 97597f1..27734fb 100644 --- a/lib/utils/miam.ts +++ b/lib/utils/miam.ts @@ -172,8 +172,15 @@ export class MIAMCoreUtils { if ('0123'.indexOf(bpad) >= 0) { const bpadValue = parseInt(bpad); - body = ascii85Decode('<~' + rawBody + '~>') || undefined; - if (body && body.length >= bpadValue) { + const decoded = ascii85Decode('<~' + rawBody + '~>'); + if (decoded === null) { + return { + decoded: false, + error: 'Ascii85 decode failed for MIAM message body', + }; + } + body = decoded; + if (body.length >= bpadValue) { body = body.subarray(0, body.length - bpadValue); } } else if (bpad === '-') {