// SPDX-License-Identifier: GPL-3.0-or-later package config import ( "bytes" "errors" "io/fs" "os" "path/filepath" "strings" "testing" ) // uncomment turns the commented examples in a skeleton into live forms. func uncomment(src []byte) []byte { lines := strings.Split(string(src), "\n") for i, l := range lines { if strings.HasPrefix(l, ";; (") || strings.HasPrefix(l, ";; ") { lines[i] = l[3:] } } return []byte(strings.Join(lines, "\n")) } func TestSkeletonsParse(t *testing.T) { tmpl := bytes.ReplaceAll(skelTemplate, []byte("@PATH@"), []byte("/tmp")) for name, src := range map[string][]byte{"plain": tmpl, "uncommented": uncomment(tmpl)} { if _, errs := ParseDir("x", "template.conf", src); len(errs) > 0 { t.Errorf("template.conf, %s: %v", name, errs) } } for name, src := range map[string][]byte{"plain": skelMain, "uncommented": uncomment(skelMain)} { if _, errs := ParseMain("krino.conf", src); len(errs) > 0 { t.Errorf("krino.conf, %s: %v", name, errs) } } } func TestInitAndNew(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) target := filepath.Join(home, "downloads") if err := os.Mkdir(target, 0o755); err != nil { t.Fatal(err) } main := filepath.Join(home, ".config", "krino", "krino.conf") created, err := Init(main) if err != nil || len(created) != 2 { t.Fatalf("Init = %v, %v", created, err) } if _, err := Init(main); err == nil || err.Error() != main+" already exists; krino init leaves it alone" { t.Fatalf("second Init: %v", err) } if cfg, errs := Load(main); len(errs) > 0 || len(cfg.Dirs) != 0 { t.Fatalf("fresh config: %v", errs) } file, err := NewDir(main, "downloads", target) if err != nil { t.Fatal(err) } cfg, errs := Load(main) if len(errs) > 0 || len(cfg.Dirs) != 1 { t.Fatalf("after NewDir: %v", errs) } if d := cfg.Dirs[0]; d.Path != target || d.PathText != "~/downloads" || d.File != file { t.Fatalf("dir = %+v", d) } } // TestInitRefusedLeavesDirsAbsent is item D: a refused init (krino.conf // already exists) must not create dirs/ either. func TestInitRefusedLeavesDirsAbsent(t *testing.T) { root := t.TempDir() main := filepath.Join(root, "krino.conf") if err := os.WriteFile(main, []byte("(include)\n"), 0o644); err != nil { t.Fatal(err) } if _, err := Init(main); err == nil || err.Error() != main+" already exists; krino init leaves it alone" { t.Fatalf("Init = %v, want already-exists", err) } if _, err := os.Stat(filepath.Join(root, "dirs")); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("dirs/ created by a refused init: %v", err) } } func TestInitKeepsTemplate(t *testing.T) { root := t.TempDir() writeFiles(t, root, map[string]string{"template.conf": ";; mine\n(path \"@PATH@\")\n"}) created, err := Init(filepath.Join(root, "krino.conf")) if err != nil || len(created) != 1 { t.Fatalf("Init = %v, %v", created, err) } if got, _ := os.ReadFile(filepath.Join(root, "template.conf")); string(got) != ";; mine\n(path \"@PATH@\")\n" { t.Fatalf("template overwritten: %q", got) } } func TestNewDirKeepsTheRestOfTheFile(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) orig := ";; my notes\n(include \"a\" ; first\n)\n(defaults (min-age 5m)) ; tail\n" writeFiles(t, home, map[string]string{"dirs/a.conf": `(path "~/a")`, "a/.keep": "", "b/.keep": ""}) main := filepath.Join(home, "krino.conf") if err := os.WriteFile(main, []byte(orig), 0o600); err != nil { t.Fatal(err) } if _, err := NewDir(main, "b", "~/b"); err != nil { t.Fatal(err) } got, _ := os.ReadFile(main) want := ";; my notes\n(include \"a\" \"b\" ; first\n)\n(defaults (min-age 5m)) ; tail\n" if string(got) != want { t.Fatalf("got\n%s\nwant\n%s", got, want) } if fi, _ := os.Stat(main); fi.Mode().Perm() != 0o600 { t.Errorf("mode = %v, want 0600 kept", fi.Mode().Perm()) } } func TestNewDirAppendsInclude(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) writeFiles(t, home, map[string]string{"krino.conf": `(log "/x.log")`, "b/.keep": ""}) main := filepath.Join(home, "krino.conf") if _, err := NewDir(main, "b", "~/b"); err != nil { t.Fatal(err) } if got, _ := os.ReadFile(main); string(got) != "(log \"/x.log\")\n(include \"b\")\n" { t.Fatalf("got %q", got) } } func TestNewDirFollowsSymlink(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) writeFiles(t, home, map[string]string{"dotfiles/krino.conf": "(include)\n", "x/.keep": ""}) confDir := filepath.Join(home, "conf") if err := os.Mkdir(confDir, 0o755); err != nil { t.Fatal(err) } main := filepath.Join(confDir, "krino.conf") if err := os.Symlink(filepath.Join(home, "dotfiles", "krino.conf"), main); err != nil { t.Fatal(err) } if _, err := NewDir(main, "x", "~/x"); err != nil { t.Fatal(err) } if fi, _ := os.Lstat(main); fi.Mode()&os.ModeSymlink == 0 { t.Fatal("krino.conf symlink was replaced by a regular file") } if got, _ := os.ReadFile(filepath.Join(home, "dotfiles", "krino.conf")); string(got) != "(include \"x\")\n" { t.Fatalf("link target = %q", got) } } // TestNewDirTemplateMissingPlaceholder is item A: a template.conf without // "@PATH@" must not silently keep its own path. func TestNewDirTemplateMissingPlaceholder(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) writeFiles(t, home, map[string]string{ "template.conf": "(path \"/somewhere/else\")\n", "b/.keep": "", }) main := filepath.Join(home, "krino.conf") orig := "(include)\n" if err := os.WriteFile(main, []byte(orig), 0o644); err != nil { t.Fatal(err) } tmplPath := filepath.Join(home, "template.conf") want := tmplPath + ` must contain (path "@PATH@")` if _, err := NewDir(main, "b", "~/b"); err == nil || err.Error() != want { t.Fatalf("NewDir = %v, want %s", err, want) } if _, err := os.Stat(filepath.Join(home, "dirs", "b.conf")); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("dirs/b.conf created: %v", err) } if got, _ := os.ReadFile(main); string(got) != orig { t.Fatalf("krino.conf changed: %q", got) } } // TestNewDirFileAlreadyExists is item I.1: a stray dirs/NAME.conf that was // never included must refuse, leaving krino.conf untouched. func TestNewDirFileAlreadyExists(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) orig := "(include)\n" writeFiles(t, home, map[string]string{"dirs/b.conf": "stale\n", "b/.keep": ""}) main := filepath.Join(home, "krino.conf") if err := os.WriteFile(main, []byte(orig), 0o644); err != nil { t.Fatal(err) } want := filepath.Join(home, "dirs", "b.conf") + " already exists" if _, err := NewDir(main, "b", "~/b"); err == nil || err.Error() != want { t.Fatalf("NewDir = %v, want %s", err, want) } if got, _ := os.ReadFile(main); string(got) != orig { t.Fatalf("krino.conf changed: %q", got) } } // TestNewDirRollsBackWhenConfigDirReadOnly is item I.2: if dirs/NAME.conf // can be written but krino.conf cannot be replaced, the new file must be // rolled back and krino.conf left untouched. func TestNewDirRollsBackWhenConfigDirReadOnly(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root ignores read-only permissions") } home := t.TempDir() t.Setenv("HOME", home) writeFiles(t, home, map[string]string{"b/.keep": ""}) confDir := filepath.Join(home, "conf") if err := os.Mkdir(confDir, 0o755); err != nil { t.Fatal(err) } main := filepath.Join(confDir, "krino.conf") orig := "(include)\n" if err := os.WriteFile(main, []byte(orig), 0o644); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(confDir, "dirs"), 0o755); err != nil { t.Fatal(err) } if err := os.Chmod(confDir, 0o500); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(confDir, 0o755) }) if _, err := NewDir(main, "b", "~/b"); err == nil { t.Fatal("NewDir succeeded, want an error from the read-only config directory") } if _, err := os.Stat(filepath.Join(confDir, "dirs", "b.conf")); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("dirs/b.conf not rolled back: %v", err) } if got, _ := os.ReadFile(main); string(got) != orig { t.Fatalf("krino.conf changed: %q", got) } } func TestNewDirErrors(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) writeFiles(t, home, map[string]string{ "krino.conf": `(include "a")`, "dirs/a.conf": `(path "~")`, "x/.keep": "", "f": "", }) main := filepath.Join(home, "krino.conf") tests := []struct{ name, path, want string }{ {"a/b", "~/x", `bad directory name "a/b": use letters, digits, '.', '_' and '-'`}, {"check", "~/x", `"check" is a krino command; choose another name`}, {"b", "~/f", filepath.Join(home, "f") + " is not a directory"}, {"b", "~/missing", filepath.Join(home, "missing") + " is not a directory"}, {"a", "~/x", `"a" is already included`}, } for _, tt := range tests { if _, err := NewDir(main, tt.name, tt.path); err == nil || err.Error() != tt.want { t.Errorf("NewDir(%q, %q) = %v, want %s", tt.name, tt.path, err, tt.want) } } if _, err := NewDir(filepath.Join(home, "none.conf"), "b", "~/x"); err == nil || !strings.HasSuffix(err.Error(), "not found; create it with: krino init") { t.Errorf("missing main file: %v", err) } }