From ebbd61d7a645bbc5a57fd55c650950da7b05bbce Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Tue, 30 Jun 2026 10:09:30 +0200 Subject: [PATCH 1/5] check_files: add symlink support add new argument followSymlinks and new attribute is_symlink add a new file walker, it can resolve file links, and keeps track of visited directories to prevent infinite recursion --- docs/checks/commands/check_files.md | 2 + pkg/snclient/check_files.go | 107 ++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/docs/checks/commands/check_files.md b/docs/checks/commands/check_files.md index 09d3300d..da464e79 100644 --- a/docs/checks/commands/check_files.md +++ b/docs/checks/commands/check_files.md @@ -62,6 +62,7 @@ Naemon Config | ---------------------------- | -------------------------------------------------------------------------------------- | | calculate-subdirectory-sizes | For subdirectories that are found under the search paths, calculate the subdirectory sizes based on found files. This calculation may be expensive. Default: false | | file | Alias for path | +| follow-symlinks | Follow symlinks of files and subdirectories while traversing. The file paths will be registered originating from search path. | | max-depth | Maximum recursion depth. Default: no limit. '0' and '1' disable recursion and only include files/directories directly under path., '2' starts to include files/directories of subdirectories with given depth. | | path | Path in which to search for files | | paths | A comma separated list of paths | @@ -98,3 +99,4 @@ these can be used in filters and thresholds (along with the default attributes): | sha256_checksum | SHA256 checksum of the file | | sha384_checksum | SHA384 checksum of the file | | sha512_checksum | SHA512 checksum of the file | +| is_symlink | The file or its parent is a symlink | diff --git a/pkg/snclient/check_files.go b/pkg/snclient/check_files.go index 9cf05948..3bce8ec4 100644 --- a/pkg/snclient/check_files.go +++ b/pkg/snclient/check_files.go @@ -35,6 +35,7 @@ type CheckFiles struct { pattern string // constructor NewCheckFiles sets this as '*' maxDepth int64 // constructor NewCheckFiles sets this as -1 calculateSubdirectorySizes bool // constructor NewCheckFiles sets this as false + followSymlinks bool // constructor NewCheckFiles sets this as true } func NewCheckFiles() CheckHandler { @@ -43,6 +44,7 @@ func NewCheckFiles() CheckHandler { pattern: "*", maxDepth: int64(-1), calculateSubdirectorySizes: false, + followSymlinks: true, } } @@ -62,6 +64,8 @@ func (l *CheckFiles) Build() *CheckData { "max-depth": {value: &l.maxDepth, description: "Maximum recursion depth. Default: no limit. '0' and '1' disable recursion and only include files/directories directly under path." + ", '2' starts to include files/directories of subdirectories with given depth. "}, "timezone": {description: "Sets the timezone for time metrics (default is local time)"}, + "follow-symlinks": {value: &l.followSymlinks, description: "Follow symlinks of files and subdirectories while traversing. " + + "The file paths will be registered originating from search path."}, "calculate-subdirectory-sizes": {value: &l.calculateSubdirectorySizes, description: "For subdirectories that are found under the search paths, " + "calculate the subdirectory sizes based on found files. This calculation may be expensive. Default: false"}, }, @@ -93,6 +97,7 @@ func (l *CheckFiles) Build() *CheckData { {name: "sha256_checksum", description: "SHA256 checksum of the file"}, {name: "sha384_checksum", description: "SHA384 checksum of the file"}, {name: "sha512_checksum", description: "SHA512 checksum of the file"}, + {name: "is_symlink", description: "The file or its parent is a symlink"}, }, exampleDefault: ` Alert if there are logs older than 1 hour in /tmp: @@ -119,10 +124,20 @@ func (l *CheckFiles) Check(_ context.Context, _ *Agent, check *CheckData, _ []Ar checkPath = l.normalizePath(checkPath) log.Tracef("normalized checkPath: %s", checkPath) - err := filepath.WalkDir(checkPath, func(path string, dirEntry fs.DirEntry, err error) error { - return l.addFile(check, path, checkPath, dirEntry, err) - }) - if err != nil { + walker := &fileWalker{cf: l, check: check, checkPath: checkPath} + + realRoot := checkPath + rootIsSymlink := false + if l.followSymlinks { + if lstat, lerr := os.Lstat(checkPath); lerr == nil && lstat.Mode()&fs.ModeSymlink != 0 { + rootIsSymlink = true + } + if resolved, evalErr := filepath.EvalSymlinks(checkPath); evalErr == nil { + realRoot = resolved + } + } + + if err := walker.walk(realRoot, checkPath, rootIsSymlink); err != nil { return nil, fmt.Errorf("error walking directory %s: %s", checkPath, err.Error()) } } @@ -153,7 +168,88 @@ func (l *CheckFiles) Check(_ context.Context, _ *Agent, check *CheckData, _ []Ar return check.Finalize() } -func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, err error) error { +// fileWalker walks one check path while following symlinks. +// it registers new paths under the checkPath, even if the links take it outside of it +// the depth is calculated with the checkpath being the root +type fileWalker struct { + cf *CheckFiles + check *CheckData + checkPath string // original check path this walk started from + visited []string // real directories already walked , normally or via a symlink +} + +// walk walks realRoot on disk but registers paths under displayRoot +// realRoot is the canonical path on the filesystem +// isSymlink marks whether realRoot was reached via a symlink. +// +//nolint:wrapcheck // filepath walker functions need to return an error, wrapping and appending a header to each call would return a large error message +func (w *fileWalker) walk(realRoot, displayRoot string, isSymlink bool) error { + return filepath.WalkDir(realRoot, func(path string, dirEntry fs.DirEntry, err error) error { + // map the current root under display root + displayPath := path + if rel, relErr := filepath.Rel(realRoot, path); relErr == nil { + displayPath = filepath.Join(displayRoot, rel) + } + + if err != nil { + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err) + } + + // filepath.WalkDir does not follow symlinks, handle it manually + if dirEntry.Type()&fs.ModeSymlink != 0 { + if w.cf.followSymlinks { + return w.followSymlink(path, displayPath) + } + + // if we do not follow symlinks, record the symlink as a plain entry, but do not proceed + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, true, nil) + } + + // remember every real directory we walk into. + // a symlink that later resolves back into already walked territory is recoded as well + // this prevents loops and duplicated subtrees. + if w.cf.followSymlinks && dirEntry.IsDir() { + w.visited = append(w.visited, path) + } + + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err) + }) +} + +// followSymlink resolves the symlink at linkPath +// if its a file, add it as a file entry +// if its a directory, descend into it and continue the search +// keep a list of visited directories to prevent infinite recursion +func (w *fileWalker) followSymlink(linkPath, displayPath string) error { + // os.Stat follows the link and therefore also resolves relative targets correctly + info, err := os.Stat(linkPath) + if err != nil { + // broken symlink (dangling target, loop, ...): record it as an errored file + return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, err) + } + + if !info.IsDir() { + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil) + } + + // resolve to the canonical real path to detect loops + resolvedSymlink, err := filepath.EvalSymlinks(linkPath) + if err != nil { + return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, err) + } + + if slices.Contains(w.visited, resolvedSymlink) { + log.Tracef("not descending into symlink %s, target %s already visited", linkPath, resolvedSymlink) + + // record the symlink itself, but do not walk into it + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil) + } + + // the resolved target is registered by walkFollowingLinks once it is entered + return w.walk(resolvedSymlink, displayPath, true) +} + +func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, isSymlink bool, err error) error { // if the search path is a directory e.g '/usr/bin' , the program assumes you are looking for files/subdirectories under it // therefore it does not add the search path directory to the entry list // if it is a file like /usr/bin/bash however, it will add that @@ -171,6 +267,7 @@ func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry "fullname": path, "type": "file", "check_path": checkPath, + "is_symlink": fmt.Sprintf("%t", isSymlink), } matchAndAdd := func() { From aac401c31ddd353a65051eb6ad0df553f2c8c06b Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Tue, 30 Jun 2026 13:22:29 +0200 Subject: [PATCH 2/5] check_files: more fixes relating to symlinks Add default value to the followSymlinks argument definition Change is_symlink behavior: it is only toggled on if the file/directory itself is a symlink change the unit of is_symlink to UBool, its filters can now parse bool values --- docs/checks/commands/check_files.md | 2 +- pkg/snclient/check_files.go | 39 +++++++++++++++++++---------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/checks/commands/check_files.md b/docs/checks/commands/check_files.md index da464e79..38f0d852 100644 --- a/docs/checks/commands/check_files.md +++ b/docs/checks/commands/check_files.md @@ -62,7 +62,7 @@ Naemon Config | ---------------------------- | -------------------------------------------------------------------------------------- | | calculate-subdirectory-sizes | For subdirectories that are found under the search paths, calculate the subdirectory sizes based on found files. This calculation may be expensive. Default: false | | file | Alias for path | -| follow-symlinks | Follow symlinks of files and subdirectories while traversing. The file paths will be registered originating from search path. | +| follow-symlinks | Follow symlinks of files and subdirectories while traversing. The file paths will be registered originating from search path. Default: true | | max-depth | Maximum recursion depth. Default: no limit. '0' and '1' disable recursion and only include files/directories directly under path., '2' starts to include files/directories of subdirectories with given depth. | | path | Path in which to search for files | | paths | A comma separated list of paths | diff --git a/pkg/snclient/check_files.go b/pkg/snclient/check_files.go index 3bce8ec4..2af4e791 100644 --- a/pkg/snclient/check_files.go +++ b/pkg/snclient/check_files.go @@ -29,6 +29,10 @@ type FileInfoUnified struct { Ctime time.Time // Create time } +const ( + CheckFilesDefaultFollowSymlinks = true +) + type CheckFiles struct { paths []string pathList CommaStringList @@ -44,7 +48,7 @@ func NewCheckFiles() CheckHandler { pattern: "*", maxDepth: int64(-1), calculateSubdirectorySizes: false, - followSymlinks: true, + followSymlinks: CheckFilesDefaultFollowSymlinks, } } @@ -64,8 +68,8 @@ func (l *CheckFiles) Build() *CheckData { "max-depth": {value: &l.maxDepth, description: "Maximum recursion depth. Default: no limit. '0' and '1' disable recursion and only include files/directories directly under path." + ", '2' starts to include files/directories of subdirectories with given depth. "}, "timezone": {description: "Sets the timezone for time metrics (default is local time)"}, - "follow-symlinks": {value: &l.followSymlinks, description: "Follow symlinks of files and subdirectories while traversing. " + - "The file paths will be registered originating from search path."}, + "follow-symlinks": {value: &l.followSymlinks, description: fmt.Sprintf("Follow symlinks of files and subdirectories while traversing. "+ + "The file paths will be registered originating from search path. Default: %t", CheckFilesDefaultFollowSymlinks)}, "calculate-subdirectory-sizes": {value: &l.calculateSubdirectorySizes, description: "For subdirectories that are found under the search paths, " + "calculate the subdirectory sizes based on found files. This calculation may be expensive. Default: false"}, }, @@ -97,7 +101,7 @@ func (l *CheckFiles) Build() *CheckData { {name: "sha256_checksum", description: "SHA256 checksum of the file"}, {name: "sha384_checksum", description: "SHA384 checksum of the file"}, {name: "sha512_checksum", description: "SHA512 checksum of the file"}, - {name: "is_symlink", description: "The file or its parent is a symlink"}, + {name: "is_symlink", description: "The file or its parent is a symlink", unit: UBool}, }, exampleDefault: ` Alert if there are logs older than 1 hour in /tmp: @@ -183,7 +187,7 @@ type fileWalker struct { // isSymlink marks whether realRoot was reached via a symlink. // //nolint:wrapcheck // filepath walker functions need to return an error, wrapping and appending a header to each call would return a large error message -func (w *fileWalker) walk(realRoot, displayRoot string, isSymlink bool) error { +func (w *fileWalker) walk(realRoot, displayRoot string, usedSymlink bool) error { return filepath.WalkDir(realRoot, func(path string, dirEntry fs.DirEntry, err error) error { // map the current root under display root displayPath := path @@ -191,8 +195,17 @@ func (w *fileWalker) walk(realRoot, displayRoot string, isSymlink bool) error { displayPath = filepath.Join(displayRoot, rel) } + // at the real root, isSymlink is not determined through recursion. + // it is passed as usedSymlink before calling walk() for the first time + isSymlink := false + if path == realRoot { + isSymlink = usedSymlink + } else if dirEntry != nil && dirEntry.Type()&fs.ModeSymlink != 0 { + isSymlink = true + } + if err != nil { - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err) } // filepath.WalkDir does not follow symlinks, handle it manually @@ -202,7 +215,7 @@ func (w *fileWalker) walk(realRoot, displayRoot string, isSymlink bool) error { } // if we do not follow symlinks, record the symlink as a plain entry, but do not proceed - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, true, nil) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, nil) } // remember every real directory we walk into. @@ -212,7 +225,7 @@ func (w *fileWalker) walk(realRoot, displayRoot string, isSymlink bool) error { w.visited = append(w.visited, path) } - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err) }) } @@ -225,31 +238,31 @@ func (w *fileWalker) followSymlink(linkPath, displayPath string) error { info, err := os.Stat(linkPath) if err != nil { // broken symlink (dangling target, loop, ...): record it as an errored file - return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, true, err) } if !info.IsDir() { - return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil) + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) } // resolve to the canonical real path to detect loops resolvedSymlink, err := filepath.EvalSymlinks(linkPath) if err != nil { - return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, true, err) } if slices.Contains(w.visited, resolvedSymlink) { log.Tracef("not descending into symlink %s, target %s already visited", linkPath, resolvedSymlink) // record the symlink itself, but do not walk into it - return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil) + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) } // the resolved target is registered by walkFollowingLinks once it is entered return w.walk(resolvedSymlink, displayPath, true) } -func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, isSymlink bool, err error) error { +func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, _, isSymlink bool, err error) error { // if the search path is a directory e.g '/usr/bin' , the program assumes you are looking for files/subdirectories under it // therefore it does not add the search path directory to the entry list // if it is a file like /usr/bin/bash however, it will add that From 9a4a10286c76a03c3735483a942557c2cb6c6953 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Mon, 6 Jul 2026 16:17:38 +0200 Subject: [PATCH 3/5] revise the walking algorithm - now tracks the links as well and uses the resolved paths for tracking visited files/directories. add a new test function for check_files, windows specific for now. uses script to generate symlinks, hardlinks and junctions --- pkg/snclient/check_files.go | 93 +++++++++++++------ pkg/snclient/check_files_helper_linux.go | 12 +++ pkg/snclient/check_files_helper_osx_bsd.go | 12 +++ pkg/snclient/check_files_helper_windows.go | 12 +++ pkg/snclient/check_files_test.go | 92 ++++++++++++++++++ .../scripts/check_files_filesystem_links.ps1 | 56 +++++++++++ 6 files changed, 251 insertions(+), 26 deletions(-) create mode 100644 pkg/snclient/t/scripts/check_files_filesystem_links.ps1 diff --git a/pkg/snclient/check_files.go b/pkg/snclient/check_files.go index 2af4e791..13ada378 100644 --- a/pkg/snclient/check_files.go +++ b/pkg/snclient/check_files.go @@ -133,10 +133,12 @@ func (l *CheckFiles) Check(_ context.Context, _ *Agent, check *CheckData, _ []Ar realRoot := checkPath rootIsSymlink := false if l.followSymlinks { - if lstat, lerr := os.Lstat(checkPath); lerr == nil && lstat.Mode()&fs.ModeSymlink != 0 { + if lstat, lerr := os.Lstat(checkPath); lerr == nil && isLink(lstat) { rootIsSymlink = true } - if resolved, evalErr := filepath.EvalSymlinks(checkPath); evalErr == nil { + if resolved, evalErr := resolveLink(checkPath); evalErr == nil { + realRoot = resolved + } else if resolved, evalErr := filepath.EvalSymlinks(checkPath); evalErr == nil { realRoot = resolved } } @@ -195,43 +197,67 @@ func (w *fileWalker) walk(realRoot, displayRoot string, usedSymlink bool) error displayPath = filepath.Join(displayRoot, rel) } + if w.cf.followSymlinks && dirEntry.IsDir() { + log.Tracef("adding into walked paths of the filewalker: %s", path) + w.visited = append(w.visited, path) + } + // at the real root, isSymlink is not determined through recursion. // it is passed as usedSymlink before calling walk() for the first time isSymlink := false + entryIsLink := false + if path == realRoot { isSymlink = usedSymlink - } else if dirEntry != nil && dirEntry.Type()&fs.ModeSymlink != 0 { - isSymlink = true + // when entered via a symlink, the link itself was already + // recorded by followSymlink, skip re-adding the root + if usedSymlink { + return nil + } + } else if dirEntry != nil { + if fi, fie := dirEntry.Info(); fie == nil && isLink(fi) { + isSymlink = true + entryIsLink = true + } } if err != nil { return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err) } - // filepath.WalkDir does not follow symlinks, handle it manually - if dirEntry.Type()&fs.ModeSymlink != 0 { + if entryIsLink { if w.cf.followSymlinks { - return w.followSymlink(path, displayPath) + linkDepth := w.cf.getDepth(displayPath, w.checkPath) + if w.cf.maxDepth == -1 || linkDepth < w.cf.maxDepth { + return w.followSymlink(path, displayPath) + } } - // if we do not follow symlinks, record the symlink as a plain entry, but do not proceed + // always add links, but only follow them if followSymlinks is on return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, nil) } - // remember every real directory we walk into. - // a symlink that later resolves back into already walked territory is recoded as well - // this prevents loops and duplicated subtrees. - if w.cf.followSymlinks && dirEntry.IsDir() { - w.visited = append(w.visited, path) - } - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err) }) } +// resolveLink resolves a symlink or junction to its canonical target path. +// This resolves junctions on Windows, filepath.EvalSymlinks can fail to do so +func resolveLink(linkPath string) (string, error) { + target, err := os.Readlink(linkPath) + if err != nil { + return "", fmt.Errorf("error when reading link details for path: %s , error: %w", linkPath, err) + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(linkPath), target) + } + + return filepath.Clean(target), nil +} + // followSymlink resolves the symlink at linkPath // if its a file, add it as a file entry -// if its a directory, descend into it and continue the search +// if its a directory, record it and let WalkDir descend normally // keep a list of visited directories to prevent infinite recursion func (w *fileWalker) followSymlink(linkPath, displayPath string) error { // os.Stat follows the link and therefore also resolves relative targets correctly @@ -245,21 +271,28 @@ func (w *fileWalker) followSymlink(linkPath, displayPath string) error { return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) } - // resolve to the canonical real path to detect loops - resolvedSymlink, err := filepath.EvalSymlinks(linkPath) + resolvedSymlink, err := resolveLink(linkPath) + if err != nil { + // fallback to filepath.EvalSymlinks + resolvedSymlink, err = filepath.EvalSymlinks(linkPath) + } if err != nil { return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, true, err) } if slices.Contains(w.visited, resolvedSymlink) { log.Tracef("not descending into symlink %s, target %s already visited", linkPath, resolvedSymlink) + } else { + w.visited = append(w.visited, resolvedSymlink) + } - // record the symlink itself, but do not walk into it - return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) + // record the symlink/junction itself, let WalkDir descend into it naturally + addErr := w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) + if addErr != nil && !errors.Is(addErr, filepath.SkipDir) { + return addErr } - // the resolved target is registered by walkFollowingLinks once it is entered - return w.walk(resolvedSymlink, displayPath, true) + return nil } func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, _, isSymlink bool, err error) error { @@ -365,6 +398,18 @@ func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry return nil } + if err := l.populateEntryDetails(check, entry, fileInfo, path); err != nil { + return err + } + + if err := checkSlowFileOperations(check, entry, path); err != nil { + return err + } + + return nil +} + +func (l *CheckFiles) populateEntryDetails(check *CheckData, entry map[string]string, fileInfo fs.FileInfo, path string) error { fileInfoSys, err := getCheckFileTimes(fileInfo) if err != nil { return fmt.Errorf("type assertion for fileInfo.Sys() failed") @@ -386,10 +431,6 @@ func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry entry["version"] = version } - if err := checkSlowFileOperations(check, entry, path); err != nil { - return err - } - return nil } diff --git a/pkg/snclient/check_files_helper_linux.go b/pkg/snclient/check_files_helper_linux.go index 017fab2a..c42e47be 100644 --- a/pkg/snclient/check_files_helper_linux.go +++ b/pkg/snclient/check_files_helper_linux.go @@ -23,3 +23,15 @@ func getCheckFileTimes(fileInfo fs.FileInfo) (*FileInfoUnified, error) { func getFileVersion(path string) (string, error) { return "0.0.0.0", fmt.Errorf("file version not supported: %s", path) } + +func isLink(fi fs.FileInfo) bool { + return fi.Mode()&fs.ModeSymlink != 0 +} + +func getHardLinkCount(fi fs.FileInfo) uint32 { + if stat, ok := fi.Sys().(*syscall.Stat_t); ok { + return uint32(stat.Nlink) + } + + return 0 +} diff --git a/pkg/snclient/check_files_helper_osx_bsd.go b/pkg/snclient/check_files_helper_osx_bsd.go index fbcfa524..f63e2fbf 100644 --- a/pkg/snclient/check_files_helper_osx_bsd.go +++ b/pkg/snclient/check_files_helper_osx_bsd.go @@ -25,3 +25,15 @@ func getCheckFileTimes(fileInfo fs.FileInfo) (*FileInfoUnified, error) { func getFileVersion(path string) (string, error) { return "0.0.0.0", fmt.Errorf("file version not supported: %s", path) } + +func isLink(fi fs.FileInfo) bool { + return fi.Mode()&fs.ModeSymlink != 0 +} + +func getHardLinkCount(fi fs.FileInfo) uint32 { + if stat, ok := fi.Sys().(*syscall.Stat_t); ok { + return uint32(stat.Nlink) + } + + return 0 +} diff --git a/pkg/snclient/check_files_helper_windows.go b/pkg/snclient/check_files_helper_windows.go index cfec8998..1246c2c5 100644 --- a/pkg/snclient/check_files_helper_windows.go +++ b/pkg/snclient/check_files_helper_windows.go @@ -30,3 +30,15 @@ func getFileVersion(path string) (string, error) { return f.FixedInfo().FileVersion.String(), nil } + +func isLink(fi fs.FileInfo) bool { + if data, ok := fi.Sys().(*syscall.Win32FileAttributeData); ok { + return data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 + } + + return fi.Mode()&fs.ModeSymlink != 0 +} + +func getHardLinkCount(_ fs.FileInfo) uint32 { + return 0 +} diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index ac5905b0..83d731ee 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -523,3 +523,95 @@ func TestCheckFilesSizePerfdata(t *testing.T) { StopTestAgent(t, snc) } + +func TestCheckFilesFilesystemLinks(t *testing.T) { + testDir, _ := os.Getwd() + scriptsDir := filepath.Join(testDir, "t", "scripts") + scriptName := "check_files_filesystem_links" + scriptFilename := scriptName + + switch runtime.GOOS { + case "windows": + scriptFilename += ".ps1" + case "linux": + scriptFilename += ".sh" + case "darwin": + // t.Skip("Skipping on darwin as it does not 'date' command does not work in the script") + scriptFilename += ".sh" + default: + t.Skipf("Test is not intended to be run on %s", runtime.GOOS) + } + + config := checkFilesTestConfigWithScript(t, scriptsDir, scriptName, scriptFilename) + snc := StartTestAgent(t, config) + + geneartionDirectory := t.TempDir() + + res := snc.RunCheck(scriptName, []string{geneartionDirectory}) + assert.Equalf(t, CheckExitOK, res.State, "script return state check is correct") + outputString := string(res.BuildPluginOutput()) + + assert.Containsf(t, outputString, "ok - Generated 3 files for testing", "output matches") + assert.Containsf(t, outputString, "ok - Generated 3 directories for testing", "output matches") + + // check_files_filesystem_links script generates a structure using the filesystem links + + // On Windows it looks like this + // tree /f 'C:\Users\sorus\repositories\snclient\pkg\snclient\t\link_test' + // C:\USERS\SORUS\REPOSITORIES\SNCLIENT\PKG\SNCLIENT\T\LINK_TEST + // │ file1_hardlink1.txt + // │ file1_symlink1.txt + // │ + // ├───dir1 + // │ file1.txt + // │ + // ├───dir1_junction1 + // │ file1.txt + // │ + // └───dir1_symlink1 + // file1.txt + + switch runtime.GOOS { + case "windows": + assert.Containsf(t, outputString, `symbolic link created for dir1_symlink1 <<===>> dir1`, "output matches") + assert.Containsf(t, outputString, `symbolic link created for file1_symlink1.txt <<===>> dir1\file1.txt`, "output matches") + assert.Containsf(t, outputString, `Hardlink created for file1_hardlink1.txt <<===>> dir1\file1.txt`, "output matches") + assert.Containsf(t, outputString, `Junction created for dir1_junction1 <<===>> dir1`, "output matches") + case "linux": + case "darwin": + } + + // On Windows, junctions are reported as files + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "file1.txt", "output has file") + assert.Containsf(t, outputString, "file1_symlink1.txt", "output does have symlink file") + assert.Containsf(t, outputString, "file1_hardlink1.txt", "output does have hardlink file") + assert.NotContainsf(t, outputString, "dir1", "output does not have directory") + assert.NotContainsf(t, outputString, "dir1_junction1", "output does not have junction directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file and is_symlink eq true", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "file1_symlink1.txt", "output does have symlink file") + assert.NotContainsf(t, outputString, "file1_hardlink1.txt", "output does not have hardlink file") + assert.NotContainsf(t, outputString, "dir1", "output does not have directory") + assert.NotContainsf(t, outputString, "dir1_junction1", "output does not have junction directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "dir1", "output has directory") + assert.Containsf(t, outputString, "dir1_junction1", "output has directory") + assert.Containsf(t, outputString, "dir1_symlink1", "output has directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir and is_symlink eq true", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "dir1_junction1", "output has directory") + assert.Containsf(t, outputString, "dir1_symlink1", "output has directory") + assert.NotContainsf(t, strings.FieldsFunc(outputString, func(r rune) bool { return r == ' ' || r == ',' }), "dir1", "output does not have directory") + + StopTestAgent(t, snc) +} diff --git a/pkg/snclient/t/scripts/check_files_filesystem_links.ps1 b/pkg/snclient/t/scripts/check_files_filesystem_links.ps1 new file mode 100644 index 00000000..33c3494c --- /dev/null +++ b/pkg/snclient/t/scripts/check_files_filesystem_links.ps1 @@ -0,0 +1,56 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory=$true)] + [string]$TestingDir +) + +$ErrorActionPreference = 'Stop' + +New-Item -ItemType Directory -Path $TestingDir -Force + +Push-Location -Path $TestingDir +try { + # Since the new location is pushed on to the stack, it is automatically applied. + + New-Item -ItemType Directory -Path "dir1" -Force > $null + + New-Item -ItemType File -Path "dir1/file1.txt" -Force > $null + $string = "This is a test line.`r`n" + $content = $string * 100 + Set-Content -Path "dir1/file1.txt" -Value $content -NoNewline + + # Symbolic link to folder + cmd /c mklink /d "dir1_symlink1" "dir1" + + # Symbolic link to file + cmd /c mklink "file1_symlink1.txt" "dir1\file1.txt" + + # Hard link to file + cmd /c mklink /h "file1_hardlink1.txt" "dir1\file1.txt" + + # Junction to folder + cmd /c mklink /j "dir1_junction1" "dir1" + + $fileCount = (Get-ChildItem -Recurse -File | Measure-Object).Count + Write-Output "ok - Generated $fileCount files for testing" + + $dirCount = (Get-ChildItem -Recurse -Directory | Measure-Object).Count + Write-Output "ok - Generated $dirCount directories for testing" + + Write-Output "printing the tree of the files" + # if tree is available, use it. Otherwise we have to use a for loop + if (Get-Command tree -ErrorAction SilentlyContinue) { + tree /f . + } else { + Get-ChildItem -Recurse | Sort-Object FullName | ForEach-Object { + $relativePath = $_.FullName.Substring($PWD.Path.Length).TrimStart([io.path]::DirectorySeparatorChar) + Write-Output $relativePath + } + } +} +finally { + Pop-Location +} + +exit 0 From d6488f7c1eab9c1ad16448de8d9c554f28812549 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 6 Jul 2026 17:23:24 +0200 Subject: [PATCH 4/5] check_files: track seen inodes in the file walker as well. this is to detect hard links and skip over them add bash script to generate filesystem links, and write linux/darwin test for them --- pkg/snclient/check_files.go | 56 ++++--- pkg/snclient/check_files_helper_linux.go | 9 +- pkg/snclient/check_files_helper_osx_bsd.go | 9 +- pkg/snclient/check_files_helper_windows.go | 4 +- pkg/snclient/check_files_test.go | 147 +++++++++++------- .../t/scripts/check_files_filesystem_links.sh | 58 +++++++ 6 files changed, 202 insertions(+), 81 deletions(-) create mode 100755 pkg/snclient/t/scripts/check_files_filesystem_links.sh diff --git a/pkg/snclient/check_files.go b/pkg/snclient/check_files.go index 13ada378..2e6da36a 100644 --- a/pkg/snclient/check_files.go +++ b/pkg/snclient/check_files.go @@ -128,7 +128,7 @@ func (l *CheckFiles) Check(_ context.Context, _ *Agent, check *CheckData, _ []Ar checkPath = l.normalizePath(checkPath) log.Tracef("normalized checkPath: %s", checkPath) - walker := &fileWalker{cf: l, check: check, checkPath: checkPath} + walker := &fileWalker{cf: l, check: check, checkPath: checkPath, seenInodes: make(map[uint64]bool)} realRoot := checkPath rootIsSymlink := false @@ -178,10 +178,11 @@ func (l *CheckFiles) Check(_ context.Context, _ *Agent, check *CheckData, _ []Ar // it registers new paths under the checkPath, even if the links take it outside of it // the depth is calculated with the checkpath being the root type fileWalker struct { - cf *CheckFiles - check *CheckData - checkPath string // original check path this walk started from - visited []string // real directories already walked , normally or via a symlink + cf *CheckFiles + check *CheckData + checkPath string // original check path this walk started from + visited []string // real directories already walked , normally or via a symlink + seenInodes map[uint64]bool // track inodes to skip hard links } // walk walks realRoot on disk but registers paths under displayRoot @@ -197,7 +198,7 @@ func (w *fileWalker) walk(realRoot, displayRoot string, usedSymlink bool) error displayPath = filepath.Join(displayRoot, rel) } - if w.cf.followSymlinks && dirEntry.IsDir() { + if w.cf.followSymlinks && dirEntry != nil && dirEntry.IsDir() { log.Tracef("adding into walked paths of the filewalker: %s", path) w.visited = append(w.visited, path) } @@ -222,7 +223,7 @@ func (w *fileWalker) walk(realRoot, displayRoot string, usedSymlink bool) error } if err != nil { - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err, w.seenInodes) } if entryIsLink { @@ -234,10 +235,10 @@ func (w *fileWalker) walk(realRoot, displayRoot string, usedSymlink bool) error } // always add links, but only follow them if followSymlinks is on - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, nil) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, nil, w.seenInodes) } - return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, usedSymlink, isSymlink, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, dirEntry, isSymlink, err, w.seenInodes) }) } @@ -264,11 +265,11 @@ func (w *fileWalker) followSymlink(linkPath, displayPath string) error { info, err := os.Stat(linkPath) if err != nil { // broken symlink (dangling target, loop, ...): record it as an errored file - return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, true, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, err, w.seenInodes) } if !info.IsDir() { - return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) + return w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil, w.seenInodes) } resolvedSymlink, err := resolveLink(linkPath) @@ -277,7 +278,7 @@ func (w *fileWalker) followSymlink(linkPath, displayPath string) error { resolvedSymlink, err = filepath.EvalSymlinks(linkPath) } if err != nil { - return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, true, err) + return w.cf.addFile(w.check, displayPath, w.checkPath, nil, true, err, w.seenInodes) } if slices.Contains(w.visited, resolvedSymlink) { @@ -287,7 +288,7 @@ func (w *fileWalker) followSymlink(linkPath, displayPath string) error { } // record the symlink/junction itself, let WalkDir descend into it naturally - addErr := w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, true, nil) + addErr := w.cf.addFile(w.check, displayPath, w.checkPath, fs.FileInfoToDirEntry(info), true, nil, w.seenInodes) if addErr != nil && !errors.Is(addErr, filepath.SkipDir) { return addErr } @@ -295,7 +296,8 @@ func (w *fileWalker) followSymlink(linkPath, displayPath string) error { return nil } -func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, _, isSymlink bool, err error) error { +//nolint:gocyclo // need to perform a lot of checks before adding a file +func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry fs.DirEntry, isSymlink bool, err error, seenInodes map[uint64]bool) error { // if the search path is a directory e.g '/usr/bin' , the program assumes you are looking for files/subdirectories under it // therefore it does not add the search path directory to the entry list // if it is a file like /usr/bin/bash however, it will add that @@ -368,17 +370,36 @@ func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry } // check filter and pattern before doing more expensive things - // pattern matching is only done on files, directories are always added + // pattern matching is only done on files and to their filenames, directories are always added // directories that do not have any matched files under them are later removed if match, _ := filepath.Match(l.pattern, entry["filename"]); entry["type"] == "file" && !match { log.Tracef("filename: %s did not match the pattern: %s , skipping", entry["filename"], l.pattern) return nil } + // check if it passes through the check filter if !check.MatchMapCondition(check.filter, entry, true) { return nil } + var fileInfo fs.FileInfo + var fileInfoErr error + + if dirEntry != nil { + fileInfo, fileInfoErr = dirEntry.Info() + } + + if dirEntry != nil && entry["type"] == "file" && !isSymlink && fileInfo != nil && fileInfoErr == nil { + if inode, ok := getFileInode(fileInfo); ok && inode > 0 { + if seenInodes[inode] { + log.Tracef("skipping hard link (inode %d already seen): %s", inode, path) + + return nil + } + seenInodes[inode] = true + } + } + defer matchAndAdd() // check for errors here, maybe the file would have been filtered out anyway @@ -388,12 +409,11 @@ func (l *CheckFiles) addFile(check *CheckData, path, checkPath string, dirEntry return nil } - fileInfo, err := dirEntry.Info() - if err != nil { + if fileInfoErr != nil { if dirEntry != nil && dirEntry.IsDir() { return fs.SkipDir } - l.setError(entry, err) + l.setError(entry, fileInfoErr) return nil } diff --git a/pkg/snclient/check_files_helper_linux.go b/pkg/snclient/check_files_helper_linux.go index c42e47be..319414d0 100644 --- a/pkg/snclient/check_files_helper_linux.go +++ b/pkg/snclient/check_files_helper_linux.go @@ -28,10 +28,11 @@ func isLink(fi fs.FileInfo) bool { return fi.Mode()&fs.ModeSymlink != 0 } -func getHardLinkCount(fi fs.FileInfo) uint32 { - if stat, ok := fi.Sys().(*syscall.Stat_t); ok { - return uint32(stat.Nlink) +func getFileInode(fi fs.FileInfo) (uint64, bool) { + stat, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return 0, false } - return 0 + return stat.Ino, true } diff --git a/pkg/snclient/check_files_helper_osx_bsd.go b/pkg/snclient/check_files_helper_osx_bsd.go index f63e2fbf..753af6fd 100644 --- a/pkg/snclient/check_files_helper_osx_bsd.go +++ b/pkg/snclient/check_files_helper_osx_bsd.go @@ -30,10 +30,11 @@ func isLink(fi fs.FileInfo) bool { return fi.Mode()&fs.ModeSymlink != 0 } -func getHardLinkCount(fi fs.FileInfo) uint32 { - if stat, ok := fi.Sys().(*syscall.Stat_t); ok { - return uint32(stat.Nlink) +func getFileInode(fi fs.FileInfo) (uint64, bool) { + stat, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return 0, false } - return 0 + return stat.Ino, true } diff --git a/pkg/snclient/check_files_helper_windows.go b/pkg/snclient/check_files_helper_windows.go index 1246c2c5..8dc9394a 100644 --- a/pkg/snclient/check_files_helper_windows.go +++ b/pkg/snclient/check_files_helper_windows.go @@ -39,6 +39,6 @@ func isLink(fi fs.FileInfo) bool { return fi.Mode()&fs.ModeSymlink != 0 } -func getHardLinkCount(_ fs.FileInfo) uint32 { - return 0 +func getFileInode(_ fs.FileInfo) (uint64, bool) { + return 0, false } diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index 83d731ee..77081790 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -536,7 +536,6 @@ func TestCheckFilesFilesystemLinks(t *testing.T) { case "linux": scriptFilename += ".sh" case "darwin": - // t.Skip("Skipping on darwin as it does not 'date' command does not work in the script") scriptFilename += ".sh" default: t.Skipf("Test is not intended to be run on %s", runtime.GOOS) @@ -551,67 +550,109 @@ func TestCheckFilesFilesystemLinks(t *testing.T) { assert.Equalf(t, CheckExitOK, res.State, "script return state check is correct") outputString := string(res.BuildPluginOutput()) - assert.Containsf(t, outputString, "ok - Generated 3 files for testing", "output matches") - assert.Containsf(t, outputString, "ok - Generated 3 directories for testing", "output matches") - // check_files_filesystem_links script generates a structure using the filesystem links - // On Windows it looks like this - // tree /f 'C:\Users\sorus\repositories\snclient\pkg\snclient\t\link_test' - // C:\USERS\SORUS\REPOSITORIES\SNCLIENT\PKG\SNCLIENT\T\LINK_TEST - // │ file1_hardlink1.txt - // │ file1_symlink1.txt - // │ - // ├───dir1 - // │ file1.txt - // │ - // ├───dir1_junction1 - // │ file1.txt - // │ - // └───dir1_symlink1 - // file1.txt - switch runtime.GOOS { case "windows": + + // the powershell script generates a filetree that looks like this + // \link_test + // │ file1_hardlink1.txt + // │ file1_symlink1.txt + // ├───dir1 + // │ file1.txt + // ├───dir1_junction1 + // │ file1.txt + // ├───dir1_symlink1 + // └───file1.txt + assert.Containsf(t, outputString, `symbolic link created for dir1_symlink1 <<===>> dir1`, "output matches") assert.Containsf(t, outputString, `symbolic link created for file1_symlink1.txt <<===>> dir1\file1.txt`, "output matches") assert.Containsf(t, outputString, `Hardlink created for file1_hardlink1.txt <<===>> dir1\file1.txt`, "output matches") assert.Containsf(t, outputString, `Junction created for dir1_junction1 <<===>> dir1`, "output matches") - case "linux": - case "darwin": - } - - // On Windows, junctions are reported as files - res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file", "show-all"}) - assert.Equalf(t, CheckExitOK, res.State, "state OK") - outputString = string(res.BuildPluginOutput()) - assert.Containsf(t, outputString, "file1.txt", "output has file") - assert.Containsf(t, outputString, "file1_symlink1.txt", "output does have symlink file") - assert.Containsf(t, outputString, "file1_hardlink1.txt", "output does have hardlink file") - assert.NotContainsf(t, outputString, "dir1", "output does not have directory") - assert.NotContainsf(t, outputString, "dir1_junction1", "output does not have junction directory") - res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file and is_symlink eq true", "show-all"}) - assert.Equalf(t, CheckExitOK, res.State, "state OK") - outputString = string(res.BuildPluginOutput()) - assert.Containsf(t, outputString, "file1_symlink1.txt", "output does have symlink file") - assert.NotContainsf(t, outputString, "file1_hardlink1.txt", "output does not have hardlink file") - assert.NotContainsf(t, outputString, "dir1", "output does not have directory") - assert.NotContainsf(t, outputString, "dir1_junction1", "output does not have junction directory") - - res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir", "show-all"}) - assert.Equalf(t, CheckExitOK, res.State, "state OK") - outputString = string(res.BuildPluginOutput()) - assert.Containsf(t, outputString, "dir1", "output has directory") - assert.Containsf(t, outputString, "dir1_junction1", "output has directory") - assert.Containsf(t, outputString, "dir1_symlink1", "output has directory") - - res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir and is_symlink eq true", "show-all"}) - assert.Equalf(t, CheckExitOK, res.State, "state OK") - outputString = string(res.BuildPluginOutput()) - assert.Containsf(t, outputString, "dir1_junction1", "output has directory") - assert.Containsf(t, outputString, "dir1_symlink1", "output has directory") - assert.NotContainsf(t, strings.FieldsFunc(outputString, func(r rune) bool { return r == ' ' || r == ',' }), "dir1", "output does not have directory") + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "file1.txt", "output has file") + assert.Containsf(t, outputString, "file1_symlink1.txt", "output does have symlink file") + assert.Containsf(t, outputString, "file1_hardlink1.txt", "output does have hardlink file") + assert.NotContainsf(t, outputString, "dir1", "output does not have directory") + assert.NotContainsf(t, outputString, "dir1_junction1", "output does not have junction directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file and is_symlink eq true", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "file1_symlink1.txt", "output does have symlink file") + assert.NotContainsf(t, outputString, "file1_hardlink1.txt", "output does not have hardlink file") + assert.NotContainsf(t, outputString, "dir1", "output does not have directory") + assert.NotContainsf(t, outputString, "dir1_junction1", "output does not have junction directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "dir1", "output has directory") + assert.Containsf(t, outputString, "dir1_junction1", "output has directory") + assert.Containsf(t, outputString, "dir1_symlink1", "output has directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir and is_symlink eq true", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "dir1_junction1", "output has directory") + assert.Containsf(t, outputString, "dir1_symlink1", "output has directory") + assert.NotContainsf(t, strings.FieldsFunc(outputString, func(r rune) bool { return r == ' ' || r == ',' }), "dir1", "output does not have directory") + + case "linux", "darwin": + + // bash script generates a filetree that looks like this + // /link_test + // ├── dir1 + // │ └── file1.txt + // ├── dir1_relative1 -> dir1 + // ├── dir1_symbolic1 -> dir1 + // ├── file1_physical1.txt + // ├── file1_relative1.txt -> dir1/file1.txt + // └── file1_symbolic1.txt -> dir1/file1.txt + + assert.Containsf(t, outputString, "ok - Generated 2 files for testing", "output matches") + assert.Containsf(t, outputString, "ok - Generated 1 directories for testing", "output matches") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "file1.txt", "output has file") + assert.Containsf(t, outputString, "file1_symbolic1.txt", "output does have symlink file") + assert.Containsf(t, outputString, "file1_relative1.txt", "output does have relative symlink file") + assert.NotContainsf(t, outputString, "file1_physical1.txt", "output does not have hardlink file") + assert.NotContainsf(t, outputString, "dir1", "output does not have directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + assert.Containsf(t, string(res.BuildPluginOutput()), "All 3 files are ok", "should count 3 files (excluding hard link)") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq file and is_symlink eq true", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "file1_symbolic1.txt", "output does have symlink file") + assert.Containsf(t, outputString, "file1_relative1.txt", "output does have relative symlink file") + assert.NotContainsf(t, outputString, "file1_physical1.txt", "output does not have hardlink file") + assert.NotContainsf(t, outputString, "file1.txt", "output does not have regular file") + assert.NotContainsf(t, outputString, "dir1", "output does not have directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "dir1", "output has directory") + assert.Containsf(t, outputString, "dir1_symbolic1", "output has symlink directory") + assert.Containsf(t, outputString, "dir1_relative1", "output has relative symlink directory") + + res = snc.RunCheck("check_files", []string{"path=" + geneartionDirectory, "filter=type eq dir and is_symlink eq true", "show-all"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "dir1_symbolic1", "output has symlink directory") + assert.Containsf(t, outputString, "dir1_relative1", "output has relative symlink directory") + assert.NotContainsf(t, strings.FieldsFunc(outputString, func(r rune) bool { return r == ' ' || r == ',' }), "dir1", "output does not have regular directory") + } StopTestAgent(t, snc) } diff --git a/pkg/snclient/t/scripts/check_files_filesystem_links.sh b/pkg/snclient/t/scripts/check_files_filesystem_links.sh new file mode 100755 index 00000000..80d1ba29 --- /dev/null +++ b/pkg/snclient/t/scripts/check_files_filesystem_links.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# This script is intended to generate systemlink files to be used in a test +# These files need to be generated dynamically, so they cant be simply saved in the repository. + +set -o errexit +set -o nounset +set -o pipefail + +TESTING_DIR="$1" + +mkdir -p "${TESTING_DIR}" + +( + cd "${TESTING_DIR}" + + mkdir -p "dir1" + + touch "dir1/file1.txt" + echo > "dir1/file1.txt" + + for ((i=1; i<=100; i++)); do + printf 'This is a test line \n' >> "dir1/file1.txt" + done + + # Symbolic link to folder + ln --symbolic "dir1" "dir1_symbolic1" + + # Symbolic link to file + ln --symbolic "dir1/file1.txt" "file1_symbolic1.txt" + + # Relative link to folder + ln --symbolic --relative "dir1" "dir1_relative1" + + # Relative link to file + ln --symbolic --relative "dir1/file1.txt" "file1_relative1.txt" + + # Physical links to directories are not allowed + # ln --physical "dir1" "dir1_physical1" + + # Physical link to file + ln --physical "dir1/file1.txt" "file1_physical1.txt" + + FILE_COUNT=$(find "${TESTING_DIR}" -type f -printf "." | wc -c) + echo "ok - Generated ${FILE_COUNT} files for testing" + + DIRECTORY_COUNT=$(find "${TESTING_DIR}" -type d -printf "." | wc -c) + echo "ok - Generated $(( DIRECTORY_COUNT - 1 )) directories for testing" + + if command -v tree >/dev/null 2>&1; then + echo "printing the tree of the files" + tree "${TESTING_DIR}" + else + echo "warning: tree command not found, skipping tree output" + fi +) + +exit 0 From 961da0030bd717191043c12f8aff81dfc69ce8bd Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 6 Jul 2026 18:30:26 +0200 Subject: [PATCH 5/5] check_files: ai pass - rewrite filesystem_link script for darwin --- .../t/scripts/check_files_filesystem_links.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/snclient/t/scripts/check_files_filesystem_links.sh b/pkg/snclient/t/scripts/check_files_filesystem_links.sh index 80d1ba29..3fb5da16 100755 --- a/pkg/snclient/t/scripts/check_files_filesystem_links.sh +++ b/pkg/snclient/t/scripts/check_files_filesystem_links.sh @@ -24,27 +24,27 @@ mkdir -p "${TESTING_DIR}" done # Symbolic link to folder - ln --symbolic "dir1" "dir1_symbolic1" + ln -s "dir1" "dir1_symbolic1" # Symbolic link to file - ln --symbolic "dir1/file1.txt" "file1_symbolic1.txt" + ln -s "dir1/file1.txt" "file1_symbolic1.txt" # Relative link to folder - ln --symbolic --relative "dir1" "dir1_relative1" + ln -s "dir1" "dir1_relative1" # Relative link to file - ln --symbolic --relative "dir1/file1.txt" "file1_relative1.txt" + ln -s "dir1/file1.txt" "file1_relative1.txt" # Physical links to directories are not allowed - # ln --physical "dir1" "dir1_physical1" + # ln "dir1" "dir1_physical1" # Physical link to file - ln --physical "dir1/file1.txt" "file1_physical1.txt" + ln "dir1/file1.txt" "file1_physical1.txt" - FILE_COUNT=$(find "${TESTING_DIR}" -type f -printf "." | wc -c) + FILE_COUNT=$(find "${TESTING_DIR}" -type f | wc -l) echo "ok - Generated ${FILE_COUNT} files for testing" - DIRECTORY_COUNT=$(find "${TESTING_DIR}" -type d -printf "." | wc -c) + DIRECTORY_COUNT=$(find "${TESTING_DIR}" -type d | wc -l) echo "ok - Generated $(( DIRECTORY_COUNT - 1 )) directories for testing" if command -v tree >/dev/null 2>&1; then