// SPDX-License-Identifier: GPL-3.0-or-later package fim import ( "crypto/sha256" "encoding/hex" "os" "path/filepath" "syscall" "testing" ) func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestHashFileStreamsSHA256(t *testing.T) { path := filepath.Join(t.TempDir(), "f") writeFile(t, path, "sentinel") sum := sha256.Sum256([]byte("sentinel")) got, ok := HashFile(path) if !ok { t.Fatal("HashFile reported failure for a readable file") } if want := hex.EncodeToString(sum[:]); got != want { t.Errorf("hash = %s, want %s", got, want) } } func TestHashFileReportsFailureForMissingPath(t *testing.T) { if _, ok := HashFile(filepath.Join(t.TempDir(), "absent")); ok { t.Error("HashFile reported success for a missing file") } } func TestScanPathsRecordsRegularFileWithHashAndMetadata(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "f") writeFile(t, path, "x") entries := ScanPaths([]string{path}) entry, ok := entries[path] if !ok { t.Fatalf("no entry for %s, got %v", path, entries) } if entry.SHA256 == nil { t.Error("regular file entry has no hash") } if entry.Link != nil { t.Error("regular file entry must not carry a link target") } if entry.Size != 1 { t.Errorf("size = %d, want 1", entry.Size) } if entry.Mode != 0o644 { t.Errorf("mode = %o, want 644", entry.Mode) } } // Permission bits alone are not enough: losing the setuid bit here would mean // a binary silently gaining setuid never trips the baseline. func TestScanPathsPreservesSetuidBit(t *testing.T) { path := filepath.Join(t.TempDir(), "suid") writeFile(t, path, "x") // syscall.Chmod takes the raw mode; os.Chmod would need os.ModeSetuid and // silently drops a bare 0o4000. if err := syscall.Chmod(path, 0o4755); err != nil { t.Fatal(err) } entry := ScanPaths([]string{path})[path] if entry.Mode != 0o4755 { t.Errorf("mode = %o, want 4755", entry.Mode) } } func TestScanPathsRecordsSymlinkTargetWithoutHashing(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "target") link := filepath.Join(dir, "link") writeFile(t, target, "x") if err := os.Symlink(target, link); err != nil { t.Fatal(err) } entry, ok := ScanPaths([]string{link})[link] if !ok { t.Fatalf("no entry for symlink %s", link) } if entry.Link == nil || *entry.Link != target { t.Errorf("link = %v, want %s", entry.Link, target) } if entry.SHA256 != nil { t.Error("symlink entry must not be hashed") } } func TestScanPathsRecordsBrokenSymlink(t *testing.T) { dir := t.TempDir() link := filepath.Join(dir, "broken") if err := os.Symlink(filepath.Join(dir, "nowhere"), link); err != nil { t.Fatal(err) } if _, ok := ScanPaths([]string{link})[link]; !ok { t.Error("broken symlink was not recorded") } } func TestScanPathsWalksDirectoriesRecursively(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "top"), "a") writeFile(t, filepath.Join(dir, "sub", "nested"), "b") entries := ScanPaths([]string{dir}) for _, want := range []string{filepath.Join(dir, "top"), filepath.Join(dir, "sub", "nested")} { if _, ok := entries[want]; !ok { t.Errorf("missing %s, got %v", want, entries) } } if _, ok := entries[filepath.Join(dir, "sub")]; ok { t.Error("directories themselves must not be recorded") } } // Python's os.walk classifies with is_dir(), which follows symlinks, so a // symlink to a directory lands in dirs and is never scanned as a file. func TestScanPathsSkipsSymlinkToDirectoryDuringWalk(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "real", "f"), "a") if err := os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "dirlink")); err != nil { t.Fatal(err) } entries := ScanPaths([]string{dir}) if _, ok := entries[filepath.Join(dir, "dirlink")]; ok { t.Error("symlink to a directory must not be recorded during a walk") } if _, ok := entries[filepath.Join(dir, "dirlink", "f")]; ok { t.Error("symlinks to directories must not be followed") } } // At the top level Python checks islink() before isdir(), so the same symlink // passed directly as a root IS recorded. func TestScanPathsRecordsSymlinkToDirectoryGivenAsRoot(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "real"), 0o755); err != nil { t.Fatal(err) } link := filepath.Join(dir, "dirlink") if err := os.Symlink(filepath.Join(dir, "real"), link); err != nil { t.Fatal(err) } entry, ok := ScanPaths([]string{link})[link] if !ok { t.Fatal("symlink root was not recorded") } if entry.Link == nil { t.Error("symlink root recorded without a link target") } } func TestScanPathsSkipsFifosAndOtherSpecialFiles(t *testing.T) { dir := t.TempDir() fifo := filepath.Join(dir, "pipe") if err := syscall.Mkfifo(fifo, 0o644); err != nil { t.Skipf("cannot create fifo: %v", err) } if _, ok := ScanPaths([]string{dir})[fifo]; ok { t.Error("fifo must be skipped") } } func TestScanPathsIgnoresMissingRoots(t *testing.T) { entries := ScanPaths([]string{filepath.Join(t.TempDir(), "absent")}) if len(entries) != 0 { t.Errorf("entries = %v, want empty", entries) } } // A single unreadable file must not abort the sweep — the rest of the tree // still has to be recorded. func TestScanPathsContinuesPastUnreadableFile(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root can read mode 000 files") } dir := t.TempDir() secret := filepath.Join(dir, "secret") writeFile(t, secret, "x") writeFile(t, filepath.Join(dir, "readable"), "y") if err := os.Chmod(secret, 0o000); err != nil { t.Fatal(err) } entries := ScanPaths([]string{dir}) if _, ok := entries[filepath.Join(dir, "readable")]; !ok { t.Error("readable sibling was dropped") } entry, ok := entries[secret] if !ok { t.Fatal("unreadable file must still be recorded via lstat") } if entry.SHA256 != nil { t.Error("unreadable file must record a nil hash, not a bogus one") } } func TestScanPathsReturnsEmptyMapForNoPaths(t *testing.T) { if entries := ScanPaths(nil); entries == nil || len(entries) != 0 { t.Errorf("entries = %v, want empty non-nil map", entries) } } func TestDefaultPathsCoverCriticalUnpackagedLocations(t *testing.T) { required := []string{"/etc/ld.so.preload", "/root/.ssh/authorized_keys", "/etc/sudoers", "/usr/local/bin"} present := make(map[string]bool, len(DefaultPaths)) for _, path := range DefaultPaths { present[path] = true } for _, path := range required { if !present[path] { t.Errorf("DefaultPaths missing %s", path) } } }