diff --git a/public/packet-filter.js b/public/packet-filter.js index 3bc4b2a7..d2082910 100644 --- a/public/packet-filter.js +++ b/public/packet-filter.js @@ -78,6 +78,18 @@ while (i < len && /[a-zA-Z]/.test(input[i])) i++; var unit = input.slice(unitStart, i); if (!DURATION_UNITS[unit]) { + // Issue #1800: when the user typed a bare hex token (e.g. + // `path 2f0b001247a047ca`), the number/duration tokenizer eats + // only the leading "2f". Extend the scan to capture any further + // [0-9a-fA-F] chars; if the combined slice is pure hex of + // length >= 4, emit a targeted error rather than the cryptic + // duration-unit message. + var probe = i; + while (probe < len && /[0-9a-fA-F]/.test(input[probe])) probe++; + var fullSlice = input.slice(start, probe); + if (/^[0-9a-fA-F]+$/.test(fullSlice) && fullSlice.length >= 4) { + return { tokens: null, error: "Hex value must be quoted: try 'field == \"\"' or use the starts_with/contains operator" }; + } return { tokens: null, error: "Invalid duration unit '" + unit + "' at position " + unitStart + " (expected s/m/h/d/w)" }; } tokens.push({ type: TK.DURATION, value: parseFloat(numStr) * DURATION_UNITS[unit], raw: numStr + unit }); @@ -274,9 +286,26 @@ if (isNaN(ms2)) return null; return Math.max(0, (Date.now() - ms2) / 1000); } - if (field === 'path') { + if (field === 'path' || field === 'path_prefixes') { try { return JSON.parse(packet.path_json || '[]').join(' → '); } catch(e) { return ''; } } + // Issue #1800: routed_through matches against resolved_path full pubkeys. + // resolved_path may arrive as a JSON string OR as an already-parsed array + // (depends on which API surface produced the packet). Handles both. + if (field === 'routed_through') { + var rp = packet.resolved_path; + if (rp == null) return ''; + var arr; + if (typeof rp === 'string') { + try { arr = JSON.parse(rp); } catch (e) { return ''; } + } else if (Array.isArray(rp)) { + arr = rp; + } else { + return ''; + } + if (!Array.isArray(arr)) return ''; + return arr.map(function(h) { return String(h || '').toLowerCase(); }).join(' '); + } if (field === 'payload_bytes') { return packet.raw_hex ? Math.max(0, packet.raw_hex.length / 2 - 2) : 0; } @@ -444,7 +473,9 @@ { name: 'observer_iata', desc: 'Observer IATA region code (e.g. SJC, SFO)' }, { name: 'iata', desc: 'Alias of observer_iata' }, { name: 'observations', desc: 'Number of observations of this packet' }, - { name: 'path', desc: 'Hop path (joined with arrows)' }, + { name: 'path', desc: 'Hop path as 1-byte prefixes joined (e.g. a3→7f). For pubkey search use routed_through.' }, + { name: 'path_prefixes', desc: 'Alias of path (1-byte hop prefixes joined with arrows)' }, + { name: 'routed_through', desc: 'Match packets routed through a node by pubkey (full or prefix)' }, { name: 'payload_bytes', desc: 'Payload size in bytes (size - 2 header bytes)' }, { name: 'payload_hex', desc: 'Payload bytes as hex (raw without header)' }, { name: 'time', desc: 'Packet timestamp (epoch ms)' }, diff --git a/test-packet-filter.js b/test-packet-filter.js index fa95ea06..80fe64b7 100644 --- a/test-packet-filter.js +++ b/test-packet-filter.js @@ -255,5 +255,48 @@ test('#1774: payload.srcHash listed in FIELDS suggestions', () => { assert(names.indexOf('payload.srcHash') !== -1, 'payload.srcHash missing from FIELDS'); }); +// --- Issue #1800: routed_through field + path desc + hex lexer error --- +const routedPkt = { + ...pkt, + resolved_path: '["2f0b001247a047cabcdef0123456789a","aabbccddeeff00112233445566778899"]', +}; +const routedArrPkt = { + ...pkt, + resolved_path: ['2f0b001247a047cabcdef0123456789a', 'aabbccddeeff00112233445566778899'], +}; +test('#1800: routed_through starts_with prefix matches', () => { + assert(PF.compile('routed_through starts_with "2f0b00"').filter(routedPkt)); +}); +test('#1800: routed_through starts_with prefix matches (array form)', () => { + assert(PF.compile('routed_through starts_with "2f0b00"').filter(routedArrPkt)); +}); +test('#1800: routed_through == full pubkey matches', () => { + assert(PF.compile('routed_through contains "2f0b001247a047cabcdef0123456789a"').filter(routedPkt)); +}); +test('#1800: routed_through contains "2f0b" matches', () => { + assert(PF.compile('routed_through contains "2f0b"').filter(routedPkt)); +}); +test('#1800: routed_through does not match unrelated prefix', () => { + assert(!PF.compile('routed_through starts_with "deadbe"').filter(routedPkt)); +}); +test('#1800: bare hex value after path → "Hex value must be quoted" error', () => { + const c = PF.compile('path 2f0b001247a047ca'); + assert(c.error !== null, 'should have parse/lex error'); + assert(c.error.indexOf('Hex value must be quoted') !== -1, + 'error must mention Hex value must be quoted, got: ' + c.error); +}); +test('#1800 regression: path contains "a3" still matches existing prefix list', () => { + const prefixPkt = { ...pkt, path_json: '["a3","7f"]' }; + assert(PF.compile('path contains "a3"').filter(prefixPkt)); +}); +test('#1800: routed_through listed in FIELDS suggestions', () => { + const names = PF.FIELDS.map(f => f.name); + assert(names.indexOf('routed_through') !== -1, 'routed_through missing from FIELDS'); +}); +test('#1800: path_prefixes alias resolves like path', () => { + const prefixPkt = { ...pkt, path_json: '["a3","7f"]' }; + assert(PF.compile('path_prefixes contains "a3"').filter(prefixPkt)); +}); + console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`); process.exit(fail > 0 ? 1 : 0);