Fix panic and rule loss in list parsing/resolution (#3905)

* Fix panic on empty inclusion attribute, rule loss in selective inclusion and other bugs

* Exercise domain/list-name validators in tests

* Consolidate parsed list state into ParsedList and simplify inclusion parsing

Co-authored-by: Loyalsoldier <10487845+Loyalsoldier@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Loyalsoldier <10487845+Loyalsoldier@users.noreply.github.com>
This commit is contained in:
Copilot 2026-08-04 18:22:31 +08:00 committed by GitHub
parent 4eb9b28041
commit b49404a24e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 277 additions and 52 deletions

View File

@ -143,20 +143,26 @@ func run() error {
exportListSlice = []string{"_all_"} exportListSlice = []string{"_all_"}
} }
failedCount := 0
for _, eplistname := range exportListSlice { for _, eplistname := range exportListSlice {
if strings.EqualFold(eplistname, "_all_") { if strings.EqualFold(eplistname, "_all_") {
if err := exportAll(filepath.Base(*inputData)+"_plain.yml", geoSites); err != nil { if err := exportAll(filepath.Base(*inputData)+"_plain.yml", geoSites); err != nil {
fmt.Printf("[Error] failed to exportAll: %v\n", err) fmt.Printf("[Error] failed to exportAll: %v\n", err)
failedCount++
continue continue
} }
} else { } else {
if err := exportSite(eplistname, geoSites); err != nil { if err := exportSite(eplistname, geoSites); err != nil {
fmt.Printf("[Error] failed to exportSite: %v\n", err) fmt.Printf("[Error] failed to exportSite: %v\n", err)
failedCount++
continue continue
} }
} }
fmt.Printf("list: %q has been exported successfully\n", eplistname) fmt.Printf("list: %q has been exported successfully\n", eplistname)
} }
if failedCount > 0 {
return fmt.Errorf("%d list(s) failed to be exported", failedCount)
}
return nil return nil
} }

118
main.go
View File

