diff options
Diffstat (limited to 'modules/test/test-cryptogzip-guard.js')
| -rw-r--r-- | modules/test/test-cryptogzip-guard.js | 136 |
1 files changed, 136 insertions, 0 deletions
diff --git a/modules/test/test-cryptogzip-guard.js b/modules/test/test-cryptogzip-guard.js new file mode 100644 index 0000000..14f50d3 --- /dev/null +++ b/modules/test/test-cryptogzip-guard.js @@ -0,0 +1,136 @@ +// node modules/test/test-cryptogzip-guard.js +// test-cryptogzip-guard — CryptoGzipModule._base64ToBytes must reject non-base64 input with a +// plain, actionable Error, not a raw DOMException out of atob() (RPT-20261102-203616-e2641bff: +// "Failed to execute 'atob' on 'Window': The string to be decoded contains characters outside of +// the Latin1 range"), which escaped as an unhandledrejection when called from the node console. +// +// It also pins the happy path byte-for-byte (including unpadded base64 and newline-wrapped +// base64, both of which atob already accepted) and the two gunzip callers. +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +global.window = {}; // the file's auto-registration tail is window-guarded + +const SRC = path.join(__dirname, '..', 'ext', 'cryptogzip.js'); +const CryptoGzipModule = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn CryptoGzipModule;')(); + +let passes = 0, fails = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); +} + +const m = new CryptoGzipModule(); +const isDomException = (e) => typeof DOMException === 'function' && e instanceof DOMException; + +function mkTerm(files) { + const store = Object.assign({}, files || {}); + const printed = []; + const term = { + print: (msg, kind) => printed.push({ msg: String(msg), kind: kind || '' }), + fs: { + readFile: (p) => (p in store ? store[p] : null), + writeFile: (p, c) => { store[p] = c; return true; }, + deleteFile: (p) => { delete store[p]; }, + readDir: () => [] + }, + inputCallback: null, + _onCommand: null + }; + const mod = new CryptoGzipModule(); + mod.terminal = term; + mod.registerCommands(); + return { mod, term, printed, store }; +} + +(async () => { + // C1-C2: happy path + const hello = m._base64ToBytes('aGVsbG8='); + ok('C1 decodes base64 to the right bytes', Buffer.from(hello).toString() === 'hello', + Buffer.from(hello).toString()); + ok('C2 returns a 5-byte Uint8Array', hello instanceof Uint8Array && hello.length === 5, + hello.constructor && hello.constructor.name + ' len ' + hello.length); + + // C3: every input that used to reach atob() must now be a plain Error mentioning base64 + const bad = [ + ['hello world!', 'non-base64 text'], + ['☃', 'non-Latin1 character'], + [null, 'null'], + [undefined, 'undefined'], + [123, 'number'], + [{}, 'object'], + ['abcde=', 'bad padding'], + ['ab=', 'incomplete padding'] + ]; + for (const [value, label] of bad) { + let err = null; + try { m._base64ToBytes(value); } catch (e) { err = e; } + const plainError = err instanceof Error && !isDomException(err) && err.name !== 'InvalidCharacterError'; + ok('C3 ' + label + ' throws a plain Error that mentions base64', + plainError && /base64/.test(err.message), + err ? err.name + ': ' + err.message : 'did not throw'); + } + + // C4: long valid base64 with CRLF/LF wrapping still decodes (atob ignores ASCII whitespace) + const long = 'The quick brown fox jumps over the lazy dog. '.repeat(40); + const b64 = Buffer.from(long, 'utf8').toString('base64'); + const wrapped = b64.replace(/(.{60})/g, '$1\r\n') + '\n'; + const out = Buffer.from(m._base64ToBytes(wrapped)).toString('utf8'); + ok('C4 newline-wrapped base64 decodes', out === long, out.length + ' vs ' + long.length + ' chars'); + + // C5: byte-identical to atob() for every form atob accepted before (incl. unpadded) + const corpus = ['aGVsbG8=', 'aGVsbG8', 'ab==', 'abc=', 'abcd', '', ' aGVs bG8=\n', 'aGVsbG8g\td29ybGQ=']; + let same = true, why = ''; + for (const c of corpus) { + let want, got; + try { want = Buffer.from(atob(c), 'binary').toString('hex'); } catch (e) { want = 'THROW'; } + try { got = Buffer.from(m._base64ToBytes(c)).toString('hex'); } catch (e) { got = 'THROW'; } + if (want !== got) { same = false; why = JSON.stringify(c) + ' atob=' + want + ' guard=' + got; break; } + } + ok('C5 happy path is byte-identical to atob()', same, why); + + // C6: _gunzipString round-trip (base64 gzip payload) + const gz = zlib.gzipSync(Buffer.from('hi hi hi', 'utf8')); + const text = await m._gunzipString(gz.toString('base64')); + ok('C6 _gunzipString round-trips a real gzip payload', text === 'hi hi hi', JSON.stringify(text)); + + // C7: _gunzipString on garbage rejects with the guarded Error, not a DOMException + let gzErr = null; + try { await m._gunzipString('hello world!'); } catch (e) { gzErr = e; } + ok('C7 _gunzipString on non-base64 throws a guarded Error', + !!gzErr && gzErr instanceof Error && !isDomException(gzErr) && /base64/.test(gzErr.message), + gzErr ? gzErr.name + ': ' + gzErr.message : 'did not throw'); + + // C8: `gunzip` command path prints its existing "gunzip error:" line instead of crashing + const t8 = mkTerm({ 'notbase64.gz': 'hello world!' }); + let cmdThrew = null; + try { await t8.mod.commands['gunzip'](['notbase64.gz']); } catch (e) { cmdThrew = e; } + const line = t8.printed.find(p => /^gunzip error: /.test(p.msg)); + ok('C8 gunzip prints "gunzip error: … not valid base64 …" and does not throw', + !cmdThrew && !!line && /base64/.test(line.msg) && t8.store['notbase64.gz'] === 'hello world!', + cmdThrew ? 'threw ' + cmdThrew.message : JSON.stringify(t8.printed)); + + // C9: legacy path — a base64 .gz written by `gzip` still decompresses + const t9 = mkTerm({ 'note.txt.gz': zlib.gzipSync(Buffer.from('gunzip me', 'utf8')).toString('base64') }); + await t9.mod.commands['gunzip'](['note.txt.gz']); + ok('C9 gunzip still decompresses a base64 .gz', t9.store['note.txt'] === 'gunzip me' + && !('note.txt.gz' in t9.store), JSON.stringify(Object.keys(t9.store))); + + // C10-C11: the VFS helpers that call _base64ToBytes keep their behaviour + const gzRaw = zlib.gzipSync(Buffer.from('latin1 payload', 'utf8')); + const latin1 = Buffer.from(gzRaw).toString('latin1'); // what _vfsWriteBytes stores + const t10 = mkTerm({ 'blob.gz': latin1 }); + const raw = t10.mod._vfsReadBytes('blob.gz'); // guard throws -> Latin-1 fallback + ok('C10 _vfsReadBytes falls back to raw Latin-1 bytes (guard rejection is swallowed)', + t10.mod._isGzip(raw) && Buffer.from(raw).equals(gzRaw), raw.length + ' bytes'); + + const t11 = mkTerm({ 'b64.gz': gzRaw.toString('base64') }); + const dec = t11.mod._vfsReadBytes('b64.gz'); // heuristic -> base64 decode + ok('C11 _vfsReadBytes still base64-decodes a stored payload', + t11.mod._isGzip(dec) && Buffer.from(dec).equals(gzRaw), dec.length + ' bytes'); + + console.log('test-cryptogzip-guard: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); |