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 }