// 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) } } // TestExcludeFailsClosedOnUnreadableContent: an exclude meant to protect // files holds when its content test cannot read a file (over max-read), so // no rule acts on a file krino could not check (review M11, Łukasz's // decision). func TestExcludeFailsClosedOnUnreadableContent(t *testing.T) { big := "confidential " + strings.Repeat("x", 2048) h, dl := excludeTree(t, map[string]string{"big.txt": big, "small.txt": "nothing to hide"}) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (max-read 1K) (exclude (type txt) (content "confidential")) (rule "old" (delete)) `}) 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 { switch fm.File.Rel { case "big.txt": if !strings.HasSuffix(fm.Excluded, "(content unreadable)") || len(fm.Rules) != 0 { t.Errorf("big.txt: Excluded %q, rules %d; want set aside as unreadable", fm.Excluded, len(fm.Rules)) } case "small.txt": if fm.Excluded != "" || len(fm.Rules) != 1 { t.Errorf("small.txt: Excluded %q, rules %d; want the rule", fm.Excluded, len(fm.Rules)) } } } x, err := e.Explain(context.Background(), filepath.Join(dl, "big.txt")) if err != nil { t.Fatal(err) } if !strings.HasSuffix(x.Excluded, "(content unreadable)") { t.Errorf("explain: Excluded %q", x.Excluded) } } // TestLoadChecksMainExcludesWithoutDirectories: a mistake in krino.conf's // (exclude ...) is reported even before any directory is included (review // cli F10). func TestLoadChecksMainExcludesWithoutDirectories(t *testing.T) { h := sandbox(t) main := writeConfig(t, h, "(include)\n(exclude (bogus 1))\n", nil) if _, errs := Load(main); len(errs) == 0 { t.Error("a broken krino.conf exclude was not reported") } } // TestNoTextFormatIsNoMatch: a file whose format has no text cannot contain // a keyword, so a content exclude without a type does not set it aside, and // no "content unreadable" warning is raised - while a real read failure (over // max-read) still fails closed (re-review N2, Łukasz's decision). func TestNoTextFormatIsNoMatch(t *testing.T) { big := "confidential " + strings.Repeat("x", 2048) h, dl := excludeTree(t, map[string]string{"photo.jpg": "\xff\xd8\xff\x00\x01binary", "big.txt": big}) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (max-read 1K) (exclude (content "confidential")) (rule "pics" (when (type jpg)) (move "Pictures")) `}) 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 { switch fm.File.Rel { case "photo.jpg": if fm.Excluded != "" || len(fm.Rules) != 1 || len(fm.Warnings) != 0 { t.Errorf("photo.jpg: Excluded %q, rules %d, warnings %v; want the pics rule and no warning", fm.Excluded, len(fm.Rules), fm.Warnings) } case "big.txt": if !strings.HasSuffix(fm.Excluded, "(content unreadable)") { t.Errorf("big.txt: Excluded %q; a real read failure must still fail closed", fm.Excluded) } } } x, err := e.Explain(context.Background(), filepath.Join(dl, "photo.jpg")) if err != nil { t.Fatal(err) } if x.Excluded != "" { t.Errorf("explain: Excluded %q, want none", x.Excluded) } } // TestTextTurningBinaryFailsClosed: a file with no known extension whose // first 8 KiB read as text but which holds a NUL further on is unreadable, // not "no text": a content exclude still sets it aside (plan 10 re-check R4). func TestTextTurningBinaryFailsClosed(t *testing.T) { mixed := "confidential " + strings.Repeat("x", 9000) + "\x00tail" h, _ := excludeTree(t, map[string]string{"mixed": mixed}) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (exclude (content "confidential")) (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 fm.File.Rel == "mixed" && (!strings.HasSuffix(fm.Excluded, "(content unreadable)") || len(fm.Rules) != 0) { t.Errorf("mixed: Excluded %q, rules %d; want set aside as unreadable", fm.Excluded, len(fm.Rules)) } } }