// SPDX-License-Identifier: GPL-3.0-or-later package cond import ( "reflect" "strings" "testing" "git.labunix.xyz/krino/internal/sexp" ) func nodes(t *testing.T, src string) []*sexp.Node { t.Helper() n, err := sexp.Parse("d.conf", []byte(src)) if err != nil { t.Fatalf("parse %q: %v", src, err) } return n } func TestCompileGood(t *testing.T) { for _, src := range []string{ ``, `(type pdf)`, `(type document image tar.gz)`, `(and (type pdf) (or (content "acme ltd" "0000000000") (name "\bacme\b")) (not (name "^draft")))`, `(path "^Work/") (size >= 10M) (age < 2w) (matched)`, `(duplicate) (duplicate "Work" "~/docs")`, `(not (not (type pdf)))`, } { if _, errs := Compile("d.conf", nodes(t, src), Options{IgnoreCase: true, Fold: true}); len(errs) > 0 { t.Errorf("%s: %v", src, errs) } } } func TestCompileErrors(t *testing.T) { tests := []struct{ src, want string }{ {`pdf`, `d.conf:1:1: a condition is a form like (type pdf), not pdf`}, {`("type" pdf)`, `d.conf:1:1: a condition is a form like (type pdf), not ("type" pdf)`}, {`(foo 1)`, `d.conf:1:1: unknown test (foo ...); tests are type, name, path, content, size, age, duplicate, matched, and, or, not`}, {`(and)`, `d.conf:1:1: and needs at least one condition`}, {`(or)`, `d.conf:1:1: or needs at least one condition`}, {`(not)`, `d.conf:1:1: not takes exactly one condition`}, {`(not (type pdf) (type doc))`, `d.conf:1:1: not takes exactly one condition`}, {`(type)`, `d.conf:1:1: type needs at least one extension or group, like (type pdf)`}, {`(type "pdf")`, `d.conf:1:7: type names are bare words: write (type pdf)`}, {`(name)`, `d.conf:1:1: name needs at least one regex in quotes`}, {`(path)`, `d.conf:1:1: path needs at least one regex in quotes`}, {`(name x)`, `d.conf:1:7: name takes regexes in quotes: write (name "x")`}, {`(name "(abc")`, `d.conf:1:7: name: bad regex "(abc": missing closing )`}, {`(path "[z-a]")`, `d.conf:1:7: path: bad regex "[z-a]": invalid character class range`}, {`(content)`, `d.conf:1:1: content needs at least one keyword in quotes`}, {`(content acme)`, `d.conf:1:10: content takes keywords in quotes: write (content "acme")`}, {`(content " ")`, `d.conf:1:10: content keyword is empty`}, {`(size 10M)`, `d.conf:1:1: size takes an operator and a size, like (size > 10M)`}, {`(age 30d)`, `d.conf:1:1: age takes an operator and a duration, like (age > 30d)`}, {`(size >> 10M)`, `d.conf:1:7: size operator is one of > >= < <= =, not >>`}, {`(size > 10Q)`, `d.conf:1:9: size: bad size "10Q": want a whole number with an optional K, M, G or T, like 50M`}, {`(age > 30)`, `d.conf:1:8: age: bad duration "30": want a whole number followed by s, m, h, d or w, like 30d`}, {`(duplicate Work)`, `d.conf:1:12: duplicate takes directories in quotes: write (duplicate "Work")`}, {`(matched x)`, `d.conf:1:1: matched takes nothing: write (matched)`}, } for _, tt := range tests { _, errs := Compile("d.conf", nodes(t, tt.src), Options{IgnoreCase: true}) if len(errs) != 1 || errs[0].Error() != tt.want { t.Errorf("%s:\n got %v\n want %s", tt.src, errs, tt.want) } } } func TestCompileCollectsAllErrors(t *testing.T) { _, errs := Compile("d.conf", nodes(t, `(type "a") (size 1) (matched x)`), Options{}) if len(errs) != 3 { t.Fatalf("got %d errors: %v", len(errs), errs) } } func TestDepthLimit(t *testing.T) { deep := func(n int) string { return strings.Repeat("(not ", n) + "(type pdf)" + strings.Repeat(")", n) } if _, errs := Compile("d.conf", nodes(t, deep(63)), Options{}); len(errs) != 0 { t.Fatalf("64 levels rejected: %v", errs) } _, errs := Compile("d.conf", nodes(t, deep(64)), Options{}) if len(errs) != 1 || !strings.HasSuffix(errs[0].Error(), "conditions nest deeper than 64 levels") { t.Fatalf("65 levels: %v", errs) } } func TestFlagsAndDupDirs(t *testing.T) { c, _ := Compile("d.conf", nodes(t, `(or (type pdf) (content "x")) (duplicate) (duplicate "Work" "~/docs")`), Options{}) if !c.UsesContent { t.Error("UsesContent not set") } if want := [][]string{{}, {"Work", "~/docs"}}; !reflect.DeepEqual(c.DupDirs, want) { t.Errorf("DupDirs = %#v, want %#v", c.DupDirs, want) } c, _ = Compile("d.conf", nodes(t, `(type pdf)`), Options{}) if c.UsesContent || len(c.DupDirs) != 0 { t.Errorf("flags set without content/duplicate tests: %+v", c) } } func TestCompileErrorReturnsNilCond(t *testing.T) { c, errs := Compile("d.conf", nodes(t, `(type "pdf")`), Options{}) if len(errs) != 1 || c != nil { t.Fatalf("got c=%v errs=%v, want c=nil and exactly one error", c, errs) } } // TestEmptyChildrenGuard is a regression test for combine's empty-children // guard: an and/or all of whose children failed to compile must not become // a hollow node that evaluates vacuously (and -> true). Without the guard, // the inner (and (name "[")) would compile to an empty and, evaluate to // true, and the outer or would match on any file. func TestEmptyChildrenGuard(t *testing.T) { c, errs := compile("d.conf", nodes(t, `(or (type pdf) (and (name "[")))`), Options{}, true) if len(errs) != 1 { t.Fatalf("got %d errors, want 1: %v", len(errs), errs) } if c.Eval(&fake{name: "x.txt"}).Match { t.Error("hollow and inside or vacuously matched") } } // TestNameGroups: B1b - NameGroups() reports the capture-group count of // every name test reachable without crossing a not, and skips one reachable // only under a not (B1): here the outer (name ...) has two groups and the // (not (name ...)) one has one, but the result must carry only the outer's // count. func TestNameGroups(t *testing.T) { c, errs := Compile("d.conf", nodes(t, `(and (name "inv-(\d+)-(\d+)") (not (name "draft-(\d+)")))`), Options{}) if len(errs) > 0 { t.Fatal(errs) } got := c.NameGroups() want := []int{2} if !reflect.DeepEqual(got, want) { t.Errorf("NameGroups() = %v, want %v", got, want) } } func TestGroupsMatchSpec(t *testing.T) { want := map[string]string{ "image": "jpg jpeg png gif webp bmp tif tiff heic heif avif svg ico raw cr2 nef arw dng", "video": "mp4 mkv webm mov avi m4v mpg mpeg wmv flv 3gp", "audio": "mp3 flac ogg opus m4a aac wav wma aiff", "archive": "zip tar gz tgz bz2 tbz2 xz txz zst 7z rar lz lzma cpio", "document": "pdf doc docx odt rtf txt md tex", "spreadsheet": "xls xlsx ods csv tsv", "presentation": "ppt pptx odp", "ebook": "epub mobi azw azw3 fb2 djvu", "code": "go c h cpp hpp py sh js ts rs java rb pl lua html css json yaml yml toml xml sql", "text": "txt md log csv tsv json yaml yml toml xml ini conf", "package": "deb rpm apk appimage exe msi flatpak snap", "font": "ttf otf woff woff2", } if len(groups) != len(want) { t.Fatalf("%d groups, want %d", len(groups), len(want)) } for g, exts := range want { if got := strings.Join(groups[g], " "); got != exts { t.Errorf("group %s = %q, want %q", g, got, exts) } } } // TestCompileCollectsKeywords: every content keyword is recorded as // compared, normalised under the compile options, and its key names those // options. func TestCompileCollectsKeywords(t *testing.T) { opt := Options{IgnoreCase: true, Fold: true} c, errs := Compile("d.conf", nodes(t, `(or (content "Spółka" "x") (not (content "NIP")))`), opt) if len(errs) > 0 { t.Fatal(errs) } want := []Keyword{{Opt: opt, Norm: "spolka"}, {Opt: opt, Norm: "x"}, {Opt: opt, Norm: "nip"}} if !reflect.DeepEqual(c.Keywords, want) { t.Errorf("Keywords = %+v, want %+v", c.Keywords, want) } if got := want[0].Key(); got != "if:spolka" { t.Errorf("Key = %q", got) } if got := KeywordKey(Options{}, "NIP"); got != "sn:NIP" { t.Errorf("strict, unfolded key = %q", got) } }