Files
sub-store/internal/rules/converter.go
T

251 lines
5.4 KiB
Go

package rules
import (
"regexp"
"strings"
)
// RuleTarget is the target format for rule conversion.
type RuleTarget string
const (
TargetMihomo RuleTarget = "mihomo"
TargetSurge RuleTarget = "surge"
TargetLoon RuleTarget = "loon"
TargetQX RuleTarget = "qx"
)
// Result holds the output of a rule conversion.
type Result struct {
Content string
Parsed int
Emitted int
Skipped int
Warnings []string
}
// NormalizedRule is a parsed and normalized rule.
type NormalizedRule struct {
Kind string
Value string
Policy string
Options []string
}
var kindAliases = map[string]string{
"HOST": "DOMAIN",
"DOMAIN": "DOMAIN",
"HOST-SUFFIX": "DOMAIN-SUFFIX",
"DOMAIN-SUFFIX": "DOMAIN-SUFFIX",
"HOST-KEYWORD": "DOMAIN-KEYWORD",
"DOMAIN-KEYWORD": "DOMAIN-KEYWORD",
"IP6CIDR": "IP-CIDR6",
"IP-CIDR6": "IP-CIDR6",
"IPCIDR": "IP-CIDR",
"IP-CIDR": "IP-CIDR",
"GEOIP": "GEOIP",
"GEOSITE": "GEOSITE",
"PROCESS-NAME": "PROCESS-NAME",
"PROCESS": "PROCESS-NAME",
"DEST-PORT": "DST-PORT",
"DST-PORT": "DST-PORT",
"FINAL": "MATCH",
"MATCH": "MATCH",
}
var ruleOptions = map[string]bool{
"no-resolve": true,
"extended-matching": true,
"pre-matching": true,
}
var yamlHeaderRe = regexp.MustCompile(`(?m)^(?:payload|rules)\s*:`)
// ConvertRules converts rule content to the target format.
func ConvertRules(content string, target RuleTarget) Result {
parsed := parseRules(content)
var lines []string
for _, rule := range parsed {
line := produceRule(rule, target)
if line != "" {
lines = append(lines, line)
}
}
return Result{
Content: strings.Join(lines, "\n"),
Parsed: len(parsed),
Emitted: len(lines),
Skipped: 0,
Warnings: []string{},
}
}
func parseRules(content string) []NormalizedRule {
lines := extractRuleLines(content)
var rules []NormalizedRule
for _, line := range lines {
rule := parseRuleLine(line)
if rule != nil {
rules = append(rules, *rule)
}
}
return rules
}
func extractRuleLines(content string) []string {
text := strings.TrimSpace(content)
if text == "" {
return []string{}
}
if yamlHeaderRe.MatchString(text) {
var lines []string
for _, line := range strings.Split(text, "\n")[1:] {
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "-")
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
lines = append(lines, line)
}
}
return lines
}
var lines []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
// Strip surrounding quotes
if len(line) >= 2 && (line[0] == '"' || line[0] == '\'') && line[len(line)-1] == line[0] {
line = line[1 : len(line)-1]
}
if line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, ";") {
lines = append(lines, line)
}
}
return lines
}
func parseRuleLine(line string) *NormalizedRule {
fields := splitCsv(line)
if len(fields) == 0 {
return nil
}
kind := kindAliases[strings.ToUpper(fields[0])]
if kind == "" {
return nil
}
if kind == "MATCH" {
policy := ""
if len(fields) > 1 {
policy = fields[1]
}
var options []string
for _, f := range fields[2:] {
if f != "" {
options = append(options, f)
}
}
return &NormalizedRule{Kind: kind, Policy: policy, Options: options}
}
if len(fields) < 2 {
return nil
}
value := strings.TrimSpace(fields[1])
if value == "" {
return nil
}
var trailing []string
for _, f := range fields[2:] {
if f != "" {
trailing = append(trailing, f)
}
}
policy := ""
var options []string
for _, f := range trailing {
if ruleOptions[strings.ToLower(f)] {
options = append(options, f)
} else {
if policy == "" {
policy = f
}
}
}
return &NormalizedRule{Kind: kind, Value: value, Policy: policy, Options: options}
}
func produceRule(rule NormalizedRule, target RuleTarget) string {
kind := rule.Kind
if target == TargetQX {
kind = qxKind(rule.Kind)
}
var fields []string
if rule.Kind == "MATCH" {
if target == TargetQX {
fields = append(fields, "FINAL")
} else {
fields = append(fields, kind)
}
if rule.Policy != "" {
fields = append(fields, rule.Policy)
}
} else {
fields = append(fields, kind, rule.Value)
if rule.Policy != "" {
fields = append(fields, rule.Policy)
}
}
for _, opt := range rule.Options {
if target == TargetQX {
if strings.ToLower(opt) == "no-resolve" {
fields = append(fields, opt)
}
} else {
fields = append(fields, opt)
}
}
return strings.Join(fields, ",")
}
func qxKind(kind string) string {
aliases := map[string]string{
"DOMAIN": "HOST",
"DOMAIN-SUFFIX": "HOST-SUFFIX",
"DOMAIN-KEYWORD": "HOST-KEYWORD",
"IP-CIDR": "IP-CIDR",
"IP-CIDR6": "IP6-CIDR",
"PROCESS-NAME": "PROCESS-NAME",
"DST-PORT": "DEST-PORT",
"MATCH": "FINAL",
}
if a, ok := aliases[kind]; ok {
return a
}
return kind
}
func splitCsv(line string) []string {
var output []string
current := ""
quote := byte(0)
for i := 0; i < len(line); i++ {
c := line[i]
if quote != 0 {
if c == quote {
quote = 0
} else {
current += string(c)
}
} else if c == '"' || c == '\'' {
quote = c
} else if c == ',' {
output = append(output, strings.TrimSpace(current))
current = ""
} else {
current += string(c)
}
}
if strings.TrimSpace(current) != "" {
output = append(output, strings.TrimSpace(current))
}
return output
}