@ -38,15 +38,17 @@ type Inclusion struct {
} }
type ParsedList struct { type ParsedList struct {
Resolved bool
Inclusions []*Inclusion Inclusions []*Inclusion
Entries []*Entry Entries []*Entry // Entries parsed from the list itself
// The fields below are filled in by resolveList
Resolving bool
Resolved bool
RoughEntries map[string]*Entry // Deduplicated direct and included entries
FinalEntries []*Entry // Sorted entries without redundant subdomains
} }
type Processor struct { type Processor struct {
plMap map[string]*ParsedList parsedListByName map[string]*ParsedList
finalMap map[string][]*Entry
cirIncMap map[string]bool
} }
type GeoSites struct { type GeoSites struct {
@ -136,11 +138,12 @@ func (gs *GeoSites) assembleDat(task DatTask) error {
return fmt.Errorf("list %q not found for allowlist task", list) return fmt.Errorf("list %q not found for allowlist task", list)
} }
} }
slices.Sort(allowedIdxes)
allowedIdxes = slices.Compact(allowedIdxes) // Avoid duplicated lists
allowedlen := len(allowedIdxes) allowedlen := len(allowedIdxes)
if allowedlen == 0 { if allowedlen == 0 {
return fmt.Errorf("allowlist needs at least one valid list") return fmt.Errorf("allowlist needs at least one valid list")
} }
slices.Sort(allowedIdxes)
geoSiteList.Entry = make([]*router.GeoSite, allowedlen) geoSiteList.Entry = make([]*router.GeoSite, allowedlen)
for i, idx := range allowedIdxes { for i, idx := range allowedIdxes {
geoSiteList.Entry[i] = gs.Sites[idx] geoSiteList.Entry[i] = gs.Sites[idx]
@ -151,12 +154,12 @@ func (gs *GeoSites) assembleDat(task DatTask) error {
if idx, ok := gs.SiteIdx[strings.ToUpper(list)]; ok { if idx, ok := gs.SiteIdx[strings.ToUpper(list)]; ok {
deniedMap[idx] = true deniedMap[idx] = true
} else { } else {
fmt.Printf("[Warn] list %q not found in denylist task %q", list, task.Name) fmt.Printf("[Warn] list %q not found in denylist task %q\n", list, task.Name)
} }
} }
deniedlen := len(deniedMap) deniedlen := len(deniedMap)
if deniedlen == 0 { if deniedlen == 0 {
fmt.Printf("[Warn] nothing to deny in task %q", task.Name) fmt.Printf("[Warn] nothing to deny in task %q\n", task.Name)
geoSiteList.Entry = gs.Sites geoSiteList.Entry = gs.Sites
} else { } else {
geoSiteList.Entry = make([]*router.GeoSite, 0, len(gs.Sites)-deniedlen) geoSiteList.Entry = make([]*router.GeoSite, 0, len(gs.Sites)-deniedlen)
@ -237,7 +240,8 @@ func parseEntry(typ, rule string) (*Entry, []string, error) {
} }
} }
slices.Sort(entry.Attrs) // Sort attributes slices.Sort(entry.Attrs) // Sort attributes
entry.Attrs = slices.Compact(entry.Attrs) // Remove duplicated attributes
// Formated plain entry: type:domain.tld:@attr1,@attr2 // Formated plain entry: type:domain.tld:@attr1,@attr2
var plain strings.Builder var plain strings.Builder
plain.Grow(plen) plain.Grow(plen)
@ -272,8 +276,7 @@ func parseInclusion(rule string) (*Inclusion, error) {
switch part[0] { switch part[0] {
case '@': case '@':
attr := strings.ToLower(part[1:]) attr := strings.ToLower(part[1:])
if attr[0] == '-' { if battr, ok := strings.CutPrefix(attr, "-"); ok {
battr := attr[1:]
if !validateAttrChars(battr) { if !validateAttrChars(battr) {
return inc, fmt.Errorf("invalid ban attribute: %q", battr) return inc, fmt.Errorf("invalid ban attribute: %q", battr)
} }
@ -336,10 +339,10 @@ func validateSiteName(name string) bool {
} }
func (p *Processor) getOrCreateParsedList(name string) *ParsedList { func (p *Processor) getOrCreateParsedList(name string) *ParsedList {
pl, exist := p.plMap[name] pl, exist := p.parsedListByName[name]
if !exist { if !exist {
pl = &ParsedList{Resolved: false} pl = new(ParsedList)
p.plMap[name] = pl p.parsedListByName[name] = pl
} }
return pl return pl
} }
@ -457,49 +460,55 @@ func polishList(roughMap map[string]*Entry) []*Entry {
return finalList return finalList
} }
func (p *Processor) resolveList(plname string) error { // resolveList resolves the inclusions of the named list and returns it.
pl, ok := p.plMap[plname] func (p *Processor) resolveList(plname string) (*ParsedList, error) {
pl, ok := p.parsedListByName[plname]
if !ok { if !ok {
return fmt.Errorf("list %q not found", plname) return nil, fmt.Errorf("list %q not found", plname)
} }
if pl.Resolved { if pl.Resolved {
return nil return pl, nil
} }
if p.cirIncMap[plname] { if pl.Resolving {
return fmt.Errorf("circular inclusion in: %q", plname) return nil, fmt.Errorf("circular inclusion in: %q", plname)
} }
p.cirIncMap[plname] = true pl.Resolving = true
defer delete(p.cirIncMap, plname) defer func() { pl.Resolving = false }()
roughMap := make(map[string]*Entry) // Avoid basic duplicates roughEntries := make(map[string]*Entry) // Avoid basic duplicates
for _, dentry := range pl.Entries { // Add direct entries for _, dentry := range pl.Entries { // Add direct entries
roughMap[dentry.Plain] = dentry roughEntries[dentry.Plain] = dentry
} }
for _, inc := range pl.Inclusions { // Add included entries for _, inc := range pl.Inclusions { // Add included entries
if err := p.resolveList(inc.Source); err != nil { ipl, err := p.resolveList(inc.Source)
return fmt.Errorf("failed to resolve inclusion %q: %w", inc.Source, err) if err != nil {
return nil, fmt.Errorf("failed to resolve inclusion %q: %w", inc.Source, err)
} }
isFullInc := len(inc.MustAttrs) == 0 && len(inc.BanAttrs) == 0 isFullInc := len(inc.MustAttrs) == 0 && len(inc.BanAttrs) == 0
for _, ientry := range p.finalMap[inc.Source] { // Filter the unpolished entries of the source list, otherwise selective
// inclusion would lose rules that have been pruned in the source list as
// redundant subdomains of a parent rule which is filtered out here.
for _, ientry := range ipl.RoughEntries {
if isFullInc || isMatchAttrFilters(ientry, inc) { if isFullInc || isMatchAttrFilters(ientry, inc) {
roughMap[ientry.Plain] = ientry roughEntries[ientry.Plain] = ientry
} }
} }
} }
if len(roughMap) == 0 { pl.RoughEntries = roughEntries
if len(roughEntries) == 0 {
fmt.Printf("[Warn] ignore empty list %q\n", plname) fmt.Printf("[Warn] ignore empty list %q\n", plname)
} else { } else {
p.finalMap[plname] = polishList(roughMap) pl.FinalEntries = polishList(roughEntries)
} }
pl.Resolved = true pl.Resolved = true
return nil return pl, nil
} }
func run() error { func run() error {
fmt.Printf("using domain lists data in %q\n", *dataPath) fmt.Printf("using domain lists data in %q\n", *dataPath)
// Generate plMap // Parse all lists in the data directory
processor := &Processor{plMap: make(map[string]*ParsedList)} processor := &Processor{parsedListByName: make(map[string]*ParsedList)}
err := filepath.WalkDir(*dataPath, func(path string, d os.DirEntry, err error) error { err := filepath.WalkDir(*dataPath, func(path string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@ -516,30 +525,29 @@ func run() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to loadData: %w", err) return fmt.Errorf("failed to loadData: %w", err)
} }
// Generate finalMap // Resolve the inclusions of all lists
processor.finalMap = make(map[string][]*Entry, len(processor.plMap)) for plname := range processor.parsedListByName {
processor.cirIncMap = make(map[string]bool) if _, err := processor.resolveList(plname); err != nil {
for plname := range processor.plMap {
if err := processor.resolveList(plname); err != nil {
return fmt.Errorf("failed to resolveList %q: %w", plname, err) return fmt.Errorf("failed to resolveList %q: %w", plname, err)
} }
} }
processor.plMap = nil
// Make sure output directory exists // Make sure output directory exists
if err := os.MkdirAll(*outputDir, 0755); err != nil { if err := os.MkdirAll(*outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err) return fmt.Errorf("failed to create output directory: %w", err)
} }
// Export plaintext lists // Export plaintext lists
failedCount := 0
for rawEpList := range strings.SplitSeq(*exportLists, ",") { for rawEpList := range strings.SplitSeq(*exportLists, ",") {
if epList := strings.TrimSpace(rawEpList); epList != "" { if epList := strings.TrimSpace(rawEpList); epList != "" {
entries, exist := processor.finalMap[strings.ToUpper(epList)] pl, exist := processor.parsedListByName[strings.ToUpper(epList)]
if !exist { if !exist || len(pl.FinalEntries) == 0 {
fmt.Printf("[Warn] list %q does not exist\n", epList) fmt.Printf("[Warn] list %q does not exist or is empty\n", epList)
continue continue
} }
if err := writePlainList(epList, entries); err != nil { if err := writePlainList(epList, pl.FinalEntries); err != nil {
fmt.Printf("[Error] failed to write list %q: %v\n", epList, err) fmt.Printf("[Error] failed to write list %q: %v\n", epList, err)
failedCount++
continue continue
} }
fmt.Printf("list %q has been generated successfully\n", epList) fmt.Printf("list %q has been generated successfully\n", epList)
@ -547,20 +555,22 @@ func run() error {
} }
// Generate proto sites // Generate proto sites
sitesCount := len(processor.finalMap) listsCount := len(processor.parsedListByName)
gs := &GeoSites{ gs := &GeoSites{
Sites: make([]*router.GeoSite, 0, sitesCount), Sites: make([]*router.GeoSite, 0, listsCount),
SiteIdx: make(map[string]int, sitesCount), SiteIdx: make(map[string]int, listsCount),
} }
for siteName, siteEntries := range processor.finalMap { for siteName, pl := range processor.parsedListByName {
gs.Sites = append(gs.Sites, makeProtoList(siteName, siteEntries)) if len(pl.FinalEntries) == 0 { // Skip empty lists
continue
}
gs.Sites = append(gs.Sites, makeProtoList(siteName, pl.FinalEntries))
} }
processor = nil
// Sort proto sites so the generated file is reproducible // Sort proto sites so the generated file is reproducible
slices.SortFunc(gs.Sites, func(a, b *router.GeoSite) int { slices.SortFunc(gs.Sites, func(a, b *router.GeoSite) int {
return strings.Compare(a.CountryCode, b.CountryCode) return strings.Compare(a.CountryCode, b.CountryCode)
}) })
for i := range sitesCount { for i := range gs.Sites {
gs.SiteIdx[gs.Sites[i].CountryCode] = i gs.SiteIdx[gs.Sites[i].CountryCode] = i
} }
@ -577,9 +587,13 @@ func run() error {
} }
for _, task := range tasks { for _, task := range tasks {
if err := gs.assembleDat(task); err != nil { if err := gs.assembleDat(task); err != nil {
fmt.Printf("[Error] failed to assembleDat %q: %v", task.Name, err) fmt.Printf("[Error] failed to assembleDat %q: %v\n", task.Name, err)
failedCount++
} }
} }
if failedCount > 0 {
return fmt.Errorf("%d output file(s) failed to be generated", failedCount)
}
return nil return nil
} }

205
main_test.go Normal file
View File

@ -0,0 +1,205 @@
package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestParseEntry(t *testing.T) {
testCases := []struct {
name string
typ string
rule string
wantPlain string
wantAffs []string
wantErr bool
}{
{name: "domain", typ: "domain", rule: "Example.COM", wantPlain: "domain:example.com"},
{name: "sorted attrs", typ: "full", rule: "a.example.com @cn @ads", wantPlain: "full:a.example.com:@ads,@cn"},
{name: "duplicated attrs", typ: "domain", rule: "example.com @ads @ads", wantPlain: "domain:example.com:@ads"},
{name: "affiliations", typ: "domain", rule: "example.com &other @ads", wantPlain: "domain:example.com:@ads", wantAffs: []string{"OTHER"}},
{name: "regexp", typ: "regexp", rule: `^example\.com$`, wantPlain: `regexp:^example\.com$`},
{name: "invalid regexp", typ: "regexp", rule: "^example(", wantErr: true},
{name: "empty rule", typ: "domain", rule: " ", wantErr: true},
{name: "unknown type", typ: "prefix", rule: "example.com", wantErr: true},
{name: "invalid domain", typ: "domain", rule: "exa_mple.com @ads", wantErr: true},
{name: "empty attr", typ: "domain", rule: "example.com @", wantErr: true},
{name: "empty affiliation", typ: "domain", rule: "example.com &", wantErr: true},
{name: "unknown field", typ: "domain", rule: "example.com ads", wantErr: true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
entry, affs, err := parseEntry(tc.typ, tc.rule)
if tc.wantErr {
if err == nil {
t.Fatalf("parseEntry(%q, %q) = %q, want error", tc.typ, tc.rule, entry.Plain)
}
return
}
if err != nil {
t.Fatalf("parseEntry(%q, %q) got unexpected error: %v", tc.typ, tc.rule, err)
}
if entry.Plain != tc.wantPlain {
t.Errorf("parseEntry(%q, %q) = %q, want %q", tc.typ, tc.rule, entry.Plain, tc.wantPlain)
}
if len(affs) != len(tc.wantAffs) {
t.Fatalf("parseEntry(%q, %q) affiliations = %v, want %v", tc.typ, tc.rule, affs, tc.wantAffs)
}
for i, aff := range affs {
if aff != tc.wantAffs[i] {
t.Errorf("parseEntry(%q, %q) affiliations = %v, want %v", tc.typ, tc.rule, affs, tc.wantAffs)
}
}
})
}
}
func TestParseInclusion(t *testing.T) {
testCases := []struct {
name string
rule string
wantSrc string
wantMust []string
wantBan []string
wantErr bool
}{
{name: "plain", rule: "other-list", wantSrc: "OTHER-LIST"},
{name: "filters", rule: "other @ads @-cn", wantSrc: "OTHER", wantMust: []string{"ads"}, wantBan: []string{"cn"}},
{name: "empty attr", rule: "other @", wantErr: true},
{name: "empty ban attr", rule: "other @-", wantErr: true},
{name: "empty rule", rule: " ", wantErr: true},
{name: "invalid name", rule: "other@list", wantErr: true},
{name: "affiliation", rule: "other &another", wantErr: true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
inc, err := parseInclusion(tc.rule)
if tc.wantErr {
if err == nil {
t.Fatalf("parseInclusion(%q) = %+v, want error", tc.rule, inc)
}
return
}
if err != nil {
t.Fatalf("parseInclusion(%q) got unexpected error: %v", tc.rule, err)
}
if inc.Source != tc.wantSrc {
t.Errorf("parseInclusion(%q) source = %q, want %q", tc.rule, inc.Source, tc.wantSrc)
}
if len(inc.MustAttrs) != len(tc.wantMust) || len(inc.BanAttrs) != len(tc.wantBan) {
t.Fatalf("parseInclusion(%q) filters = %v/%v, want %v/%v", tc.rule, inc.MustAttrs, inc.BanAttrs, tc.wantMust, tc.wantBan)
}
for i, attr := range inc.MustAttrs {
if attr != tc.wantMust[i] {
t.Errorf("parseInclusion(%q) must attrs = %v, want %v", tc.rule, inc.MustAttrs, tc.wantMust)
}
}
for i, attr := range inc.BanAttrs {
if attr != tc.wantBan[i] {
t.Errorf("parseInclusion(%q) ban attrs = %v, want %v", tc.rule, inc.BanAttrs, tc.wantBan)
}
}
})
}
}
func TestPolishList(t *testing.T) {
rules := []struct{ typ, rule string }{
{"domain", "example.com"},
{"domain", "sub.example.com"}, // Redundant
{"full", "www.example.com"}, // Redundant
{"full", "example.com"}, // Redundant
{"full", "example.org"}, // Kept, no parent domain rule
{"domain", "ads.example.com @ads"}, // Kept, has attribute
{"keyword", "example"},
}
roughMap := make(map[string]*Entry, len(rules))
for _, r := range rules {
entry, _, err := parseEntry(r.typ, r.rule)
if err != nil {
t.Fatalf("parseEntry(%q, %q) got unexpected error: %v", r.typ, r.rule, err)
}
roughMap[entry.Plain] = entry
}
want := []string{"domain:ads.example.com:@ads", "domain:example.com", "full:example.org", "keyword:example"}
assertPlains(t, "polishList", polishList(roughMap), want)
}
// TestResolveSelectiveInclusion makes sure that selective inclusion does not
// lose rules which are redundant in the source list only.
func TestResolveSelectiveInclusion(t *testing.T) {
dataPath := t.TempDir()
files := map[string]string{
"source": "domain:example.com @cn\nfull:mail.example.com\ndomain:sub.example.com\ndomain:example.org @ads\n",
"banned": "include:source @-cn\n",
"must": "include:source @ads\n",
"full": "include:source\n",
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(dataPath, name), []byte(content), 0644); err != nil {
t.Fatalf("failed to write test data %q: %v", name, err)
}
}
processor := &Processor{parsedListByName: make(map[string]*ParsedList)}
for name := range files {
if err := processor.loadData(strings.ToUpper(name), filepath.Join(dataPath, name)); err != nil {
t.Fatalf("loadData(%q) got unexpected error: %v", name, err)
}
}
for name := range files {
if _, err := processor.resolveList(strings.ToUpper(name)); err != nil {
t.Fatalf("resolveList(%q) got unexpected error: %v", name, err)
}
}
assertList(t, processor, "SOURCE", []string{"domain:example.com:@cn", "domain:example.org:@ads"})
assertList(t, processor, "FULL", []string{"domain:example.com:@cn", "domain:example.org:@ads"})
assertList(t, processor, "MUST", []string{"domain:example.org:@ads"})
assertList(t, processor, "BANNED", []string{"domain:example.org:@ads", "domain:sub.example.com", "full:mail.example.com"})
}
func TestResolveCircularInclusion(t *testing.T) {
dataPath := t.TempDir()
files := map[string]string{
"first": "domain:example.com\ninclude:second\n",
"second": "include:first\n",
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(dataPath, name), []byte(content), 0644); err != nil {
t.Fatalf("failed to write test data %q: %v", name, err)
}
}
processor := &Processor{parsedListByName: make(map[string]*ParsedList)}
for name := range files {
if err := processor.loadData(strings.ToUpper(name), filepath.Join(dataPath, name)); err != nil {
t.Fatalf("loadData(%q) got unexpected error: %v", name, err)
}
}
if _, err := processor.resolveList("FIRST"); err == nil {
t.Fatal("resolveList(\"FIRST\") = nil, want circular inclusion error")
}
}
func assertList(t *testing.T, p *Processor, name string, want []string) {
t.Helper()
pl, exist := p.parsedListByName[name]
if !exist {
t.Fatalf("list %q does not exist", name)
}
assertPlains(t, name, pl.FinalEntries, want)
}
func assertPlains(t *testing.T, name string, entries []*Entry, want []string) {
t.Helper()
got := make([]string, 0, len(entries))
for _, entry := range entries {
got = append(got, entry.Plain)
}
if !slices.Equal(got, want) {
t.Errorf("%s = %v, want %v", name, got, want)
}
}