// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "os" "path/filepath" "strings" "testing" "time" "krino/internal/plan" "krino/internal/scan" ) // excludeTree creates ~/dl with files (name -> content), all old enough to // be scanned, and returns home and dl. func excludeTree(t *testing.T, files map[string]string) (home, dl string) { t.Helper() home = sandbox(t) t.Setenv("PATH", t.TempDir()) dl = filepath.Join(home, "dl") old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for name, body := range files { p := filepath.Join(dl, name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(p, old, old); err != nil { t.Fatal(err) } } return home, dl } // TestExcludeSetsFilesAside: an (exclude ...) in krino.conf applies to the // directory, and the directory's own excludes apply too - by type, by name // regex and by content - so those files get no actions from any rule and // are reported as excluded, with the form that matched. func TestExcludeSetsFilesAside(t *testing.T) { h, _ := excludeTree(t, map[string]string{ "a.iso": "disk image", "draft-1.pdf": "%PDF draft", "secret.txt": "this is poufne material", "keep.txt": "ordinary notes", }) main := writeConfig(t, h, "(include \"dl\")\n(exclude (type iso))\n", map[string]string{"dl": ` (path "~/dl") (exclude (name "^draft")) (exclude (type txt) (content "poufne")) (rule "all" (move "Out")) `}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims()) if err != nil { t.Fatal(err) } want := map[string]string{ "a.iso": "(exclude (type iso))", "draft-1.pdf": `(exclude (name "^draft"))`, "secret.txt": `(exclude (type txt) (content "poufne"))`, "keep.txt": "", } for _, fm := range dp.Result.Matched { w, ok := want[fm.File.Rel] if !ok { t.Errorf("unexpected matched file %s", fm.File.Rel) continue } if fm.Excluded != w { t.Errorf("%s: Excluded = %q, want %q", fm.File.Rel, fm.Excluded, w) } if w != "" && len(fm.Rules) != 0 { t.Errorf("%s: excluded but matched rules %v", fm.File.Rel, fm.Rules) } } if len(dp.Result.Matched) != 4 || len(dp.Result.Unmatched) != 0 { t.Errorf("matched %d, unmatched %d; want every file matched (3 excluded, 1 by the rule)", len(dp.Result.Matched), len(dp.Result.Unmatched)) } for _, c := range dp.Chains { if acting := len(c.Steps) > 0; acting != (c.File.Rel == "keep.txt") { t.Errorf("%s: steps %+v", c.File.Rel, c.Steps) } } } // TestExcludeNeedsEveryCondition: within one form, every condition must // hold, as in when. func TestExcludeNeedsEveryCondition(t *testing.T) { h, _ := excludeTree(t, map[string]string{"draft.txt": "x", "draft.pdf": "%PDF x"}) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (exclude (type pdf) (name "^draft")) (rule "all" (move "Out")) `}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } r, err := e.Match(context.Background(), e.Dirs[0]) if err != nil { t.Fatal(err) } for _, fm := range r.Matched { if excluded := fm.Excluded != ""; excluded != (fm.File.Rel == "draft.pdf") { t.Errorf("%s: Excluded = %q", fm.File.Rel, fm.Excluded) } } } // TestExcludeContentAndRuleContentKeepTheirOwnSettings: an exclude reading // content under the directory's settings first must not change how a rule // with different case/fold settings sees the same text. func TestExcludeContentAndRuleContentKeepTheirOwnSettings(t *testing.T) { h, _ := excludeTree(t, map[string]string{"a.txt": "Invoice ACME"}) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (exclude (content "never present")) (rule "strict" (case strict) (when (content "ACME")) (move "Out")) `}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } r, err := e.Match(context.Background(), e.Dirs[0]) if err != nil { t.Fatal(err) } if len(r.Matched) != 1 || len(r.Matched[0].Rules) != 1 { t.Fatalf("a.txt should match the strict content rule after the exclude read its text: %+v", r.Matched) } } // TestLoadReportsBadExcludeOnce: a condition error inside a krino.conf // exclude is reported once, not once per directory. func TestLoadReportsBadExcludeOnce(t *testing.T) { h := sandbox(t) main := writeConfig(t, h, "(include \"a\" \"b\")\n(exclude (size big))\n", map[string]string{ "a": "(path \"/tmp\")", "b": "(path \"/tmp\")", }) _, errs := Load(main) if len(errs) != 1 || !strings.Contains(errs[0].Error(), "size") { t.Errorf("errs = %v, want exactly one error about size", errs) } } // TestMaxSizeSkipsTooBig: a directory's max-size skips larger files before // any rule, as too big. func TestMaxSizeSkipsTooBig(t *testing.T) { h, _ := excludeTree(t, map[string]string{"small.txt": "x", "big.txt": strings.Repeat("x", 2048)}) main := writeConfig(t, h, "(include \"dl\")\n(defaults (max-size 1K))\n", map[string]string{"dl": ` (path "~/dl") (rule "all" (move "Out")) `}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } r, err := e.Match(context.Background(), e.Dirs[0]) if err != nil { t.Fatal(err) } if len(r.Skipped) != 1 || r.Skipped[0].Rel != "big.txt" || r.Skipped[0].Reason != scan.TooBig { t.Errorf("skipped = %+v, want big.txt too big", r.Skipped) } if len(r.Matched) != 1 || r.Matched[0].File.Rel != "small.txt" { t.Errorf("matched = %+v, want small.txt", r.Matched) } } // TestExplainReportsExclusionAndSize: explain names the exclude that sets a // file aside, traces every exclude, and says a file is too big. func TestExplainReportsExclusionAndSize(t *testing.T) { h, dl := excludeTree(t, map[string]string{"a.iso": "disk", "big.txt": strings.Repeat("x", 2048)}) main := writeConfig(t, h, "(include \"dl\")\n(exclude (type iso))\n", map[string]string{"dl": ` (path "~/dl") (max-size 1K) (exclude (name "^nothing")) (rule "all" (move "Out")) `}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } x, err := e.Explain(context.Background(), filepath.Join(dl, "a.iso")) if err != nil { t.Fatal(err) } if x.Excluded != "(exclude (type iso))" { t.Errorf("Excluded = %q", x.Excluded) } if len(x.Excludes) != 2 || !x.Excludes[0].Match || x.Excludes[1].Match || x.Excludes[0].Trace == nil { t.Errorf("Excludes = %+v, want the global one matching, the directory's one not", x.Excludes) } big, err := e.Explain(context.Background(), filepath.Join(dl, "big.txt")) if err != nil { t.Fatal(err) } if big.Skip != "too big" { t.Errorf("Skip = %q, want too big", big.Skip) } }