From 3d26b0b39ccdfcb3f005ce8482ca014b0dcb7606 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 6 Aug 2026 22:34:19 -0700 Subject: [PATCH] Add filter policy playground --- docs/_javascript/filter_tool.js | 1584 +++++++++++++++++++++++++++++ docs/_stylesheets/filter_tool.css | 585 +++++++++++ docs/filter_tool.md | 491 +++++++++ docs/index.md | 1 + mkdocs.yml | 2 + scripts/test_filter_tool.js | 246 +++++ 6 files changed, 2909 insertions(+) create mode 100644 docs/_javascript/filter_tool.js create mode 100644 docs/_stylesheets/filter_tool.css create mode 100644 docs/filter_tool.md create mode 100644 scripts/test_filter_tool.js diff --git a/docs/_javascript/filter_tool.js b/docs/_javascript/filter_tool.js new file mode 100644 index 00000000..0d39132a --- /dev/null +++ b/docs/_javascript/filter_tool.js @@ -0,0 +1,1584 @@ +(function (global) { + "use strict"; + + const BUNDLE_PREFIX = "MCPOLICY1:"; + const POLICY_FORMAT = "meshcore-policy-engine-playground"; + const POLICY_VERSION = 1; + const PHASES = Object.freeze(["scope_gate", "rewrite", "forward", "content", "schedule"]); + const PHASE_LABELS = Object.freeze({ + scope_gate: "Incoming scope gate", + rewrite: "Scope rewrite", + forward: "Forwarding decision", + content: "Decrypted content", + schedule: "Scheduling and retry", + }); + const PHASE_ORDER = Object.freeze(Object.fromEntries(PHASES.map((phase, index) => [phase, index]))); + const OWNERS = Object.freeze(["scope", "filter", "admin", "system"]); + const OWNER_LABELS = Object.freeze({ + scope: "ACL 4 scope manager", + filter: "ACL 5 filter manager", + admin: "administrator", + system: "firmware system", + }); + const MODES = Object.freeze(["active", "shadow", "disabled"]); + const STOPS = Object.freeze(["none", "phase", "policy"]); + const ROUTES = Object.freeze(["flood", "unscoped_flood", "scoped_flood", "direct", "any"]); + const TYPE_NAMES = Object.freeze([ + "req", "response", "txt_msg", "ack", "advert", "grp_txt", "grp_data", "anon_req", + "path", "trace", "multipart", "control", "ota", "13", "14", "raw_custom", + ]); + const TYPE_CLASSES = Object.freeze(["class:group", "class:login", "class:other"]); + const LOGIN_TYPES = Object.freeze(["req", "response", "txt_msg", "anon_req", "path"]); + const GROUP_TYPES = Object.freeze(["grp_txt", "grp_data"]); + const PATH_KINDS = Object.freeze([ + "none", "prefix", "blacklist", + "bucket:1", "bucket:2", "bucket:3", "bucket:4", "bucket:5", "bucket:6", + "loop:strict", "loop:moderate", "loop:minimal", + ]); + const PROFILE_BUDGETS = Object.freeze({ + stm32: 2048, + nrf52: 8192, + esp32: 16384, + esp32_roomy: 65536, + }); + + class FilterToolError extends Error { + constructor(message) { + super(message); + this.name = "FilterToolError"; + } + } + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function clean(value) { + return String(value == null ? "" : value).trim(); + } + + function requiredText(value, label, maximum) { + const text = clean(value); + if (!text) throw new FilterToolError(`${label} is required.`); + if (text.length > maximum) throw new FilterToolError(`${label} must be at most ${maximum} characters.`); + if (/[\x00-\x1F\x7F]/.test(text)) throw new FilterToolError(`${label} contains a control character.`); + return text; + } + + function nullableInteger(value, minimum, maximum, label) { + const text = clean(value); + if (!text) return null; + if (!/^\d+$/.test(text)) throw new FilterToolError(`${label} must be a whole number.`); + const parsed = Number(text); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new FilterToolError(`${label} must be ${minimum}-${maximum}.`); + } + return parsed; + } + + function requiredInteger(value, minimum, maximum, label) { + const parsed = nullableInteger(value, minimum, maximum, label); + if (parsed == null) throw new FilterToolError(`${label} is required.`); + return parsed; + } + + function nullableNumber(value, minimum, maximum, label) { + const text = clean(value); + if (!text) return null; + const parsed = Number(text); + if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) { + throw new FilterToolError(`${label} must be ${minimum}-${maximum}.`); + } + return Math.round(parsed * 100) / 100; + } + + function enumValue(value, allowed, label, fallback) { + const normalized = clean(value).toLowerCase() || fallback; + if (!allowed.includes(normalized)) throw new FilterToolError(`${label} is invalid.`); + return normalized; + } + + function normalizeRuleId(value) { + const id = requiredText(value, "Rule ID", 32); + if (!/^[A-Za-z][A-Za-z0-9_.-]{0,31}$/.test(id)) { + throw new FilterToolError("Rule ID must begin with a letter and use only letters, digits, dot, dash, or underscore."); + } + return id; + } + + function normalizeType(value) { + const type = clean(value).toLowerCase() || "any"; + if (type === "any" || TYPE_NAMES.includes(type) || TYPE_CLASSES.includes(type)) return type; + throw new FilterToolError("Payload matcher must be any, a known type, or class:group/login/other."); + } + + function normalizePacketType(value) { + const type = clean(value).toLowerCase(); + if (TYPE_NAMES.includes(type)) return type; + throw new FilterToolError("Packet payload type must be one exact known type."); + } + + function normalizeHops(value) { + const hops = clean(value).toLowerCase() || "all"; + if (hops === "all" || hops === "0+") return "all"; + let match = hops.match(/^(\d{1,2})$/); + if (match && Number(match[1]) <= 63) return String(Number(match[1])); + match = hops.match(/^(\d{1,2})\+$/); + if (match && Number(match[1]) <= 63) return Number(match[1]) === 0 ? "all" : `${Number(match[1])}+`; + match = hops.match(/^(\d{1,2})-(\d{1,2})$/); + if (match && Number(match[1]) <= Number(match[2]) && Number(match[2]) <= 63) { + return `${Number(match[1])}-${Number(match[2])}`; + } + throw new FilterToolError("Received hops must be all, N, N+, or N-M using 0-63."); + } + + function hopBounds(hops) { + if (hops === "all") return [0, 63]; + if (hops.endsWith("+")) return [Number(hops.slice(0, -1)), 63]; + if (hops.includes("-")) return hops.split("-").map(Number); + return [Number(hops), Number(hops)]; + } + + function normalizeChannel(value, optional) { + const channel = clean(value); + if (!channel) { + if (optional) return ""; + throw new FilterToolError("Authenticated channel is required."); + } + if (channel.toLowerCase() === "public") return "public"; + if (channel[0] === "#") { + if (channel.length < 2 || channel.length > 31 || /\s/.test(channel)) { + throw new FilterToolError("A hashtag channel must be 1-30 non-space characters after #."); + } + return channel; + } + if (/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{64})$/.test(channel)) return channel.toUpperCase(); + throw new FilterToolError("Channel must be public, #channel, or a 128/256-bit hexadecimal key."); + } + + function normalizeScopeName(value) { + let name = requiredText(value, "Scope name", 31); + if (name[0] === "#") name = name.slice(1); + if (!name || name.length > 30 || /\s/.test(name) || name[0] === "$") { + throw new FilterToolError("Scope must be a public name of at most 30 non-space characters."); + } + return name; + } + + function normalizeRegionName(value) { + const name = requiredText(value, "Region name", 31).replace(/\?$/, ""); + if (name === "*") throw new FilterToolError("A wildcard region cannot be a rewrite target."); + return name; + } + + function normalizeIncoming(value) { + const original = clean(value) || "any"; + const lowered = original.toLowerCase(); + const aliases = { "*": "any", n: "none", unscoped: "none", s: "scoped", a: "allowed", known: "allowed", u: "unknown" }; + if (Object.prototype.hasOwnProperty.call(aliases, lowered)) return aliases[lowered]; + if (["any", "none", "scoped", "allowed", "unknown"].includes(lowered)) return lowered; + let match = original.match(/^(?:scope|s):(.+)$/i); + if (match) return `scope:${normalizeScopeName(match[1])}`; + match = original.match(/^(?:region|r):(.+)$/i); + if (match) return `region:${normalizeRegionName(match[1])}`; + throw new FilterToolError("Original scope must be any, none, scoped, allowed, unknown, scope:name, or region:name."); + } + + function normalizePathPrefix(value, maximumHops) { + const path = clean(value); + if (!path || path === "*") return ""; + const ids = path.split(",").map((part) => part.trim().toUpperCase()); + if (ids.length < 1 || ids.length > maximumHops) { + throw new FilterToolError(`Path accepts one to ${maximumHops} comma-separated pbyte IDs.`); + } + const width = ids[0].length; + if (![2, 4, 6].includes(width) || ids.some((id) => id.length !== width || !/^[0-9A-F]+$/.test(id))) { + throw new FilterToolError("Every path ID must use the same 2, 4, or 6 hex-character width."); + } + return ids.join(","); + } + + function defaultRule(id) { + return { + id: id || "rule-1", + phase: "rewrite", + owner: "scope", + priority: 100, + mode: "active", + stop: "none", + route: "flood", + type: "grp_data", + hops: "all", + channel: "#rgdata", + incoming: "none", + pathKind: "none", + pathPrefix: "", + sender: "", + tempRadio: "any", + snrMin: null, + snrMax: null, + verdict: "continue", + scopeGate: "unchanged", + targetKind: "scope", + target: "BlackHole86", + rate: null, + burst: null, + timing: "fast", + queue: "inherit", + retryBucket: "none", + retryAttempts: null, + tag: "", + }; + } + + function hasAction(rule) { + return rule.verdict === "drop" + || rule.scopeGate !== "unchanged" + || rule.targetKind !== "none" + || rule.rate != null + || rule.timing !== "inherit" + || rule.queue !== "inherit" + || rule.retryBucket !== "none" + || Boolean(rule.tag) + || rule.stop !== "none"; + } + + function normalizeRule(input) { + if (!input || typeof input !== "object") throw new FilterToolError("Rule must be an object."); + const rule = { + id: normalizeRuleId(input.id), + phase: enumValue(input.phase, PHASES, "Processing phase", "forward"), + owner: enumValue(input.owner, OWNERS, "Rule owner", "filter"), + priority: requiredInteger(input.priority == null ? 0 : input.priority, 0, 255, "Priority"), + mode: enumValue(input.mode, MODES, "Rule mode", "active"), + stop: enumValue(input.stop, STOPS, "Stop behavior", "none"), + route: enumValue(input.route, ROUTES, "Route matcher", "flood"), + type: normalizeType(input.type), + hops: normalizeHops(input.hops), + channel: normalizeChannel(input.channel, true), + incoming: normalizeIncoming(input.incoming), + pathKind: enumValue(input.pathKind, PATH_KINDS, "Path matcher", "none"), + pathPrefix: "", + sender: clean(input.sender), + tempRadio: enumValue(input.tempRadio, ["any", "active", "inactive"], "Temporary-radio matcher", "any"), + snrMin: nullableNumber(input.snrMin, -30, 30, "Minimum SNR"), + snrMax: nullableNumber(input.snrMax, -30, 30, "Maximum SNR"), + verdict: enumValue(input.verdict, ["continue", "drop"], "Forwarding verdict", "continue"), + scopeGate: enumValue(input.scopeGate, ["unchanged", "require_allowed", "bypass_global"], "Scope-gate action", "unchanged"), + targetKind: enumValue(input.targetKind, ["none", "scope", "region"], "Scope target", "none"), + target: "", + rate: nullableInteger(input.rate, 1, 65534, "Token rate"), + burst: null, + timing: enumValue(input.timing, ["inherit", "fast", "normal", "slow"], "Timing action", "inherit"), + queue: enumValue(input.queue, ["inherit", "high", "normal", "low"], "Queue priority", "inherit"), + retryBucket: enumValue(input.retryBucket, ["none", "bucket:1", "bucket:2", "bucket:3", "bucket:4", "bucket:5", "bucket:6"], "Retry bucket", "none"), + retryAttempts: null, + tag: clean(input.tag), + }; + + if (rule.pathKind === "prefix") rule.pathPrefix = normalizePathPrefix(input.pathPrefix, 3); + if (rule.pathKind === "prefix" && !rule.pathPrefix) throw new FilterToolError("Prefix path matcher requires at least one pbyte ID."); + if (rule.sender) { + rule.sender = requiredText(rule.sender, "Sender", 31); + if (rule.sender.includes(":")) throw new FilterToolError("Sender matcher cannot contain a colon."); + if (!["content"].includes(rule.phase)) throw new FilterToolError("Decrypted sender matching is available only in the content phase."); + if (!["any", "class:group", "grp_txt"].includes(rule.type)) { + throw new FilterToolError("Sender matching requires type any, class:group, or grp_txt."); + } + } + if (rule.channel && !["any", "class:group", "grp_txt", "grp_data"].includes(rule.type)) { + throw new FilterToolError("Authenticated channel matching requires a group-capable payload type or class."); + } + if (rule.snrMin != null && rule.snrMax != null && rule.snrMin > rule.snrMax) { + throw new FilterToolError("Minimum SNR cannot exceed maximum SNR."); + } + + if (rule.targetKind === "scope") rule.target = normalizeScopeName(input.target); + if (rule.targetKind === "region") rule.target = normalizeRegionName(input.target); + if (rule.rate != null) { + rule.burst = nullableInteger(input.burst, 1, 65534, "Burst tokens"); + if (rule.burst == null) rule.burst = Math.max(1, rule.rate); + } else if (clean(input.burst)) { + throw new FilterToolError("Burst tokens require a token rate."); + } + if (rule.retryBucket !== "none") { + rule.retryAttempts = nullableInteger(input.retryAttempts, 1, 10, "Retry attempts"); + if (rule.retryAttempts == null) rule.retryAttempts = 1; + } else if (clean(input.retryAttempts)) { + throw new FilterToolError("Retry attempts require a retry bucket."); + } + if (rule.tag) { + rule.tag = requiredText(rule.tag, "Decision tag", 24); + if (!/^[A-Za-z0-9_.-]+$/.test(rule.tag)) { + throw new FilterToolError("Decision tag may use only letters, digits, dot, dash, or underscore."); + } + } + + if (rule.scopeGate !== "unchanged" && rule.phase !== "scope_gate") { + throw new FilterToolError("Scope-gate actions must run in the incoming scope-gate phase."); + } + if (rule.targetKind !== "none" && rule.phase !== "rewrite") { + throw new FilterToolError("Scope rewrite actions must run in the rewrite phase."); + } + if (rule.retryBucket !== "none" && rule.phase !== "schedule") { + throw new FilterToolError("Retry actions must run in the scheduling phase."); + } + if (rule.owner === "scope") { + if (!["scope_gate", "rewrite"].includes(rule.phase)) { + throw new FilterToolError("ACL 4 rules may run only in scope-gate or rewrite phases."); + } + if (rule.verdict === "drop" || rule.rate != null || rule.queue !== "inherit" || rule.retryBucket !== "none") { + throw new FilterToolError("ACL 4 cannot create general drop, rate, queue, or retry actions."); + } + if (rule.stop === "policy") throw new FilterToolError("ACL 4 may stop only its current phase."); + } + if (rule.owner === "filter") { + if (rule.scopeGate !== "unchanged") throw new FilterToolError("ACL 5 cannot change the region-gate decision."); + if (rule.targetKind === "region") throw new FilterToolError("ACL 5 cannot select a configured region target."); + } + if (!hasAction(rule)) throw new FilterToolError("Rule requires at least one action, tag, or stop behavior."); + return rule; + } + + function quoteDsl(value) { + const text = String(value); + if (!/[\s"'\\]/.test(text)) return text; + return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + + function buildDefinition(input) { + const rule = normalizeRule(input); + const header = [ + "policy", "set", rule.id, + `phase=${rule.phase}`, + `owner=${rule.owner}`, + `priority=${rule.priority}`, + ]; + if (rule.mode !== "active") header.push(`mode=${rule.mode}`); + const matches = [ + `route=${rule.route}`, + `type=${rule.type}`, + `hops=${rule.hops}`, + ]; + if (rule.channel) matches.push(`channel=${quoteDsl(rule.channel)}`); + if (rule.incoming !== "any") matches.push(`rx.scope=${quoteDsl(rule.incoming)}`); + if (rule.pathKind === "prefix") matches.push(`path=prefix:${rule.pathPrefix}`); + else if (rule.pathKind !== "none") matches.push(`path=${rule.pathKind}`); + if (rule.sender) matches.push(`sender=${quoteDsl(rule.sender)}`); + if (rule.tempRadio !== "any") matches.push(`tempradio=${rule.tempRadio}`); + if (rule.snrMin != null || rule.snrMax != null) { + matches.push(`snr=${rule.snrMin == null ? "" : rule.snrMin}..${rule.snrMax == null ? "" : rule.snrMax}`); + } + const actions = []; + if (rule.verdict === "drop") actions.push("drop"); + if (rule.scopeGate === "require_allowed") actions.push("scope-gate=require"); + if (rule.scopeGate === "bypass_global") actions.push("scope-gate=bypass"); + if (rule.targetKind === "scope") actions.push(`scope=${quoteDsl(`#${rule.target}`)}`); + if (rule.targetKind === "region") actions.push(`region=${quoteDsl(rule.target)}`); + if (rule.rate != null) actions.push(`rate=${rule.rate}/min`, `burst=${rule.burst}`); + if (rule.timing !== "inherit") actions.push(`timing=${rule.timing}`); + if (rule.queue !== "inherit") actions.push(`queue=${rule.queue}`); + if (rule.retryBucket !== "none") actions.push(`retry=${rule.retryBucket}/${rule.retryAttempts}`); + if (rule.tag) actions.push(`tag=${rule.tag}`); + if (rule.stop !== "none") actions.push(`stop=${rule.stop}`); + return [...header, "when", ...matches, "do", ...actions].join(" "); + } + + function tokenize(text) { + const tokens = []; + let current = ""; + let quote = ""; + let escaped = false; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (quote) { + if (escaped) { + current += character; + escaped = false; + } else if (character === "\\") escaped = true; + else if (character === quote) quote = ""; + else current += character; + } else if (character === '"' || character === "'") { + quote = character; + } else if (/\s/.test(character)) { + if (current) { + tokens.push(current); + current = ""; + } + } else { + current += character; + } + } + if (quote || escaped) throw new FilterToolError("Definition contains an unterminated quote or escape."); + if (current) tokens.push(current); + return tokens; + } + + function splitOption(token) { + const equals = token.indexOf("="); + return equals < 0 ? [token.toLowerCase(), ""] : [token.slice(0, equals).toLowerCase(), token.slice(equals + 1)]; + } + + function parseRate(value) { + const match = clean(value).match(/^(\d+)(?:\/(?:min|m))?$/i); + if (!match) throw new FilterToolError("Rate must use N/min."); + return Number(match[1]); + } + + function parseDefinition(definition) { + const tokens = tokenize(clean(definition)); + if (tokens.length < 8 || tokens[0].toLowerCase() !== "policy" || tokens[1].toLowerCase() !== "set") { + throw new FilterToolError("Readable definitions begin with: policy set ."); + } + const whenIndex = tokens.findIndex((token) => token.toLowerCase() === "when"); + const doIndex = tokens.findIndex((token) => token.toLowerCase() === "do"); + if (whenIndex < 3 || doIndex <= whenIndex + 1 || doIndex === tokens.length - 1) { + throw new FilterToolError("Definition requires header options, when matchers, and do actions."); + } + const rule = defaultRule(tokens[2]); + rule.channel = ""; + rule.incoming = "any"; + rule.pathKind = "none"; + rule.pathPrefix = ""; + rule.sender = ""; + rule.tempRadio = "any"; + rule.snrMin = null; + rule.snrMax = null; + rule.verdict = "continue"; + rule.scopeGate = "unchanged"; + rule.targetKind = "none"; + rule.target = ""; + rule.rate = null; + rule.burst = null; + rule.timing = "inherit"; + rule.queue = "inherit"; + rule.retryBucket = "none"; + rule.retryAttempts = null; + rule.tag = ""; + rule.stop = "none"; + + tokens.slice(3, whenIndex).forEach((token) => { + const [name, value] = splitOption(token); + if (name === "phase") rule.phase = value; + else if (name === "owner") rule.owner = value; + else if (name === "priority") rule.priority = Number(value); + else if (name === "mode") rule.mode = value; + else throw new FilterToolError(`Unsupported rule-header option: ${token}`); + }); + tokens.slice(whenIndex + 1, doIndex).forEach((token) => { + const [name, value] = splitOption(token); + if (name === "route") rule.route = value; + else if (name === "type") rule.type = value; + else if (name === "hops") rule.hops = value; + else if (name === "channel") rule.channel = value; + else if (name === "rx.scope") rule.incoming = value; + else if (name === "path") { + if (value.toLowerCase().startsWith("prefix:")) { + rule.pathKind = "prefix"; + rule.pathPrefix = value.slice(7); + } else rule.pathKind = value; + } else if (name === "sender") rule.sender = value; + else if (name === "tempradio") rule.tempRadio = value; + else if (name === "snr") { + const match = value.match(/^(-?\d+(?:\.\d+)?)?\.\.(-?\d+(?:\.\d+)?)?$/); + if (!match || (!match[1] && !match[2])) throw new FilterToolError("SNR matcher must use min..max, min.., or ..max."); + rule.snrMin = match[1] || null; + rule.snrMax = match[2] || null; + } else throw new FilterToolError(`Unsupported receive-time matcher: ${token}`); + }); + tokens.slice(doIndex + 1).forEach((token) => { + const [name, value] = splitOption(token); + if (name === "drop") rule.verdict = "drop"; + else if (name === "scope-gate") { + if (value.toLowerCase() === "require") rule.scopeGate = "require_allowed"; + else if (value.toLowerCase() === "bypass") rule.scopeGate = "bypass_global"; + else throw new FilterToolError("scope-gate action must be require or bypass."); + } else if (name === "scope") { + rule.targetKind = "scope"; + rule.target = value.replace(/^#/, ""); + } else if (name === "region") { + rule.targetKind = "region"; + rule.target = value; + } else if (name === "rate") rule.rate = parseRate(value); + else if (name === "burst") rule.burst = Number(value); + else if (name === "timing") rule.timing = value; + else if (name === "queue") rule.queue = value; + else if (name === "retry") { + const match = value.match(/^(bucket:[1-6])\/(\d+)$/i); + if (!match) throw new FilterToolError("Retry action must use bucket:N/attempts."); + rule.retryBucket = match[1].toLowerCase(); + rule.retryAttempts = Number(match[2]); + } else if (name === "tag") rule.tag = value; + else if (name === "stop") rule.stop = value; + else throw new FilterToolError(`Unsupported policy action: ${token}`); + }); + return normalizeRule(rule); + } + + function typeDescription(type) { + const descriptions = { + any: "any payload type", + "class:group": "the group-text/data class", + "class:login": "the login/admin payload class", + "class:other": "the catch-all non-group, non-login class", + req: "REQ", response: "RESPONSE", txt_msg: "TXT_MSG", ack: "ACK", advert: "ADVERT", + grp_txt: "GRP_TXT", grp_data: "GRP_DATA", anon_req: "ANON_REQ", path: "PATH", + trace: "TRACE", multipart: "MULTIPART", control: "CONTROL", ota: "OTA", + "13": "reserved type 13", "14": "reserved type 14", raw_custom: "RAW_CUSTOM", + }; + return descriptions[type] || type; + } + + function routeDescription(route) { + return { + flood: "either flood route", + unscoped_flood: "an unscoped flood", + scoped_flood: "a transport-scoped flood", + direct: "a direct route", + any: "any route", + }[route]; + } + + function hopsDescription(hops) { + if (hops === "all") return "at any received hop count"; + if (hops.endsWith("+")) return `at hop ${hops.slice(0, -1)} or higher`; + if (hops.includes("-")) return `from hop ${hops.replace("-", " through ")}`; + return `at exactly hop ${hops}`; + } + + function channelDescription(channel) { + if (channel === "public") return "authenticated Public channel"; + if (channel.startsWith("#")) return `authenticated ${channel}`; + return `authenticated key ${channel.slice(0, 8)}...`; + } + + function actionPhrases(rule) { + const phrases = []; + if (rule.verdict === "drop") phrases.push("make the forwarding verdict drop (sticky)"); + if (rule.scopeGate === "require_allowed") phrases.push("require the original incoming scope to resolve to an allowed region"); + if (rule.scopeGate === "bypass_global") phrases.push("bypass the global region gate for this packet"); + if (rule.targetKind === "scope") phrases.push(`select regionless scope #${rule.target}`); + if (rule.targetKind === "region") phrases.push(`select configured region ${rule.target}`); + if (rule.rate != null) phrases.push(`attach a ${rule.rate}/minute token bucket with burst ${rule.burst}`); + if (rule.timing !== "inherit") phrases.push(`select ${rule.timing} timing`); + if (rule.queue !== "inherit") phrases.push(`select ${rule.queue} queue priority`); + if (rule.retryBucket !== "none") phrases.push(`retry through ${rule.retryBucket} up to ${rule.retryAttempts} time(s)`); + if (rule.tag) phrases.push(`attach decision tag ${rule.tag}`); + if (rule.stop === "phase") phrases.push("stop later rules in this phase"); + if (rule.stop === "policy") phrases.push("stop all later configurable policy phases"); + return phrases; + } + + function explainRule(input) { + const rule = normalizeRule(input); + const conditions = [routeDescription(rule.route), typeDescription(rule.type), hopsDescription(rule.hops)]; + if (rule.channel) conditions.push(channelDescription(rule.channel)); + if (rule.incoming === "none") conditions.push("an originally unscoped packet"); + else if (rule.incoming === "scoped") conditions.push("an originally scoped packet"); + else if (rule.incoming === "allowed") conditions.push("an original scope allowed by the region map"); + else if (rule.incoming === "unknown") conditions.push("an original scope that is unknown or denied"); + else if (rule.incoming.startsWith("scope:")) conditions.push(`original exact scope #${rule.incoming.slice(6)}`); + else if (rule.incoming.startsWith("region:")) conditions.push(`original exact region ${rule.incoming.slice(7)}`); + if (rule.pathKind === "prefix") conditions.push(`a path beginning ${rule.pathPrefix}`); + else if (rule.pathKind === "blacklist") conditions.push("a path matching the passive blacklist"); + else if (rule.pathKind.startsWith("bucket:")) conditions.push(`a path matching ${rule.pathKind}`); + else if (rule.pathKind.startsWith("loop:")) conditions.push(`the ${rule.pathKind.slice(5)} own-ID loop threshold`); + if (rule.sender) conditions.push(`decrypted sender "${rule.sender}"`); + if (rule.tempRadio !== "any") conditions.push(`temporary radio ${rule.tempRadio}`); + if (rule.snrMin != null || rule.snrMax != null) { + if (rule.snrMin != null && rule.snrMax != null) conditions.push(`SNR ${rule.snrMin} through ${rule.snrMax} dB`); + else if (rule.snrMin != null) conditions.push(`SNR at least ${rule.snrMin} dB`); + else conditions.push(`SNR at most ${rule.snrMax} dB`); + } + const mode = rule.mode === "shadow" + ? "In shadow mode, report that it would " + : rule.mode === "disabled" + ? "This rule is disabled; if enabled it would " + : "When matched, "; + return `${PHASE_LABELS[rule.phase]} rule ${rule.id}, owned by ${OWNER_LABELS[rule.owner]}, matches ${conditions.join(", ")}. ${mode}${actionPhrases(rule).join(" and ")}. Priority ${rule.priority}; every condition reads immutable receive-time facts.`; + } + + function ruleWarnings(input) { + const rule = normalizeRule(input); + const warnings = []; + const [minimumHops] = hopBounds(rule.hops); + if (rule.verdict === "drop" && rule.route === "any" && rule.type === "any" && rule.hops === "all") { + warnings.push("This is a global drop rule across flood and direct traffic."); + } + if (rule.verdict === "drop" && minimumHops === 0 + && ["any", "class:login", "req", "response", "txt_msg", "anon_req", "path"].includes(rule.type)) { + warnings.push("This rule can block remote administration traffic at hop 0. Keep a serial recovery path."); + } + if (rule.type === "class:other") warnings.push("class:other intentionally includes current and future types outside group and login classes, including OTA."); + if (rule.channel && rule.type === "any") warnings.push("A channel condition narrows type=any to authenticated group text/data packets."); + if (rule.sender) warnings.push("Displayed sender names are spoofable and are moderation signals, not identities."); + if (rule.pathKind !== "none") warnings.push("Pbyte and path-table matches use truncated routing hints, not authenticated identities."); + if (rule.pathKind.startsWith("bucket:")) warnings.push("The selected bucket must exist on the target node; the policy stores a reference, not its IDs."); + if (rule.snrMin != null || rule.snrMax != null) warnings.push("Signal-based policy can produce different decisions at neighboring nodes hearing the same packet."); + if (rule.stop === "policy") warnings.push("stop=policy skips every later configurable phase when this rule matches, but not mandatory protocol/radio safety."); + if (rule.mode === "shadow") warnings.push("Shadow mode records the match but applies no action and does not stop processing."); + if (rule.targetKind === "region") warnings.push("The configured region must exist, allow flooding, and have a usable transport key at evaluation time."); + return warnings; + } + + function estimateRuleBytes(input) { + const rule = normalizeRule(input); + let bytes = 12; + bytes += 3 + 3 + 3; + if (rule.channel) bytes += 3 + (rule.channel.length === 32 || rule.channel.length === 64 ? rule.channel.length / 2 : rule.channel.length); + if (rule.incoming !== "any") bytes += 3 + rule.incoming.length; + if (rule.pathKind === "prefix") bytes += 4 + rule.pathPrefix.replace(/,/g, "").length / 2; + else if (rule.pathKind !== "none") bytes += 3; + if (rule.sender) bytes += 3 + rule.sender.length; + if (rule.tempRadio !== "any") bytes += 3; + if (rule.snrMin != null || rule.snrMax != null) bytes += 6; + if (rule.verdict === "drop") bytes += 2; + if (rule.scopeGate !== "unchanged") bytes += 3; + if (rule.targetKind !== "none") bytes += 3 + rule.target.length; + if (rule.rate != null) bytes += 7; + if (rule.timing !== "inherit") bytes += 3; + if (rule.queue !== "inherit") bytes += 3; + if (rule.retryBucket !== "none") bytes += 4; + if (rule.tag) bytes += 3 + rule.tag.length; + if (rule.stop !== "none") bytes += 3; + return Math.ceil(bytes); + } + + function sortedRules(rules) { + return rules.slice().sort((left, right) => { + const phase = PHASE_ORDER[left.phase] - PHASE_ORDER[right.phase]; + if (phase !== 0) return phase; + if (left.priority !== right.priority) return right.priority - left.priority; + if (left.id < right.id) return -1; + if (left.id > right.id) return 1; + return 0; + }); + } + + function policyWarnings(rules, profile) { + const warnings = []; + const ids = new Set(); + rules.forEach((rule) => { + if (ids.has(rule.id)) warnings.push(`Rule ID ${rule.id} is duplicated.`); + ids.add(rule.id); + }); + const activeRewrites = rules.filter((rule) => rule.mode === "active" && rule.targetKind !== "none"); + activeRewrites.forEach((rule, index) => { + activeRewrites.slice(index + 1).forEach((other) => { + if (rule.phase === other.phase && rule.priority === other.priority + && rule.route === other.route && rule.type === other.type + && rule.hops === other.hops && rule.channel === other.channel) { + warnings.push(`Rewrite rules ${rule.id} and ${other.id} have overlapping core matches at the same priority; stable ID decides the winner.`); + } + }); + }); + const estimated = rules.reduce((total, rule) => total + estimateRuleBytes(rule), 16); + const budget = PROFILE_BUDGETS[profile] || PROFILE_BUDGETS.nrf52; + if (estimated > budget) warnings.push(`Approximate packed size ${estimated} bytes exceeds the selected ${budget}-byte target budget.`); + if (rules.length > 255) warnings.push("The draft exceeds the proposed 255 stable rule-ID limit."); + return Array.from(new Set(warnings)); + } + + function utf8ToBase64(text) { + if (typeof Buffer !== "undefined") return Buffer.from(text, "utf8").toString("base64"); + const bytes = new TextEncoder().encode(text); + let binary = ""; + bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); + return global.btoa(binary); + } + + function base64ToUtf8(encoded) { + try { + if (typeof Buffer !== "undefined") return Buffer.from(encoded, "base64").toString("utf8"); + const binary = global.atob(encoded); + return new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))); + } catch (_error) { + throw new FilterToolError("Playground bundle is not valid Base64."); + } + } + + function policyDocument(rules) { + return { + format: POLICY_FORMAT, + version: POLICY_VERSION, + status: "design-preview", + evaluation: "phase, descending priority, stable rule ID; immutable receive-time matches", + rules: sortedRules(rules.map(normalizeRule)), + }; + } + + function encodeBundle(rules) { + return BUNDLE_PREFIX + utf8ToBase64(JSON.stringify(policyDocument(rules))); + } + + function decodePolicyObject(value) { + if (!value || typeof value !== "object" || value.format !== POLICY_FORMAT || value.version !== POLICY_VERSION) { + throw new FilterToolError("Policy document has an unknown format or version."); + } + if (!Array.isArray(value.rules)) throw new FilterToolError("Policy document does not contain a rules array."); + return value.rules.map(normalizeRule); + } + + function decodeBundle(value) { + const text = clean(value).replace(/\s+/g, ""); + if (!text.startsWith(BUNDLE_PREFIX)) throw new FilterToolError(`Bundle must begin with ${BUNDLE_PREFIX}`); + try { + return decodePolicyObject(JSON.parse(base64ToUtf8(text.slice(BUNDLE_PREFIX.length)))); + } catch (error) { + if (error instanceof FilterToolError) throw error; + throw new FilterToolError("Playground bundle does not contain valid policy JSON."); + } + } + + function parsePolicyInput(input) { + const text = clean(input) + .replace(/^```(?:text|json)?\s*/i, "") + .replace(/```\s*$/, "") + .trim(); + if (!text) throw new FilterToolError("Paste readable policy, JSON, or a playground bundle first."); + if (text.startsWith(BUNDLE_PREFIX)) return decodeBundle(text); + if (text[0] === "{") { + try { + return decodePolicyObject(JSON.parse(text)); + } catch (error) { + if (error instanceof FilterToolError) throw error; + throw new FilterToolError("Policy JSON could not be parsed."); + } + } + const rules = []; + const failures = []; + text.split(/\r?\n/).forEach((rawLine, index) => { + const line = clean(rawLine); + if (!line || line.startsWith("#")) return; + try { + rules.push(parseDefinition(line)); + } catch (error) { + failures.push(`Line ${index + 1}: ${error.message}`); + } + }); + if (failures.length) throw new FilterToolError(failures.join(" ")); + if (!rules.length) throw new FilterToolError("No readable policy rules were found."); + return rules; + } + + function normalizePacketPath(value) { + return normalizePathPrefix(value, 63); + } + + function normalizePacket(input) { + const scopeStatus = enumValue(input.scopeStatus, ["none", "allowed", "unknown"], "Packet scope status", "none"); + const type = normalizePacketType(input.type); + const channel = clean(input.channel) ? normalizeChannel(input.channel, false) : ""; + const sender = clean(input.sender); + if (channel && !GROUP_TYPES.includes(type)) { + throw new FilterToolError("Only group text/data packet facts can include an authenticated channel."); + } + if (sender && type !== "grp_txt") { + throw new FilterToolError("Only a decrypted group-text packet can include a displayed sender."); + } + const buckets = clean(input.buckets) + ? clean(input.buckets).split(",").map((value) => requiredInteger(value, 1, 6, "Path bucket")) + : []; + return { + route: enumValue(input.route, ["unscoped_flood", "scoped_flood", "direct"], "Packet route", "unscoped_flood"), + type, + hops: requiredInteger(input.hops, 0, 63, "Packet hops"), + channel, + path: normalizePacketPath(input.path), + scopeStatus, + scopeName: clean(input.scopeName).replace(/^#/, ""), + regionName: clean(input.regionName), + sender, + tempRadio: Boolean(input.tempRadio), + snr: nullableNumber(input.snr, -30, 30, "Packet SNR"), + blacklist: Boolean(input.blacklist), + buckets: Array.from(new Set(buckets)), + loopLevel: requiredInteger(input.loopLevel == null ? 0 : input.loopLevel, 0, 3, "Loop result"), + }; + } + + function typeMatches(ruleType, packetType) { + if (ruleType === "any") return true; + if (ruleType === "class:group") return GROUP_TYPES.includes(packetType); + if (ruleType === "class:login") return LOGIN_TYPES.includes(packetType); + if (ruleType === "class:other") return !GROUP_TYPES.includes(packetType) && !LOGIN_TYPES.includes(packetType); + return ruleType === packetType; + } + + function incomingMatches(ruleIncoming, packet) { + if (ruleIncoming === "any") return true; + if (ruleIncoming === "none") return packet.scopeStatus === "none"; + if (ruleIncoming === "scoped") return packet.scopeStatus !== "none"; + if (ruleIncoming === "allowed") return packet.scopeStatus === "allowed"; + if (ruleIncoming === "unknown") return packet.scopeStatus === "unknown"; + if (ruleIncoming.startsWith("scope:")) return packet.scopeStatus !== "none" && packet.scopeName === ruleIncoming.slice(6); + if (ruleIncoming.startsWith("region:")) { + return packet.scopeStatus === "allowed" + && packet.regionName.toLowerCase() === ruleIncoming.slice(7).toLowerCase(); + } + return false; + } + + function matchRule(inputRule, inputPacket) { + const rule = normalizeRule(inputRule); + const packet = normalizePacket(inputPacket); + const misses = []; + if (rule.route === "flood" && !["unscoped_flood", "scoped_flood"].includes(packet.route)) misses.push("route is not flood"); + else if (rule.route !== "any" && rule.route !== "flood" && rule.route !== packet.route) misses.push(`route is ${packet.route}`); + if (!typeMatches(rule.type, packet.type)) misses.push(`payload type ${packet.type} is outside ${rule.type}`); + const [minimum, maximum] = hopBounds(rule.hops); + if (packet.hops < minimum || packet.hops > maximum) misses.push(`hop ${packet.hops} is outside ${rule.hops}`); + if (rule.channel && rule.channel !== packet.channel) misses.push("authenticated channel differs or is unavailable"); + if (!incomingMatches(rule.incoming, packet)) misses.push(`original scope does not satisfy ${rule.incoming}`); + if (rule.pathKind === "prefix") { + const wanted = rule.pathPrefix.split(","); + const actual = packet.path ? packet.path.split(",") : []; + if (wanted.some((id, index) => actual[index] !== id)) misses.push(`path does not begin ${rule.pathPrefix}`); + } else if (rule.pathKind === "blacklist" && !packet.blacklist) misses.push("passive blacklist did not match"); + else if (rule.pathKind.startsWith("bucket:") && !packet.buckets.includes(Number(rule.pathKind.slice(7)))) { + misses.push(`${rule.pathKind} did not match`); + } else if (rule.pathKind.startsWith("loop:")) { + const required = { strict: 1, moderate: 2, minimal: 3 }[rule.pathKind.slice(5)]; + if (packet.loopLevel < required) misses.push(`${rule.pathKind} threshold was not reached`); + } + if (rule.sender && rule.sender.toLowerCase() !== packet.sender.toLowerCase()) misses.push("decrypted sender differs or is unavailable"); + if (rule.tempRadio === "active" && !packet.tempRadio) misses.push("temporary radio is inactive"); + if (rule.tempRadio === "inactive" && packet.tempRadio) misses.push("temporary radio is active"); + if (rule.snrMin != null && (packet.snr == null || packet.snr < rule.snrMin)) misses.push(`SNR is below ${rule.snrMin} dB`); + if (rule.snrMax != null && (packet.snr == null || packet.snr > rule.snrMax)) misses.push(`SNR is above ${rule.snrMax} dB`); + return { matched: misses.length === 0, misses }; + } + + function applyRuleActions(rule, packet, decision) { + const applied = []; + if (rule.verdict === "drop") { + decision.drop = true; + applied.push("sticky drop"); + } + if (rule.scopeGate !== "unchanged" && decision.scopeGate === "global") { + decision.scopeGate = rule.scopeGate; + applied.push(rule.scopeGate === "require_allowed" ? "require allowed original scope" : "bypass global region gate"); + if (rule.scopeGate === "require_allowed" && packet.scopeStatus !== "allowed") { + decision.drop = true; + applied.push("scope requirement failed -> sticky drop"); + } + } else if (rule.scopeGate !== "unchanged") applied.push("scope-gate action ignored; higher rule already selected it"); + if (rule.targetKind !== "none" && !decision.scopeTarget) { + decision.scopeTarget = { kind: rule.targetKind, name: rule.target, rule: rule.id }; + applied.push(`${rule.targetKind} target ${rule.target}`); + } else if (rule.targetKind !== "none") applied.push("rewrite ignored; higher rule already selected a target"); + if (rule.rate != null) { + decision.rates.push({ rule: rule.id, rate: rule.rate, burst: rule.burst }); + applied.push(`rate ${rule.rate}/min burst ${rule.burst}`); + } + if (rule.timing !== "inherit" && decision.timing === "inherit") { + decision.timing = rule.timing; + applied.push(`${rule.timing} timing`); + } else if (rule.timing !== "inherit") applied.push("timing ignored; higher rule already selected it"); + if (rule.queue !== "inherit" && decision.queue === "inherit") { + decision.queue = rule.queue; + applied.push(`${rule.queue} queue`); + } else if (rule.queue !== "inherit") applied.push("queue action ignored; higher rule already selected it"); + if (rule.retryBucket !== "none" && !decision.retry) { + decision.retry = { bucket: rule.retryBucket, attempts: rule.retryAttempts, rule: rule.id }; + applied.push(`retry ${rule.retryBucket}/${rule.retryAttempts}`); + } else if (rule.retryBucket !== "none") applied.push("retry ignored; higher rule already selected it"); + if (rule.tag) { + decision.tags.push(rule.tag); + applied.push(`tag ${rule.tag}`); + } + return applied; + } + + function simulatePolicy(inputRules, inputPacket) { + const rules = sortedRules(inputRules.map(normalizeRule)); + const packet = normalizePacket(inputPacket); + const decision = { + drop: false, + scopeGate: "global", + scopeTarget: null, + rates: [], + timing: "inherit", + queue: "inherit", + retry: null, + tags: [], + }; + const trace = []; + const stoppedPhases = new Set(); + let policyStopped = false; + rules.forEach((rule) => { + if (rule.mode === "disabled") { + trace.push({ id: rule.id, phase: rule.phase, status: "disabled", detail: "Rule is disabled." }); + return; + } + if (policyStopped) { + trace.push({ id: rule.id, phase: rule.phase, status: "stopped", detail: "A prior stop=policy skipped this rule." }); + return; + } + if (stoppedPhases.has(rule.phase)) { + trace.push({ id: rule.id, phase: rule.phase, status: "stopped", detail: "A prior stop=phase skipped this rule." }); + return; + } + const result = matchRule(rule, packet); + if (!result.matched) { + trace.push({ id: rule.id, phase: rule.phase, status: "miss", detail: result.misses.join("; ") }); + return; + } + if (rule.mode === "shadow") { + trace.push({ id: rule.id, phase: rule.phase, status: "shadow", detail: `Would apply: ${actionPhrases(rule).join("; ")}.` }); + return; + } + const applied = applyRuleActions(rule, packet, decision); + trace.push({ id: rule.id, phase: rule.phase, status: "match", detail: `Applied: ${applied.join("; ")}.` }); + if (rule.stop === "phase") stoppedPhases.add(rule.phase); + if (rule.stop === "policy") policyStopped = true; + }); + return { packet, decision, trace }; + } + + const EXAMPLES = Object.freeze({ + blackhole: Object.freeze([ + { + ...defaultRule("blackhole-rewrite"), + priority: 160, + type: "grp_data", + hops: "4+", + channel: "#rgdata", + incoming: "none", + pathKind: "prefix", + pathPrefix: "860C", + stop: "phase", + }, + ]), + wildcards: Object.freeze([ + { + ...defaultRule("login-scope"), + type: "class:login", + channel: "", + incoming: "any", + target: "BlackHole86", + priority: 140, + }, + { + ...defaultRule("other-scope-bucket2"), + type: "class:other", + channel: "", + incoming: "any", + pathKind: "bucket:2", + target: "BlackHole86", + timing: "slow", + priority: 130, + }, + ]), + moderation: Object.freeze([ + { + ...defaultRule("public-noisy-user"), + phase: "content", + owner: "filter", + type: "grp_txt", + channel: "public", + incoming: "any", + sender: "Noisy User", + targetKind: "none", + target: "", + timing: "inherit", + rate: 5, + burst: 5, + tag: "public-rate", + }, + { + ...defaultRule("local-bot-path"), + phase: "content", + owner: "filter", + type: "grp_txt", + channel: "#local", + incoming: "any", + pathKind: "prefix", + pathPrefix: "A1B2C3,D4E5F6", + sender: "bot", + targetKind: "none", + target: "", + timing: "inherit", + verdict: "drop", + priority: 180, + }, + ]), + system: Object.freeze([ + { + ...defaultRule("system-flood-max"), + phase: "forward", + owner: "system", + type: "any", + channel: "", + incoming: "any", + hops: "9+", + targetKind: "none", + target: "", + timing: "inherit", + verdict: "drop", + priority: 220, + }, + { + ...defaultRule("system-loop-minimal"), + phase: "forward", + owner: "system", + type: "any", + channel: "", + incoming: "any", + pathKind: "loop:minimal", + targetKind: "none", + target: "", + timing: "inherit", + verdict: "drop", + priority: 230, + }, + { + ...defaultRule("ota-outside-temp-radio"), + phase: "forward", + owner: "system", + type: "ota", + channel: "", + incoming: "any", + tempRadio: "inactive", + targetKind: "none", + target: "", + timing: "inherit", + verdict: "drop", + priority: 250, + }, + ]), + }); + + const COMPLETE_EXAMPLES = Object.freeze({ + blackhole: EXAMPLES.blackhole, + wildcards: EXAMPLES.wildcards, + moderation: EXAMPLES.moderation, + system: EXAMPLES.system, + mixed: Object.freeze([ + { + ...defaultRule("group-gate-default"), + phase: "scope_gate", + owner: "scope", + type: "class:group", + channel: "", + incoming: "any", + targetKind: "none", + target: "", + timing: "inherit", + scopeGate: "bypass_global", + priority: 1, + }, + { + ...defaultRule("bot-requires-region"), + phase: "scope_gate", + owner: "scope", + type: "class:group", + channel: "#bot", + incoming: "any", + targetKind: "none", + target: "", + timing: "inherit", + scopeGate: "require_allowed", + stop: "phase", + priority: 200, + }, + ...clone(EXAMPLES.blackhole), + ...clone(EXAMPLES.moderation), + ...clone(EXAMPLES.system), + { + ...defaultRule("weak-signal-slow-queue"), + phase: "schedule", + owner: "filter", + type: "any", + channel: "", + incoming: "any", + snrMax: -8, + targetKind: "none", + target: "", + timing: "slow", + queue: "low", + retryBucket: "bucket:3", + retryAttempts: 2, + priority: 80, + }, + ]), + }); + + function initializeTool() { + const root = document.querySelector("[data-filter-tool]"); + if (!root) return; + const field = (name) => root.querySelector(`[data-field='${name}']`); + const packetField = (name) => root.querySelector(`[data-packet='${name}']`); + const liveDefinition = root.querySelector("[data-role='live-command']"); + const liveExplanation = root.querySelector("[data-role='live-explanation']"); + const liveWarnings = root.querySelector("[data-role='live-warnings']"); + const saveButton = root.querySelector("[data-role='save-rule']"); + const ruleList = root.querySelector("[data-role='rule-list']"); + const emptyPolicy = root.querySelector("[data-role='empty-policy']"); + const policySummary = root.querySelector("[data-role='policy-summary']"); + const policyWarningList = root.querySelector("[data-role='policy-warnings']"); + const targetProfile = root.querySelector("[data-role='target-profile']"); + const importInput = root.querySelector("[data-role='import-input']"); + const importError = root.querySelector("[data-role='import-error']"); + const explainResults = root.querySelector("[data-role='explain-results']"); + const simulationError = root.querySelector("[data-role='simulation-error']"); + const simulationResult = root.querySelector("[data-role='simulation-result']"); + const exportDsl = root.querySelector("[data-role='export-dsl']"); + const exportJson = root.querySelector("[data-role='export-json']"); + const exportBundle = root.querySelector("[data-role='export-bundle']"); + let rules = []; + let editingId = ""; + let nextId = 1; + + function freshId() { + let id; + do { id = `rule-${nextId++}`; } while (rules.some((rule) => rule.id === id)); + return id; + } + + function setValue(name, value) { + const element = field(name); + if (!element) return; + if (element.type === "checkbox") element.checked = Boolean(value); + else element.value = value == null ? "" : String(value); + } + + function collectForm() { + return normalizeRule({ + id: field("id").value, + phase: field("phase").value, + owner: field("owner").value, + priority: field("priority").value, + mode: field("mode").value, + stop: field("stop").value, + route: field("route").value, + type: field("type").value, + hops: field("hops").value, + channel: field("channel").value, + incoming: field("incoming").value, + pathKind: field("path-kind").value, + pathPrefix: field("path-prefix").value, + sender: field("sender").value, + tempRadio: field("temp-radio").value, + snrMin: field("snr-min").value, + snrMax: field("snr-max").value, + verdict: field("verdict").value, + scopeGate: field("scope-gate").value, + targetKind: field("target-kind").value, + target: field("target").value, + rate: field("rate").value, + burst: field("burst").value, + timing: field("timing").value, + queue: field("queue").value, + retryBucket: field("retry-bucket").value, + retryAttempts: field("retry-attempts").value, + tag: field("tag").value, + }); + } + + function writeForm(input, editing) { + const rule = normalizeRule(input); + editingId = editing ? rule.id : ""; + Object.entries({ + id: rule.id, + phase: rule.phase, + owner: rule.owner, + priority: rule.priority, + mode: rule.mode, + stop: rule.stop, + route: rule.route, + type: rule.type, + hops: rule.hops, + channel: rule.channel, + incoming: rule.incoming, + "path-kind": rule.pathKind, + "path-prefix": rule.pathPrefix, + sender: rule.sender, + "temp-radio": rule.tempRadio, + "snr-min": rule.snrMin, + "snr-max": rule.snrMax, + verdict: rule.verdict, + "scope-gate": rule.scopeGate, + "target-kind": rule.targetKind, + target: rule.target, + rate: rule.rate, + burst: rule.burst, + timing: rule.timing, + queue: rule.queue, + "retry-bucket": rule.retryBucket, + "retry-attempts": rule.retryAttempts, + tag: rule.tag, + }).forEach(([name, value]) => setValue(name, value)); + saveButton.textContent = editing ? "Update rule" : "Add rule to policy"; + updateFieldStates(); + updateLive(); + } + + function resetForm() { + writeForm(defaultRule(freshId()), false); + } + + function updateFieldStates() { + field("path-prefix").disabled = field("path-kind").value !== "prefix"; + field("target").disabled = field("target-kind").value === "none"; + field("burst").disabled = clean(field("rate").value) === ""; + field("retry-attempts").disabled = field("retry-bucket").value === "none"; + } + + function renderWarnings(container, warnings) { + container.replaceChildren(); + warnings.forEach((warning) => { + const item = document.createElement("li"); + item.textContent = warning; + container.appendChild(item); + }); + container.hidden = warnings.length === 0; + } + + function updateLive() { + updateFieldStates(); + try { + const rule = collectForm(); + liveDefinition.textContent = buildDefinition(rule); + liveExplanation.textContent = explainRule(rule); + renderWarnings(liveWarnings, ruleWarnings(rule)); + saveButton.disabled = false; + } catch (error) { + liveDefinition.textContent = "Complete a valid rule to generate its readable definition."; + liveExplanation.textContent = error.message; + renderWarnings(liveWarnings, []); + saveButton.disabled = true; + } + } + + async function copyText(text, button) { + try { + await navigator.clipboard.writeText(text); + const original = button.textContent; + button.textContent = "Copied"; + global.setTimeout(() => { button.textContent = original; }, 1200); + } catch (_error) { + importError.textContent = "Browser clipboard access was denied. Select and copy the text manually."; + importError.hidden = false; + } + } + + function makeButton(label, callback, className) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + if (className) button.className = className; + button.addEventListener("click", callback); + return button; + } + + function renderPolicy() { + const ordered = sortedRules(rules); + ruleList.replaceChildren(); + emptyPolicy.hidden = ordered.length !== 0; + ordered.forEach((rule, index) => { + const item = document.createElement("li"); + const card = document.createElement("article"); + card.className = "filter-rule-card"; + const header = document.createElement("div"); + header.className = "filter-rule-card-header"; + const labels = document.createElement("div"); + labels.className = "filter-rule-labels"; + const name = document.createElement("strong"); + name.textContent = rule.id; + const phase = document.createElement("span"); + phase.textContent = `Phase ${PHASE_ORDER[rule.phase] + 1}: ${PHASE_LABELS[rule.phase]}`; + labels.append(name, phase); + const meta = document.createElement("span"); + meta.className = "filter-rule-meta"; + meta.textContent = `order ${index + 1}, priority ${rule.priority}, ${rule.mode}, ~${estimateRuleBytes(rule)} bytes`; + header.append(labels, meta); + const definition = document.createElement("code"); + definition.className = "filter-rule-command"; + definition.textContent = buildDefinition(rule); + const explanation = document.createElement("p"); + explanation.textContent = explainRule(rule); + const warnings = document.createElement("ul"); + warnings.className = "filter-inline-warnings"; + renderWarnings(warnings, ruleWarnings(rule)); + const actions = document.createElement("div"); + actions.className = "filter-rule-actions"; + actions.append( + makeButton("Edit", () => { + writeForm(rule, true); + root.querySelector(".filter-builder").scrollIntoView({ behavior: "smooth", block: "start" }); + }), + makeButton("Copy", (event) => copyText(buildDefinition(rule), event.currentTarget)), + makeButton("Remove", () => { + rules = rules.filter((candidate) => candidate !== rule); + if (editingId === rule.id) resetForm(); + renderPolicy(); + }, "filter-danger-action") + ); + card.append(header, definition, explanation, warnings, actions); + item.appendChild(card); + ruleList.appendChild(item); + }); + const estimate = rules.reduce((total, rule) => total + estimateRuleBytes(rule), 16); + const budget = PROFILE_BUDGETS[targetProfile.value] || PROFILE_BUDGETS.nrf52; + const percentage = Math.min(999, Math.round(estimate * 100 / budget)); + policySummary.textContent = `${rules.length} rule${rules.length === 1 ? "" : "s"} | approximate packed size ${estimate}/${budget} bytes (${percentage}%)`; + renderWarnings(policyWarningList, policyWarnings(rules, targetProfile.value)); + exportDsl.value = ordered.map(buildDefinition).join("\n"); + exportJson.value = JSON.stringify(policyDocument(ordered), null, 2); + exportBundle.value = encodeBundle(ordered); + } + + function assignRules(inputRules) { + rules = inputRules.map(normalizeRule); + const numericIds = rules + .map((rule) => rule.id.match(/^rule-(\d+)$/)) + .filter(Boolean) + .map((match) => Number(match[1])); + if (numericIds.length) nextId = Math.max(nextId, Math.max(...numericIds) + 1); + } + + function renderExplanation(inputRules) { + explainResults.replaceChildren(); + const heading = document.createElement("h3"); + heading.textContent = `${inputRules.length} parsed rule${inputRules.length === 1 ? "" : "s"}`; + explainResults.appendChild(heading); + const list = document.createElement("ol"); + sortedRules(inputRules).forEach((rule) => { + const item = document.createElement("li"); + const definition = document.createElement("code"); + definition.textContent = buildDefinition(rule); + const explanation = document.createElement("p"); + explanation.textContent = explainRule(rule); + const warnings = document.createElement("ul"); + warnings.className = "filter-inline-warnings"; + renderWarnings(warnings, ruleWarnings(rule)); + item.append(definition, explanation, warnings); + list.appendChild(item); + }); + explainResults.appendChild(list); + const warnings = document.createElement("ul"); + warnings.className = "filter-inline-warnings"; + renderWarnings(warnings, policyWarnings(inputRules, targetProfile.value)); + explainResults.appendChild(warnings); + explainResults.hidden = false; + } + + function parseImport() { + importError.hidden = true; + importError.textContent = ""; + try { + return parsePolicyInput(importInput.value); + } catch (error) { + explainResults.hidden = true; + importError.textContent = error.message; + importError.hidden = false; + return null; + } + } + + function collectPacket() { + return normalizePacket({ + route: packetField("route").value, + type: packetField("type").value, + hops: packetField("hops").value, + channel: packetField("channel").value, + path: packetField("path").value, + scopeStatus: packetField("scope-status").value, + scopeName: packetField("scope-name").value, + regionName: packetField("region-name").value, + sender: packetField("sender").value, + tempRadio: packetField("temp-radio").checked, + snr: packetField("snr").value, + blacklist: packetField("blacklist").value === "yes", + buckets: packetField("buckets").value, + loopLevel: packetField("loop-level").value, + }); + } + + function decisionValue(label, value) { + const item = document.createElement("div"); + const term = document.createElement("span"); + term.textContent = label; + const content = document.createElement("strong"); + content.textContent = value; + item.append(term, content); + return item; + } + + function renderSimulation(result) { + simulationResult.replaceChildren(); + const heading = document.createElement("h3"); + heading.textContent = result.decision.drop + ? "Final policy decision: DROP" + : "Final policy decision: eligible, subject to remaining gates"; + const summary = document.createElement("div"); + summary.className = "filter-decision-grid"; + summary.append( + decisionValue("Region gate", result.decision.scopeGate), + decisionValue("Scope target", result.decision.scopeTarget ? `${result.decision.scopeTarget.kind}:${result.decision.scopeTarget.name}` : "unchanged"), + decisionValue("Rate constraints", result.decision.rates.length ? result.decision.rates.map((rate) => `${rate.rate}/min burst ${rate.burst}`).join(", ") : "none"), + decisionValue("Timing", result.decision.timing), + decisionValue("Queue", result.decision.queue), + decisionValue("Retry", result.decision.retry ? `${result.decision.retry.bucket}/${result.decision.retry.attempts}` : "none"), + decisionValue("Tags", result.decision.tags.length ? result.decision.tags.join(", ") : "none") + ); + const traceHeading = document.createElement("h3"); + traceHeading.textContent = "Evaluation trace"; + const trace = document.createElement("ol"); + trace.className = "filter-trace"; + result.trace.forEach((entry) => { + const item = document.createElement("li"); + item.className = `filter-trace-${entry.status}`; + const title = document.createElement("strong"); + title.textContent = `${entry.id} - ${entry.status}`; + const detail = document.createElement("span"); + detail.textContent = entry.detail; + item.append(title, detail); + trace.appendChild(item); + }); + simulationResult.append(heading, summary, traceHeading, trace); + simulationResult.hidden = false; + } + + root.querySelectorAll("[data-field]").forEach((element) => { + element.addEventListener("input", updateLive); + element.addEventListener("change", updateLive); + }); + root.querySelector("[data-role='reset-form']").addEventListener("click", resetForm); + saveButton.addEventListener("click", () => { + try { + const rule = collectForm(); + if (editingId) { + const index = rules.findIndex((candidate) => candidate.id === editingId); + if (index >= 0) rules[index] = rule; + else rules.push(rule); + } else rules.push(rule); + resetForm(); + renderPolicy(); + } catch (_error) { + updateLive(); + } + }); + root.querySelector("[data-role='copy-live']").addEventListener("click", (event) => { + if (liveDefinition.textContent) copyText(liveDefinition.textContent, event.currentTarget); + }); + root.querySelector("[data-role='clear-policy']").addEventListener("click", () => { + rules = []; + resetForm(); + renderPolicy(); + simulationResult.hidden = true; + }); + targetProfile.addEventListener("change", renderPolicy); + root.querySelectorAll("[data-example]").forEach((button) => { + button.addEventListener("click", () => { + const example = COMPLETE_EXAMPLES[button.getAttribute("data-example")]; + if (!example) return; + assignRules(clone(example)); + writeForm(rules[0], true); + renderPolicy(); + simulationResult.hidden = true; + }); + }); + root.querySelector("[data-role='explain-input']").addEventListener("click", () => { + const parsed = parseImport(); + if (parsed) renderExplanation(parsed); + }); + root.querySelector("[data-role='load-input']").addEventListener("click", () => { + const parsed = parseImport(); + if (!parsed) return; + assignRules(parsed); + renderExplanation(rules); + writeForm(rules[0], true); + renderPolicy(); + }); + root.querySelector("[data-role='simulate']").addEventListener("click", () => { + simulationError.hidden = true; + try { + renderSimulation(simulatePolicy(rules, collectPacket())); + } catch (error) { + simulationResult.hidden = true; + simulationError.textContent = error.message; + simulationError.hidden = false; + } + }); + root.querySelector("[data-role='reset-packet']").addEventListener("click", () => { + packetField("route").value = "unscoped_flood"; + packetField("type").value = "grp_data"; + packetField("hops").value = "4"; + packetField("channel").value = "#rgdata"; + packetField("path").value = "860C,12A4"; + packetField("scope-status").value = "none"; + packetField("scope-name").value = ""; + packetField("region-name").value = ""; + packetField("sender").value = ""; + packetField("snr").value = "6"; + packetField("blacklist").value = "no"; + packetField("buckets").value = ""; + packetField("loop-level").value = "0"; + packetField("temp-radio").checked = false; + simulationResult.hidden = true; + simulationError.hidden = true; + }); + root.querySelectorAll("[data-export-tab]").forEach((button) => { + button.addEventListener("click", () => { + const selected = button.getAttribute("data-export-tab"); + root.querySelectorAll("[data-export-tab]").forEach((candidate) => { + candidate.setAttribute("aria-selected", candidate === button ? "true" : "false"); + }); + root.querySelectorAll("[data-export-panel]").forEach((panel) => { + panel.hidden = panel.getAttribute("data-export-panel") !== selected; + }); + }); + }); + root.querySelector("[data-role='copy-export']").addEventListener("click", (event) => { + const panel = Array.from(root.querySelectorAll("[data-export-panel]")).find((candidate) => !candidate.hidden); + const textarea = panel ? panel.querySelector("textarea") : null; + if (textarea) copyText(textarea.value, event.currentTarget); + }); + root.querySelector("[data-role='download-json']").addEventListener("click", () => { + const blob = new Blob([exportJson.value], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "meshcore-policy-engine-draft.json"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }); + + const requestedExample = new URLSearchParams(global.location.search).get("example"); + if (requestedExample && COMPLETE_EXAMPLES[requestedExample]) assignRules(clone(COMPLETE_EXAMPLES[requestedExample])); + resetForm(); + renderPolicy(); + } + + const api = Object.freeze({ + BUNDLE_PREFIX, + EXAMPLES: COMPLETE_EXAMPLES, + FilterToolError, + buildDefinition, + decodeBundle, + encodeBundle, + estimateRuleBytes, + explainRule, + matchRule, + normalizePacket, + normalizeRule, + parseDefinition, + parsePolicyInput, + policyDocument, + policyWarnings, + ruleWarnings, + simulatePolicy, + sortedRules, + }); + + global.MeshCoreFilterTool = api; + if (typeof module === "object" && module.exports) module.exports = api; + if (typeof document !== "undefined") { + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initializeTool, { once: true }); + else initializeTool(); + } +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/docs/_stylesheets/filter_tool.css b/docs/_stylesheets/filter_tool.css new file mode 100644 index 00000000..bf05f6df --- /dev/null +++ b/docs/_stylesheets/filter_tool.css @@ -0,0 +1,585 @@ +.filter-tool { + --filter-border: color-mix(in srgb, var(--md-default-fg-color) 18%, transparent); + --filter-border-strong: color-mix(in srgb, var(--md-primary-fg-color) 46%, transparent); + --filter-soft: color-mix(in srgb, var(--md-default-fg-color) 5%, transparent); + --filter-softer: color-mix(in srgb, var(--md-default-fg-color) 2.5%, transparent); + --filter-accent: color-mix(in srgb, var(--md-primary-fg-color) 12%, transparent); + --filter-warning: #ed9b00; + --filter-danger: #d32f2f; + margin: 1.5rem 0 2rem; + color: var(--md-default-fg-color); +} + +.filter-design-warning { + margin: 1rem 0; + padding: 0.75rem 0.9rem; + border-left: 0.25rem solid #ed9b00; + border-radius: 0.25rem; + background: color-mix(in srgb, #ed9b00 11%, transparent); +} + +.filter-design-warning strong { + display: block; + margin-bottom: 0.2rem; +} + +.filter-design-warning p { + margin: 0; +} + +.filter-tool [hidden] { + display: none !important; +} + +.filter-tool-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + align-items: center; + margin-bottom: 1rem; + padding: 0.8rem; + border: 1px solid var(--filter-border); + border-radius: 0.65rem; + background: + linear-gradient(120deg, var(--filter-accent), transparent 56%), + var(--md-default-bg-color); +} + +.filter-tool-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.9fr); + gap: 1rem; + align-items: start; +} + +.filter-panel { + min-width: 0; + padding: 1rem; + border: 1px solid var(--filter-border); + border-radius: 0.65rem; + background: var(--md-default-bg-color); + box-shadow: 0 0.15rem 0.65rem color-mix(in srgb, #000 9%, transparent); +} + +.filter-panel + .filter-panel, +.filter-tool-grid + .filter-panel { + margin-top: 1rem; +} + +.filter-tool-grid .filter-panel + .filter-panel { + margin-top: 0; +} + +.filter-panel-heading { + display: flex; + gap: 0.75rem; + align-items: flex-start; + justify-content: space-between; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--filter-border); +} + +.filter-panel-heading h2 { + margin: 0.12rem 0 0 !important; + font-size: 1.22rem; + line-height: 1.25; +} + +.filter-eyebrow { + margin: 0; + color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent); + font-size: 0.66rem; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.filter-tool h3 { + margin: 1rem 0 0.55rem; + font-size: 0.88rem; + letter-spacing: 0.015em; +} + +.filter-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.72rem; +} + +.filter-tool label { + display: block; + min-width: 0; + color: color-mix(in srgb, var(--md-default-fg-color) 82%, transparent); + font-size: 0.76rem; + font-weight: 750; +} + +.filter-tool input, +.filter-tool select, +.filter-tool textarea { + display: block; + width: 100%; + box-sizing: border-box; + margin-top: 0.3rem; + border: 1px solid var(--filter-border); + border-radius: 0.38rem; + color: var(--md-default-fg-color); + background: var(--md-code-bg-color); + font: inherit; +} + +.filter-tool input, +.filter-tool select { + min-height: 2.35rem; + padding: 0.4rem 0.55rem; +} + +.filter-tool textarea { + min-height: 9rem; + padding: 0.72rem; + resize: vertical; + font: 0.76rem/1.5 var(--md-code-font-family); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.filter-tool input:focus-visible, +.filter-tool select:focus-visible, +.filter-tool textarea:focus-visible, +.filter-tool button:focus-visible { + outline: 0.15rem solid var(--md-accent-fg-color); + outline-offset: 0.12rem; +} + +.filter-tool input::placeholder, +.filter-tool textarea::placeholder { + color: color-mix(in srgb, var(--md-code-fg-color) 48%, transparent); +} + +.filter-check-row { + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1rem; + margin-top: 0.8rem; + padding: 0.68rem 0.75rem; + border: 1px solid var(--filter-border); + border-radius: 0.4rem; + background: var(--filter-soft); +} + +.filter-check-row label { + display: inline-flex; + gap: 0.45rem; + align-items: center; + margin: 0; + cursor: pointer; +} + +.filter-check-row input { + width: 1rem; + min-height: 1rem; + height: 1rem; + margin: 0; + padding: 0; +} + +.filter-tool button { + appearance: none; + min-height: 2.2rem; + padding: 0.4rem 0.72rem; + border: 1px solid var(--filter-border); + border-radius: 0.35rem; + color: var(--md-default-fg-color); + background: var(--filter-soft); + font: inherit; + font-size: 0.76rem; + font-weight: 750; + cursor: pointer; +} + +.filter-tool button:hover:not(:disabled) { + border-color: var(--filter-border-strong); + background: var(--filter-accent); +} + +.filter-tool button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.filter-tool .filter-primary-action { + border-color: var(--md-primary-fg-color); + color: var(--md-primary-bg-color); + background: var(--md-primary-fg-color); +} + +.filter-tool .filter-primary-action:hover:not(:disabled) { + filter: brightness(1.12); + background: var(--md-primary-fg-color); +} + +.filter-tool .filter-danger-action { + color: color-mix(in srgb, var(--filter-danger) 82%, var(--md-default-fg-color)); +} + +.filter-field-help { + margin: 0.65rem 0; + color: color-mix(in srgb, var(--md-default-fg-color) 67%, transparent); + font-size: 0.76rem; + line-height: 1.48; +} + +.filter-live-preview { + display: grid; + gap: 0.6rem; + margin-top: 1rem; + padding: 0.8rem; + border: 1px solid var(--filter-border-strong); + border-radius: 0.48rem; + background: + linear-gradient(135deg, var(--filter-accent), transparent 62%), + var(--filter-softer); +} + +.filter-live-preview > div { + min-width: 0; +} + +.filter-preview-label { + display: block; + margin-bottom: 0.32rem; + color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent); + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.filter-live-preview code, +.filter-rule-command, +.filter-explain-results code { + display: block; + max-width: 100%; + padding: 0.58rem 0.65rem; + border: 1px solid var(--filter-border); + border-radius: 0.35rem; + color: var(--md-code-fg-color); + background: var(--md-code-bg-color); + font-size: 0.72rem; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.filter-live-preview p { + margin: 0; + font-size: 0.78rem; + line-height: 1.48; +} + +.filter-builder-actions, +.filter-rule-actions { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + margin-top: 0.75rem; +} + +.filter-rule-list { + display: grid; + gap: 0.72rem; + margin: 0.85rem 0 0; + padding: 0; + list-style: none; +} + +.filter-rule-list > li { + margin: 0; +} + +.filter-rule-card { + padding: 0.75rem; + border: 1px solid var(--filter-border); + border-radius: 0.48rem; + background: var(--filter-softer); +} + +.filter-rule-card-header { + display: flex; + gap: 0.75rem; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 0.55rem; +} + +.filter-rule-labels { + display: grid; + gap: 0.12rem; +} + +.filter-rule-labels strong { + font-size: 0.81rem; +} + +.filter-rule-labels span, +.filter-rule-meta { + color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent); + font-size: 0.66rem; +} + +.filter-rule-meta { + text-align: right; +} + +.filter-rule-card p { + margin: 0.62rem 0 0; + font-size: 0.75rem; + line-height: 1.48; +} + +.filter-rule-actions { + margin-top: 0.62rem; +} + +.filter-rule-actions button { + min-height: 1.9rem; + padding: 0.26rem 0.55rem; + font-size: 0.7rem; +} + +.filter-empty-state { + margin-top: 0.85rem; + padding: 2.2rem 1rem; + border: 1px dashed var(--filter-border-strong); + border-radius: 0.48rem; + color: color-mix(in srgb, var(--md-default-fg-color) 58%, transparent); + background: var(--filter-softer); + text-align: center; + font-size: 0.78rem; +} + +.filter-policy-summary { + margin-top: 0.85rem; + padding-top: 0.65rem; + border-top: 1px solid var(--filter-border); + color: color-mix(in srgb, var(--md-default-fg-color) 68%, transparent); + font-size: 0.7rem; + font-weight: 700; +} + +.filter-policy-target { + margin-top: 0.85rem; +} + +.filter-policy-target label { + max-width: 18rem; +} + +.filter-inline-warnings { + margin: 0.58rem 0 0 1rem; + padding: 0; + color: color-mix(in srgb, var(--filter-warning) 80%, var(--md-default-fg-color)); + font-size: 0.71rem; + line-height: 1.42; +} + +.filter-inline-warnings li + li { + margin-top: 0.2rem; +} + +.filter-import textarea, +.filter-export textarea { + min-height: 10rem; +} + +.filter-error { + margin-top: 0.85rem; + padding: 0.7rem 0.8rem; + border-left: 0.25rem solid var(--filter-danger); + border-radius: 0.25rem; + color: color-mix(in srgb, var(--filter-danger) 82%, var(--md-default-fg-color)); + background: color-mix(in srgb, var(--filter-danger) 10%, transparent); + font-size: 0.76rem; +} + +.filter-simulator-facts { + margin-top: 0.85rem; +} + +.filter-tool .filter-simulator-check { + display: flex; + gap: 0.55rem; + align-items: center; + align-self: end; + min-height: 2.35rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--filter-border); + border-radius: 0.38rem; + background: var(--filter-soft); + cursor: pointer; +} + +.filter-tool .filter-simulator-check input { + flex: 0 0 auto; + width: 1rem; + height: 1rem; + min-height: 1rem; + margin: 0; + padding: 0; +} + +.filter-decision { + margin-top: 1rem; + padding: 0.85rem; + border: 1px solid var(--filter-border-strong); + border-radius: 0.48rem; + background: var(--filter-softer); +} + +.filter-decision > h3:first-child { + margin-top: 0; +} + +.filter-decision-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: 0.55rem; +} + +.filter-decision-grid > div { + display: grid; + gap: 0.15rem; + padding: 0.58rem; + border: 1px solid var(--filter-border); + border-radius: 0.35rem; + background: var(--md-code-bg-color); +} + +.filter-decision-grid span { + color: color-mix(in srgb, var(--md-default-fg-color) 58%, transparent); + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.filter-decision-grid strong { + font-size: 0.72rem; + overflow-wrap: anywhere; +} + +.filter-trace { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.filter-trace li { + display: grid; + gap: 0.18rem; + margin: 0; + padding: 0.58rem 0.65rem; + border-left: 0.22rem solid var(--filter-border-strong); + border-radius: 0.28rem; + background: var(--filter-soft); + font-size: 0.72rem; + line-height: 1.42; +} + +.filter-trace li strong { + font-size: 0.7rem; + text-transform: uppercase; +} + +.filter-trace-match { + border-left-color: #2e7d32 !important; +} + +.filter-trace-shadow { + border-left-color: var(--filter-warning) !important; +} + +.filter-trace-miss, +.filter-trace-disabled, +.filter-trace-stopped { + opacity: 0.72; +} + +.filter-explain-results { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--filter-border); +} + +.filter-explain-results h3 { + margin-top: 0; +} + +.filter-explain-results ol { + display: grid; + gap: 0.75rem; + margin: 0; + padding-left: 1.4rem; +} + +.filter-explain-results li { + padding-left: 0.2rem; +} + +.filter-explain-results p { + margin: 0.45rem 0 0; + font-size: 0.76rem; + line-height: 1.48; +} + +.filter-export-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0.85rem 0; + padding-bottom: 0.65rem; + border-bottom: 1px solid var(--filter-border); +} + +.filter-export-tabs button[aria-selected="true"] { + border-color: var(--md-primary-fg-color); + color: var(--md-primary-bg-color); + background: var(--md-primary-fg-color); +} + +@media (max-width: 62rem) { + .filter-tool-grid { + grid-template-columns: 1fr; + } + + .filter-tool-grid .filter-panel + .filter-panel { + margin-top: 0; + } +} + +@media (max-width: 44rem) { + .filter-panel { + padding: 0.75rem; + } + + .filter-form-grid { + grid-template-columns: 1fr; + } + + .filter-panel-heading, + .filter-rule-card-header { + align-items: stretch; + flex-direction: column; + } + + .filter-rule-meta { + text-align: left; + } + + .filter-tool-toolbar button, + .filter-builder-actions button { + flex: 1 1 auto; + } +} diff --git a/docs/filter_tool.md b/docs/filter_tool.md new file mode 100644 index 00000000..808e5f1c --- /dev/null +++ b/docs/filter_tool.md @@ -0,0 +1,491 @@ +# Filter policy playground + +Design and test policies for the proposed ground-up MeshCore forwarding engine. +The playground models phased evaluation, immutable receive-time matches, +explicit priority and stop behavior, ACL ownership, compact typed conditions, +and accumulated forwarding decisions. + +Everything runs locally in this browser. Channel keys, packet facts, and policy +drafts are not uploaded anywhere. + +
+ Engine design preview +

+ This page targets the new policy-engine idea, not today's FPF7 file or + existing set flood.* commands. Its readable policy language, + JSON, and Base64 bundle are a prototype for design testing. Current + firmware cannot install these policies yet. +

+
+ +## Build a policy + +
+
+ Load an example: + + + + + +
+ +
+
+
+
+

Rule builder

+

Create a policy rule

+
+ +
+ +

Identity and execution

+
+ + + + + + +
+ +

Match immutable receive-time facts

+
+ + + + + + + + + + + +
+ +

Accumulate forwarding actions

+
+ + + + + + + + + + + +
+ +
+
+ Readable policy definition + +
+

+ +
+ +
+ + +
+
+ +
+
+
+

Policy draft

+

Rules in execution order

+
+ +
+
+ +
+

+ Rules match the same immutable packet facts. Ordering is phase, then + descending priority, then stable rule ID. Drop decisions are sticky. +

+
+ Add a rule or load an example to start exploring. +
+
    +
    + +
    +
    + +
    +
    +
    +

    Immutable packet simulator

    +

    Explain an evaluation

    +
    + +
    +
    + + + + + + + + + + + + + + +
    +
    + +
    + + +
    + +
    +
    +
    +

    Import and explain

    +

    Paste readable policy or a saved draft

    +
    +
    +

    + Accepts one-line policy set ... when ... do ... definitions, + playground JSON, or a playground Base64 bundle. +

    + +
    + + +
    + + +
    + +
    +
    +
    +

    Export

    +

    Move or save this design

    +
    +
    +
    + + + +
    +
    +

    Proposed human-readable definition. Current firmware does not accept it yet.

    + +
    + + +
    + + +
    +
    +
    + +## Proposed evaluation contract + +The simulator uses these rules: + +1. Every matcher reads the same immutable receive-time packet facts. +2. Rules run by phase, descending priority, then stable rule ID. +3. A drop decision is sticky and cannot be undone by a later rule. +4. The first matching scope, timing, queue, and retry action in execution order + wins. +5. All matching token-bucket rate constraints remain attached to the decision. +6. `stop=phase` skips later rules in that phase. `stop=policy` skips later + configurable rules, but never mandatory packet validation or radio safety. +7. Shadow rules report what they would do without changing the decision or + stopping other rules. +8. Expensive facts such as channel authentication, decryption, and path-table + lookup are resolved once per packet and reused by every matching rule. + +The byte-budget display is deliberately approximate until the packed firmware +codec exists. It demonstrates why simple mappings should not reserve a maximum- +sized structure for every possible condition and action. diff --git a/docs/index.md b/docs/index.md index ab3d906d..d801804e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,7 @@ Below are a few quick start guides. - [Frequently Asked Questions](./faq.md) - [CLI Commands](./cli_commands.md) +- [Filter Policy Playground](./filter_tool.md) - [Telemetry History Decoder](./telemetry_decoder.md) - [CLI Availability by Firmware Build](./cli_build_matrix.md) - [Easy LoRa OTA: ESP32 full images and nRF52 deltas](./ota_easy.md) diff --git a/mkdocs.yml b/mkdocs.yml index 710b9b28..4910da67 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,8 @@ theme: extra_css: - _stylesheets/extra.css - _stylesheets/telemetry_decoder.css + - _stylesheets/filter_tool.css extra_javascript: - _javascript/telemetry_decoder.js + - _javascript/filter_tool.js diff --git a/scripts/test_filter_tool.js b/scripts/test_filter_tool.js new file mode 100644 index 00000000..123564e4 --- /dev/null +++ b/scripts/test_filter_tool.js @@ -0,0 +1,246 @@ +"use strict"; + +const assert = require("assert"); +const tool = require("../docs/_javascript/filter_tool.js"); + +let passed = 0; + +function test(name, callback) { + callback(); + passed += 1; + process.stdout.write(`ok ${passed} - ${name}\n`); +} + +function assertToolError(callback, pattern) { + assert.throws(callback, (error) => error instanceof tool.FilterToolError && pattern.test(error.message)); +} + +function packet(overrides) { + return { + route: "unscoped_flood", + type: "grp_data", + hops: 4, + channel: "#rgdata", + path: "860C,12A4", + scopeStatus: "none", + scopeName: "", + regionName: "", + sender: "", + tempRadio: false, + snr: 6, + blacklist: false, + buckets: [], + loopLevel: 0, + ...overrides, + }; +} + +test("builds and parses the BlackHole86 policy definition", () => { + const definition = "policy set blackhole-rewrite phase=rewrite owner=scope priority=160 when route=flood type=grp_data hops=4+ channel=#rgdata rx.scope=none path=prefix:860C do scope=#BlackHole86 timing=fast stop=phase"; + const rule = tool.parseDefinition(definition); + assert.strictEqual(rule.targetKind, "scope"); + assert.strictEqual(rule.target, "BlackHole86"); + assert.strictEqual(tool.buildDefinition(rule), definition); +}); + +test("keeps a payload class and path bucket in one rule", () => { + const definition = "policy set other-bucket phase=rewrite owner=scope priority=130 when route=flood type=class:other hops=all path=bucket:2 do scope=#BlackHole86 timing=slow"; + const rule = tool.parseDefinition(definition); + assert.strictEqual(rule.type, "class:other"); + assert.strictEqual(rule.pathKind, "bucket:2"); + assert.strictEqual(tool.buildDefinition(rule), definition); + assert.match(tool.explainRule(rule), /catch-all non-group, non-login class/); +}); + +test("enforces ACL 4 scope-manager boundaries", () => { + assertToolError( + () => tool.parseDefinition("policy set bad-drop phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do drop"), + /ACL 4 cannot create general drop/ + ); + assertToolError( + () => tool.parseDefinition("policy set bad-stop phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do scope=#x stop=policy"), + /ACL 4 may stop only its current phase/ + ); +}); + +test("enforces ACL 5 filter-manager boundaries", () => { + assertToolError( + () => tool.parseDefinition("policy set bad-region phase=rewrite owner=filter priority=1 when route=flood type=any hops=all do region=usa"), + /ACL 5 cannot select a configured region target/ + ); + const rule = tool.parseDefinition( + "policy set public-scope phase=rewrite owner=filter priority=1 when route=flood type=any hops=all do scope=#BlackHole86" + ); + assert.strictEqual(rule.targetKind, "scope"); +}); + +test("limits decrypted sender matching to the content phase", () => { + assertToolError( + () => tool.parseDefinition('policy set early-sender phase=forward owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy User" do drop'), + /only in the content phase/ + ); + const definition = 'policy set noisy-user phase=content owner=filter priority=150 when route=flood type=grp_txt hops=all channel=public sender="Noisy User" do rate=5/min burst=5 tag=public-rate'; + assert.strictEqual(tool.buildDefinition(tool.parseDefinition(definition)), definition); + const quoted = 'policy set quoted-user phase=content owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy \\"User\\"" do drop'; + assert.strictEqual(tool.buildDefinition(tool.parseDefinition(quoted)), quoted); +}); + +test("round trips token rate and burst actions", () => { + const rule = tool.parseDefinition( + "policy set rate-limit phase=forward owner=filter priority=90 when route=flood type=grp_data hops=3+ channel=#rgdata do rate=10/min burst=3" + ); + assert.strictEqual(rule.rate, 10); + assert.strictEqual(rule.burst, 3); + assert.strictEqual(tool.buildDefinition(rule).endsWith("rate=10/min burst=3"), true); + assertToolError( + () => tool.parseDefinition("policy set zero-rate phase=forward owner=filter priority=1 when route=flood type=any hops=all do rate=0/min"), + /Token rate must be 1-65534/ + ); +}); + +test("encodes and decodes a complete playground bundle", () => { + const rules = tool.EXAMPLES.mixed.map(tool.normalizeRule); + const bundle = tool.encodeBundle(rules); + assert.ok(bundle.startsWith(tool.BUNDLE_PREFIX)); + assert.deepStrictEqual(tool.decodeBundle(bundle), tool.sortedRules(rules)); + assert.deepStrictEqual(tool.parsePolicyInput(bundle), tool.sortedRules(rules)); +}); + +test("imports JSON and multiple readable definitions", () => { + const rules = tool.EXAMPLES.blackhole.map(tool.normalizeRule); + const json = JSON.stringify(tool.policyDocument(rules)); + assert.deepStrictEqual(tool.parsePolicyInput(json), rules); + const readable = [ + "# a local note", + tool.buildDefinition(tool.EXAMPLES.blackhole[0]), + tool.buildDefinition(tool.EXAMPLES.system[0]), + ].join("\n"); + assert.strictEqual(tool.parsePolicyInput(readable).length, 2); +}); + +test("orders by phase, descending priority, and stable ASCII ID", () => { + const definitions = [ + "policy set z-last phase=forward owner=filter priority=20 when route=flood type=any hops=all do tag=z", + "policy set rewrite-first phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do scope=#x", + "policy set b-middle phase=forward owner=filter priority=30 when route=flood type=any hops=all do tag=b", + "policy set A-first phase=forward owner=filter priority=20 when route=flood type=any hops=all do tag=a", + ]; + assert.deepStrictEqual( + tool.sortedRules(definitions.map(tool.parseDefinition)).map((rule) => rule.id), + ["rewrite-first", "b-middle", "A-first", "z-last"] + ); +}); + +test("matches every rule against immutable receive-time scope", () => { + const rules = [ + tool.parseDefinition("policy set rewrite phase=rewrite owner=scope priority=200 when route=flood type=grp_data hops=all rx.scope=none do scope=#BlackHole86"), + tool.parseDefinition("policy set original-none phase=forward owner=filter priority=100 when route=flood type=grp_data hops=all rx.scope=none do drop"), + ]; + const result = tool.simulatePolicy(rules, packet()); + assert.deepStrictEqual(result.decision.scopeTarget, { kind: "scope", name: "BlackHole86", rule: "rewrite" }); + assert.strictEqual(result.decision.drop, true); + assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "match"]); + assert.strictEqual(result.packet.scopeStatus, "none"); +}); + +test("stop=phase skips lower rules only in that phase", () => { + const rules = [ + tool.parseDefinition("policy set first phase=forward owner=filter priority=200 when route=flood type=any hops=all do tag=first stop=phase"), + tool.parseDefinition("policy set skipped phase=forward owner=filter priority=100 when route=flood type=any hops=all do drop"), + tool.parseDefinition("policy set later phase=content owner=filter priority=100 when route=flood type=any hops=all do tag=later"), + ]; + const result = tool.simulatePolicy(rules, packet()); + assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "stopped", "match"]); + assert.deepStrictEqual(result.decision.tags, ["first", "later"]); + assert.strictEqual(result.decision.drop, false); +}); + +test("stop=policy skips every later configurable phase", () => { + const rules = [ + tool.parseDefinition("policy set first phase=forward owner=admin priority=200 when route=flood type=any hops=all do tag=first stop=policy"), + tool.parseDefinition("policy set skipped phase=content owner=filter priority=100 when route=flood type=any hops=all do drop"), + ]; + const result = tool.simulatePolicy(rules, packet()); + assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "stopped"]); + assert.strictEqual(result.decision.drop, false); +}); + +test("shadow rules neither act nor stop later rules", () => { + const rules = [ + tool.parseDefinition("policy set shadow-drop phase=forward owner=admin priority=200 mode=shadow when route=flood type=any hops=all do drop stop=policy"), + tool.parseDefinition("policy set active-drop phase=forward owner=filter priority=100 when route=flood type=any hops=all do drop"), + ]; + const result = tool.simulatePolicy(rules, packet()); + assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["shadow", "match"]); + assert.strictEqual(result.decision.drop, true); +}); + +test("drop is sticky while later non-verdict actions still accumulate", () => { + const rules = [ + tool.parseDefinition("policy set drop-first phase=forward owner=filter priority=200 when route=flood type=any hops=all do drop"), + tool.parseDefinition("policy set tag-later phase=forward owner=filter priority=100 when route=flood type=any hops=all do tag=observed"), + ]; + const result = tool.simulatePolicy(rules, packet()); + assert.strictEqual(result.decision.drop, true); + assert.deepStrictEqual(result.decision.tags, ["observed"]); +}); + +test("scope-gate require drops an originally unscoped packet", () => { + const rule = tool.parseDefinition( + "policy set require-region phase=scope_gate owner=scope priority=200 when route=flood type=class:group hops=all do scope-gate=require stop=phase" + ); + const result = tool.simulatePolicy([rule], packet()); + assert.strictEqual(result.decision.scopeGate, "require_allowed"); + assert.strictEqual(result.decision.drop, true); + assert.match(result.trace[0].detail, /scope requirement failed/); +}); + +test("matches login and future-safe other payload classes", () => { + const login = tool.parseDefinition("policy set login phase=forward owner=filter priority=1 when route=flood type=class:login hops=all do drop"); + const other = tool.parseDefinition("policy set other phase=forward owner=filter priority=1 when route=flood type=class:other hops=all do drop"); + assert.strictEqual(tool.matchRule(login, packet({ type: "req", channel: "" })).matched, true); + assert.strictEqual(tool.matchRule(login, packet({ type: "ota", channel: "" })).matched, false); + assert.strictEqual(tool.matchRule(other, packet({ type: "ota", channel: "" })).matched, true); + assert.strictEqual(tool.matchRule(other, packet({ type: "grp_data" })).matched, false); + assertToolError(() => tool.normalizePacket(packet({ type: "class:group", channel: "" })), /exact known type/); + assertToolError(() => tool.normalizePacket(packet({ type: "req" })), /authenticated channel/); +}); + +test("matches ordered pbyte prefixes, buckets, and loop thresholds", () => { + const prefix = tool.parseDefinition("policy set path-prefix phase=forward owner=filter priority=1 when route=flood type=any hops=all path=prefix:86,0C do drop"); + const bucket = tool.parseDefinition("policy set path-bucket phase=forward owner=filter priority=1 when route=flood type=any hops=all path=bucket:2 do drop"); + const moderate = tool.parseDefinition("policy set loop phase=forward owner=filter priority=1 when route=flood type=any hops=all path=loop:moderate do drop"); + const facts = packet({ channel: "", path: "86,0C,AA", buckets: [2], loopLevel: 2 }); + assert.strictEqual(tool.matchRule(prefix, facts).matched, true); + assert.strictEqual(tool.matchRule(bucket, facts).matched, true); + assert.strictEqual(tool.matchRule(moderate, facts).matched, true); + assertToolError( + () => tool.parseDefinition("policy set mixed-width phase=forward owner=filter priority=1 when route=flood type=any hops=all path=prefix:86,1234 do drop"), + /same 2, 4, or 6 hex-character width/ + ); +}); + +test("reports duplicate IDs, rewrite conflicts, and target overflow", () => { + const first = tool.parseDefinition("policy set duplicate phase=rewrite owner=scope priority=100 when route=flood type=any hops=all do scope=#one"); + const second = tool.parseDefinition("policy set duplicate phase=rewrite owner=scope priority=100 when route=flood type=any hops=all do scope=#two"); + const duplicates = tool.policyWarnings([first, second], "nrf52"); + assert.ok(duplicates.some((warning) => /duplicated/.test(warning))); + assert.ok(duplicates.some((warning) => /overlapping core matches/.test(warning))); + + const large = Array.from({ length: 90 }, (_unused, index) => tool.parseDefinition( + `policy set size-${index} phase=forward owner=filter priority=1 when route=flood type=any hops=all do tag=tag-${index}` + )); + assert.ok(tool.policyWarnings(large, "stm32").some((warning) => /exceeds/.test(warning))); +}); + +test("flags lockout, future-type, spoofing, and policy-stop risks", () => { + const broad = tool.parseDefinition("policy set broad phase=forward owner=admin priority=255 when route=any type=any hops=all do drop stop=policy"); + const other = tool.parseDefinition("policy set future phase=forward owner=filter priority=1 when route=flood type=class:other hops=all do drop"); + const sender = tool.parseDefinition('policy set sender phase=content owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy User" do drop'); + assert.ok(tool.ruleWarnings(broad).some((warning) => /remote administration/.test(warning))); + assert.ok(tool.ruleWarnings(broad).some((warning) => /mandatory protocol/.test(warning))); + assert.ok(tool.ruleWarnings(other).some((warning) => /future types/.test(warning))); + assert.ok(tool.ruleWarnings(sender).some((warning) => /spoofable/.test(warning))); +}); + +process.stdout.write(`# ${passed} filter-policy playground tests passed\n`);