Files
OpenGFW/web/frontend/src/lib/rule/compile.ts
T

114 lines
4.2 KiB
TypeScript
Raw Normal View History

import { FIELD_MAP, type FieldDef, type OperatorId } from './fields'
import type { Condition, RuleBuilderState } from './types'
/** Quotes a value as an expr string literal. */
export function quote(value: string): string {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g
/** Converts a `*.example.com` style pattern into an anchored regular expression. */
export function wildcardToRegex(pattern: string): string {
return `^${pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*')}$`
}
export interface WildcardExplain {
kind: 'exact' | 'suffix' | 'prefix' | 'contains' | 'regex' | 'any'
/** The meaningful part of the pattern, e.g. ".example.com" for "*.example.com". */
value: string
}
/** Describes how a wildcard pattern will be matched, for the inline hint. */
export function explainWildcard(pattern: string): WildcardExplain {
const p = pattern.trim()
if (p === '*') return { kind: 'any', value: '' }
if (!p.includes('*')) return { kind: 'exact', value: p }
const starts = p.startsWith('*')
const ends = p.endsWith('*')
const core = p.replace(/^\*/, '').replace(/\*$/, '')
if (core.includes('*')) return { kind: 'regex', value: wildcardToRegex(p) }
if (starts && ends) return { kind: 'contains', value: core }
if (starts) return { kind: 'suffix', value: core }
if (ends) return { kind: 'prefix', value: core }
return { kind: 'regex', value: wildcardToRegex(p) }
}
function wildcardTerm(subject: string, pattern: string): string {
const info = explainWildcard(pattern)
switch (info.kind) {
case 'any':
return `${subject} != ""`
case 'exact':
return `${subject} == ${quote(info.value)}`
case 'suffix':
return `${subject} endsWith ${quote(info.value)}`
case 'prefix':
return `${subject} startsWith ${quote(info.value)}`
case 'contains':
return `${subject} contains ${quote(info.value)}`
case 'regex':
return `${subject} matches ${quote(info.value)}`
}
}
/** Builds the expression for one value of a condition. */
function term(field: FieldDef, op: OperatorId, subject: string, value: string): string {
const v = value.trim()
switch (op) {
case 'is':
return field.type === 'port' ? `${subject} == ${v}` : `${subject} == ${quote(v)}`
case 'subdomain':
return `(${subject} == ${quote(v)} || ${subject} endsWith ${quote('.' + v)})`
case 'wildcard':
return wildcardTerm(subject, v)
case 'contains':
case 'startsWith':
case 'endsWith':
return `${subject} ${op} ${quote(v)}`
case 'regex':
return `${subject} matches ${quote(v)}`
case 'geosite':
return `geosite(${subject}, ${quote(v.toLowerCase())})`
case 'cidr':
return `cidr(${subject}, ${quote(v)})`
case 'geoip':
return `geoip(${subject}, ${quote(v.toLowerCase())})`
case 'detected':
return `${v} != nil`
case 'range':
return ''
}
}
/** Compiles a single condition into an expression, or "" when incomplete. */
export function compileCondition(condition: Condition): string {
const field = FIELD_MAP[condition.field]
if (!field) return ''
const values = condition.values.map((v) => v.trim()).filter(Boolean)
let body: string
if (condition.op === 'range') {
const [from, to] = condition.values
if (!from?.trim() || !to?.trim()) return ''
body = `(${field.expr} >= ${from.trim()} && ${field.expr} <= ${to.trim()})`
} else {
if (values.length === 0) return ''
const subject = field.iter ? (field.item as string) : (field.expr as string)
const terms = values.map((v) => term(field, condition.op, subject, v))
const joined = terms.length > 1 ? `(${terms.join(' || ')})` : terms[0]
body = field.iter ? `any(${field.iter}, {${joined}})` : joined
}
if (!condition.negate) return body
return body.startsWith('(') && body.endsWith(')') ? `!${body}` : `!(${body})`
}
/** Compiles the whole builder state into a rule expression. */
export function compileRule(state: RuleBuilderState): string {
const parts = state.conditions.map(compileCondition).filter(Boolean)
if (parts.length === 0) return ''
if (parts.length === 1) return parts[0]
return parts.join(state.mode === 'and' ? ' && ' : ' || ')
}