mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 00:14:07 +00:00
Red commit: 11d8c51e8a (CI:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1792)
## What
Render GRP_DATA (PAYLOAD_TYPE 0x06) channel hash + (when decrypted)
inner `data_type` / `data_len` / blob hex in the packets table "details"
cell, mirroring the existing GRP_TXT branch in `public/packets.js
getDetailPreview()`.
Previously these packets showed only the opaque payload bytes —
operators had no way to see channel distribution or recognize specific
data_type values at a glance.
## How
`public/packets.js` — one new branch right after the GRP_TXT branch:
- Always renders `Ch 0xNN` (from `channelHashHex` or computed from
`channelHash`).
- `decryptionStatus = no_key | decryption_failed` → status label, same
shape as GRP_TXT.
- `decryptionStatus = decrypted` → adds `type=0xNNNN len=N` plus a
`<code>` block with the blob hex, truncated to 32 hex chars (16 bytes)
with `…` when longer.
Inner layout per `firmware/src/helpers/BaseChatMesh.cpp:382-385` (uint16
LE data_type, u8 data_len, blob).
## TDD
- **Red commit** `11d8c51e`: 4 new assertion-shaped failures in
`test-packets.js` getDetailPreview suite (`packets.js tests: 70 passed,
17 failed` → +4 vs baseline 13).
- **Green commit** `3466ed09`: 17 → 13 failed (baseline
emoji-vs-Phosphor drift, unrelated). All 4 GRP_DATA assertions pass.
## Optional check #2 — backend JSON parity
Confirmed `cmd/server/decoder.go` and `cmd/ingestor/decoder.go` use
identical JSON tags (`channelHashHex`, `decryptionStatus`, `dataType`,
`dataLen`, `decryptedBlob`). No backend change needed — server emits
envelope-only fields, ingestor adds the inner fields when a channel key
matches; the frontend handles both shapes.
## Scope
- 2 files: `public/packets.js` (+17 lines), `test-packets.js` (+48 lines
test).
- No public API change. No CSS. No migration.
Preflight clean (PII, branch scope, red commit, CSS, LIKE-on-JSON,
sync/async migration, XSS sinks — all pass).
Fixes #1792
---------
Co-authored-by: Kpa-clawbot <bot@example.com>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
This commit is contained in:
co-authored by
Kpa-clawbot
meshcore-bot
Kpa-clawbot
parent
770749a8ce
commit
d5ceb27334
@@ -2829,6 +2829,43 @@
|
||||
const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key' : 'decryption failed';
|
||||
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> Ch 0x${hashHex} <span class="muted">(${statusLabel})</span>`;
|
||||
}
|
||||
// Group data (binary datagrams over channels) — #1792.
|
||||
// Envelope: channel_hash + MAC + ciphertext. When decrypted, inner is
|
||||
// data_type(uint16 LE) + data_len(1) + blob (firmware BaseChatMesh.cpp:382-385).
|
||||
if (decoded.type === 'GRP_DATA' && decoded.channelHash != null) {
|
||||
const hashHex = decoded.channelHashHex || decoded.channelHash.toString(16).padStart(2, '0').toUpperCase();
|
||||
// #1796 r1 DRY: all three GRP_DATA branches below share the same
|
||||
// database-icon + `Ch 0x${hashHex}` prefix. Extract once; behavior is
|
||||
// byte-identical with the prior inline form.
|
||||
const prefix = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-database"/></svg> Ch 0x${hashHex}`;
|
||||
// Happy path: decrypted with a parsed inner header (dataType present, no parse error).
|
||||
// Per firmware/src/helpers/BaseChatMesh.cpp:387 a data_len of 0 is a LEGITIMATE
|
||||
// empty datagram (the firmware only drops data_len > available_len). Backend
|
||||
// cmd/ingestor/decoder.go:142 marshals DecryptedBlob with `omitempty`, so an
|
||||
// empty blob is normal — render the <code> block ONLY when blob is a non-empty
|
||||
// string. The 'data_len exceeds buffer' malformed branch sets decoded.error,
|
||||
// which falls through to the explicit malformed label below.
|
||||
if (decoded.decryptionStatus === 'decrypted' && decoded.dataType != null && !decoded.error) {
|
||||
const dt = Number(decoded.dataType).toString(16).padStart(4, '0').toUpperCase();
|
||||
const dl = decoded.dataLen != null ? Number(decoded.dataLen) : 0;
|
||||
const blob = decoded.decryptedBlob || '';
|
||||
const blobBlock = blob ? ` <code>${escapeHtml(blob.length > 32 ? blob.slice(0, 32) + '…' : blob)}</code>` : '';
|
||||
return `${prefix} <span class="muted">type=0x${dt} len=${dl}</span>${blobBlock}`;
|
||||
}
|
||||
// #1796 polish: decrypted-but-malformed branch. Backend leaves
|
||||
// status='decrypted' for two failure modes (decoder.go:619-654):
|
||||
// (a) inner too short → DataType=nil
|
||||
// (b) inner data_len exceeds buffer → DataType set, blob empty, Error set
|
||||
// Without this explicit branch we would either lie ('encrypted') or
|
||||
// render a misleading "type=0xNN len=N" header with an empty <code></code>.
|
||||
if (decoded.decryptionStatus === 'decrypted') {
|
||||
return `${prefix} <span class="muted">(decrypted, malformed)</span>`;
|
||||
}
|
||||
const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key'
|
||||
: decoded.decryptionStatus === 'decryption_failed' ? 'decryption failed'
|
||||
: 'encrypted';
|
||||
return `${prefix} <span class="muted">(${statusLabel})</span>`;
|
||||
}
|
||||
// Direct messages
|
||||
if (decoded.type === 'TXT_MSG') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg> ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`;
|
||||
// Path updates
|
||||
|
||||
+161
@@ -280,6 +280,167 @@ console.log('\n=== packets.js: getDetailPreview ===');
|
||||
assert(result.includes('0xFF'));
|
||||
});
|
||||
|
||||
// #1792: GRP_DATA detail preview parity with GRP_TXT.
|
||||
test('getDetailPreview handles GRP_DATA with channelHash (no_key)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA', channelHash: 0xAB, channelHashHex: 'AB', decryptionStatus: 'no_key'
|
||||
});
|
||||
assert(result.includes('Ch 0xAB'), 'should render channel hash hex with Ch prefix');
|
||||
assert(result.includes('no key'), 'should render no key status');
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_DATA decryption_failed', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA', channelHash: 5, channelHashHex: '05', decryptionStatus: 'decryption_failed'
|
||||
});
|
||||
assert(result.includes('Ch 0x05'), 'should render channel hash hex with Ch prefix');
|
||||
assert(result.includes('decryption failed'), 'should render failure status');
|
||||
});
|
||||
|
||||
// #1796 polish: explicit 'encrypted' fallback when decryptionStatus is absent/pending.
|
||||
test('getDetailPreview handles GRP_DATA encrypted fallback (no decryptionStatus)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA', channelHash: 0xAB, channelHashHex: 'AB'
|
||||
});
|
||||
assert(result.includes('Ch 0xAB'), 'should render channel hash hex with Ch prefix');
|
||||
assert(result.includes('encrypted'), 'should render encrypted fallback label');
|
||||
});
|
||||
|
||||
// #1796 polish: decrypted-but-malformed — status is 'decrypted' but dataType is null
|
||||
// because inner payload was too short to parse (cmd/ingestor/decoder.go:619-654).
|
||||
test('getDetailPreview handles GRP_DATA decrypted-but-malformed (dataType null)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA',
|
||||
channelHash: 0xAB,
|
||||
channelHashHex: 'AB',
|
||||
decryptionStatus: 'decrypted',
|
||||
dataType: null
|
||||
});
|
||||
assert(result.includes('Ch 0xAB'), 'should render channel hash hex with Ch prefix');
|
||||
assert(result.includes('malformed'), 'should label decrypted-but-malformed inner explicitly');
|
||||
assert(!result.includes('encrypted'), 'must NOT mislabel decrypted packet as encrypted');
|
||||
});
|
||||
|
||||
// #1796 r1 adversarial — pin the `!decoded.error` guard on the happy-path branch
|
||||
// (public/packets.js:2841). Backend cmd/ingestor/decoder.go:619-654 sets BOTH
|
||||
// DataType=0xNN AND Error='data_len exceeds buffer' for the malformed inner case
|
||||
// where data_len > available_len. Without the `!decoded.error` guard, this row
|
||||
// would mis-render as `type=0x0001 len=0` (a confident-looking happy-path label)
|
||||
// instead of falling through to the explicit `(decrypted, malformed)` branch.
|
||||
// Regression pin: if a future refactor drops the `!decoded.error` clause, this
|
||||
// test fails. (Verified locally: removing `&& !decoded.error` makes this fail.)
|
||||
test('getDetailPreview routes decrypted+dataType+error through malformed branch (#1796 r1 adv)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA',
|
||||
channelHash: 0x12,
|
||||
channelHashHex: '12',
|
||||
decryptionStatus: 'decrypted',
|
||||
dataType: 0x0001,
|
||||
dataLen: 0,
|
||||
error: 'data_len exceeds buffer'
|
||||
// decryptedBlob absent (omitempty); backend Error field set.
|
||||
});
|
||||
assert(result.includes('Ch 0x12'), 'should still render channel hash hex');
|
||||
assert(result.includes('malformed'),
|
||||
'must label as malformed when Error is set, NOT confidently render type=0xNN');
|
||||
assert(!result.includes('type=0x0001'),
|
||||
'must NOT show a happy-path type=0xNN header when inner parse errored');
|
||||
assert(!result.includes('len=0'),
|
||||
'must NOT show a happy-path len=N header when inner parse errored');
|
||||
});
|
||||
|
||||
// #1796 r1 regression — data_len=0 is a LEGITIMATE empty datagram per firmware
|
||||
// (BaseChatMesh.cpp:387: `data_len > available_len` is the only reject; 0 is allowed).
|
||||
// Backend cmd/ingestor/decoder.go:142 marshals DecryptedBlob with `omitempty`, so a
|
||||
// valid data_len=0 packet arrives with an empty/absent blob and no error. Frontend
|
||||
// must render the header (type=...len=0) WITHOUT a <code> block and MUST NOT label
|
||||
// it 'malformed'. (Same assertion covers the round-0 'tightened gate' regression.)
|
||||
test('getDetailPreview renders GRP_DATA data_len=0 empty datagram (no code block, not malformed)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA',
|
||||
channelHash: 0x12,
|
||||
channelHashHex: '12',
|
||||
decryptionStatus: 'decrypted',
|
||||
dataType: 0x0001,
|
||||
dataLen: 0
|
||||
// decryptedBlob absent (backend `omitempty`); no error.
|
||||
});
|
||||
assert(result.includes('Ch 0x12'), 'should render channel hash hex');
|
||||
assert(result.includes('type=0x0001'), 'should render data_type as hex');
|
||||
assert(result.includes('len=0'), 'should render data_len=0');
|
||||
assert(!result.includes('<code>'), 'must NOT render any <code> block when blob is empty');
|
||||
assert(!result.includes('malformed'), 'data_len=0 is a legitimate empty datagram, NOT malformed');
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_DATA decrypted with data_type and blob', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA',
|
||||
channelHash: 0x12,
|
||||
channelHashHex: '12',
|
||||
decryptionStatus: 'decrypted',
|
||||
dataType: 0x0001,
|
||||
dataLen: 4,
|
||||
decryptedBlob: 'deadbeef'
|
||||
});
|
||||
assert(result.includes('0x12'), 'should render channel hash hex');
|
||||
assert(result.includes('0x0001'), 'should render data_type as hex');
|
||||
assert(result.includes('len=4'), 'should render data_len with len= label');
|
||||
assert(result.includes('deadbeef'), 'should render blob hex');
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_DATA decrypted truncates long blob', () => {
|
||||
const longBlob = 'ab'.repeat(64); // 128 hex chars = 64 bytes
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA',
|
||||
channelHash: 0x12,
|
||||
channelHashHex: '12',
|
||||
decryptionStatus: 'decrypted',
|
||||
dataType: 0,
|
||||
dataLen: 64,
|
||||
decryptedBlob: longBlob
|
||||
});
|
||||
// Adversarial #3: assert EXACT rendered <code> content via regex, not substring.
|
||||
// Substring match would still pass at cutoff 40/48 because 'ab'.repeat(16)+'…'
|
||||
// is a prefix of any longer rendered blob. Pin: exactly 32 hex chars + ellipsis,
|
||||
// and nothing else inside the <code> tag.
|
||||
const codeMatch = result.match(/<code>([^<]*)<\/code>/);
|
||||
assert(codeMatch, 'should render a <code> block');
|
||||
assert.strictEqual(codeMatch[1], 'ab'.repeat(16) + '…',
|
||||
`<code> content must be exactly 32 hex chars + ellipsis, got: ${JSON.stringify(codeMatch[1])}`);
|
||||
});
|
||||
|
||||
// Boundary: blob with EXACTLY 32 hex chars renders WITHOUT ellipsis (.length > 32 is strict).
|
||||
test('getDetailPreview renders GRP_DATA blob of exactly 32 hex chars without ellipsis', () => {
|
||||
const exactBlob = 'cd'.repeat(16); // 32 hex chars
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA',
|
||||
channelHash: 0x12,
|
||||
channelHashHex: '12',
|
||||
decryptionStatus: 'decrypted',
|
||||
dataType: 0,
|
||||
dataLen: 16,
|
||||
decryptedBlob: exactBlob
|
||||
});
|
||||
const codeMatch = result.match(/<code>([^<]*)<\/code>/);
|
||||
assert(codeMatch, 'should render a <code> block at the 32-char boundary');
|
||||
assert.strictEqual(codeMatch[1], exactBlob,
|
||||
'exactly-32-char blob must render verbatim, no ellipsis');
|
||||
assert(!result.includes('…'), 'must NOT append ellipsis at the boundary');
|
||||
});
|
||||
|
||||
// Item 6: channelHash=0 — confirms the `!= null` gate (not truthy check) so falsy 0
|
||||
// still enters the GRP_DATA branch and renders `Ch 0x00`.
|
||||
test('getDetailPreview handles GRP_DATA channelHash=0 (falsy but valid)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_DATA',
|
||||
channelHash: 0,
|
||||
decryptionStatus: 'no_key'
|
||||
});
|
||||
assert(result.includes('Ch 0x00'),
|
||||
'channelHash=0 must render Ch 0x00 (falsy 0 passes `!= null` gate)');
|
||||
assert(result.includes('no key'), 'should render no key status');
|
||||
});
|
||||
|
||||
test('getDetailPreview handles TXT_MSG', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'TXT_MSG', srcHash: 'abcdef01', destHash: '12345678'
|
||||
|
||||
Reference in New Issue
Block a user