From e8f0c83ef681ebd152598a3307b1af00d08c39af Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 31 Aug 2026 12:10:29 +0200 Subject: [PATCH 1/7] ai assisted: add disksize attribute. uses the abstracted fileInfo struct of the golang in the linux/bsd , but has to do another windows file handle createFile operation to get its stats. golang strips the information out in its standard library. the disksize is an optional attribute, only added when add-disk-size argument is true total_disksize and total_diskbytes are also added as metrics --- docs/checks/commands/check_files.md | 4 + pkg/snclient/check_files.go | 57 ++++++ pkg/snclient/check_files_helper_linux.go | 15 ++ pkg/snclient/check_files_helper_osx_bsd.go | 15 ++ pkg/snclient/check_files_test.go | 213 +++++++++++++++++++++ pkg/snclient/check_files_windows.go | 50 +++++ pkg/snclient/check_files_windows_test.go | 49 +++++ 7 files changed, 403 insertions(+) create mode 100644 pkg/snclient/check_files_windows.go diff --git a/docs/checks/commands/check_files.md b/docs/checks/commands/check_files.md index 96608d30..c93ab714 100644 --- a/docs/checks/commands/check_files.md +++ b/docs/checks/commands/check_files.md @@ -60,6 +60,7 @@ Naemon Config | Argument | Description | | ---------------------------- | -------------------------------------------------------------------------------------- | +| add-disk-size | Add the disk size used on disk for each file and directory. On unix systems this is calculated from the allocated blocks, on windows an additional API call is required for each entry. This calculation may be expensive. Default: false | | add-files-only-once | When following symlinks, same file can be added multiple times. Enable this to track added files and stop adding them twice. Files will be added using the first path they were encountered with. Default: false | | 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 | @@ -89,6 +90,7 @@ these can be used in filters and thresholds (along with the default attributes): | access | Unix timestamp of last access time | | creation | Unix timestamp when file was created | | size | File size in bytes | +| disksize | File size on disk in bytes | | written | Unix timestamp when file was last written to | | write | Alias for written | | age | Seconds since file was last written | @@ -96,6 +98,8 @@ these can be used in filters and thresholds (along with the default attributes): | line_count | Number of lines in the files (text files) | | total_bytes | Total size over all files in bytes | | total_size | Total size over all files as human readable bytes | +| total_diskbytes | Total disk size over all files in bytes | +| total_disksize | Total disk size over all files as human readable bytes | | md5_checksum | MD5 checksum of the file | | sha1_checksum | SHA1 checksum of the file | | sha256_checksum | SHA256 checksum of the file | diff --git a/pkg/snclient/check_files.go b/pkg/snclient/check_files.go index 24dc5a36..425c99ff 100644 --- a/pkg/snclient/check_files.go +++ b/pkg/snclient/check_files.go @@ -51,6 +51,7 @@ type CheckFiles struct { calculateSubdirectorySizes bool // constructor NewCheckFiles sets this as false followSymlinks bool addFilesOnlyOnce bool + addDiskSize bool // constructor NewCheckFiles sets this as false maxFiles int64 // maximum number of files } @@ -62,6 +63,7 @@ func NewCheckFiles() CheckHandler { calculateSubdirectorySizes: false, followSymlinks: CheckFilesDefaultFollowSymlinks, addFilesOnlyOnce: CheckFilesDefaultAddFilesOnlyOnce, + addDiskSize: false, maxFiles: CheckFilesDefaultMaxFiles, } } @@ -88,6 +90,8 @@ func (l *CheckFiles) Build() *CheckData { " Enable this to track added files and stop adding them twice. Files will be added using the first path they were encountered with. Default: %t", CheckFilesDefaultAddFilesOnlyOnce)}, "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"}, + "add-disk-size": {value: &l.addDiskSize, description: "Add the disk size used on disk for each file and directory. On unix systems this is " + + "calculated from the allocated blocks, on windows an additional API call is required for each entry. This calculation may be expensive. Default: false"}, "max-files": {value: &l.maxFiles, description: fmt.Sprintf("Maximum number of files to process. Default: %d", CheckFilesDefaultMaxFiles)}, }, detailSyntax: "%(name)", @@ -106,6 +110,7 @@ func (l *CheckFiles) Build() *CheckData { {name: "access", description: "Unix timestamp of last access time", unit: UDate}, {name: "creation", description: "Unix timestamp when file was created", unit: UDate}, {name: "size", description: "File size in bytes", unit: UByte}, + {name: "disksize", description: "File size on disk in bytes", unit: UByte}, {name: "written", description: "Unix timestamp when file was last written to", unit: UDate}, {name: "write", description: "Alias for written", unit: UDate}, {name: "age", description: "Seconds since file was last written", unit: UDuration}, @@ -113,6 +118,8 @@ func (l *CheckFiles) Build() *CheckData { {name: "line_count", description: "Number of lines in the files (text files)"}, {name: "total_bytes", description: "Total size over all files in bytes", unit: UByte}, {name: "total_size", description: "Total size over all files as human readable bytes", unit: UByte}, + {name: "total_diskbytes", description: "Total disk size over all files in bytes", unit: UByte}, + {name: "total_disksize", description: "Total disk size over all files as human readable bytes", unit: UByte}, {name: "md5_checksum", description: "MD5 checksum of the file"}, {name: "sha1_checksum", description: "SHA1 checksum of the file"}, {name: "sha256_checksum", description: "SHA256 checksum of the file"}, @@ -518,6 +525,14 @@ func (l *CheckFiles) populateEntryDetails(check *CheckData, entry map[string]str entry["write"] = fmt.Sprintf("%d", fileInfoSys.Mtime.Unix()) entry["written"] = fmt.Sprintf("%d", fileInfoSys.Mtime.Unix()) + if l.addDiskSize { + if diskSize, diskErr := getFileDiskSize(fileInfo, path); diskErr != nil { + log.Debugf("could not get disk size for %s: %s", path, diskErr.Error()) + } else { + entry["disksize"] = fmt.Sprintf("%d", diskSize) + } + } + needVersion := check.HasThreshold("version") || check.HasMacro("version") if needVersion { version, err := getFileVersion(path) @@ -623,6 +638,34 @@ func (l *CheckFiles) addGeneralMetrics(check *CheckData) { } } + // only calculate the total disk size when the attribute has been populated for the entries + if l.addDiskSize { + totalDiskSize := uint64(0) + for _, data := range check.listData { + if data["type"] == "file" { + totalDiskSize += convert.UInt64(data["disksize"]) + } + } + + if len(check.listData) > 0 || check.emptySyntax == "" { + check.details["total_diskbytes"] = fmt.Sprintf("%d", totalDiskSize) + check.details["total_disksize"] = humanize.IBytesF(convert.UInt64(totalDiskSize), 2) + } + + if check.HasThreshold("total_disksize") { + check.result.Metrics = append(check.result.Metrics, + &CheckMetric{ + ThresholdName: "total_disksize", + Name: "total_disksize", + Value: totalDiskSize, + Unit: "B", + Warning: check.warnThreshold, + Critical: check.critThreshold, + Min: &Zero, + }) + } + } + // files do not have a 'count' atrribute, so this wont collide like 'size' would. No need for 'totalCount' if check.HasThreshold("count") { check.result.Metrics = append(check.result.Metrics, @@ -735,8 +778,10 @@ func (l *CheckFiles) addSubDirMetrics(check *CheckData) { } } +//nolint:funlen // the length is ok, function is not complex func (l *CheckFiles) addFileMetrics(check *CheckData) { needSize := check.HasThreshold("size") + needDiskSize := l.addDiskSize && check.HasThreshold("disksize") needAge := check.HasThreshold("age") needAccess := check.HasThreshold("access") needWritten := check.HasThreshold("written") @@ -755,6 +800,18 @@ func (l *CheckFiles) addFileMetrics(check *CheckData) { Min: &Zero, }) } + if needDiskSize { + check.result.Metrics = append(check.result.Metrics, + &CheckMetric{ + ThresholdName: "disksize", + Name: data["filename"] + " " + "disksize", + Value: convert.UInt64(data["disksize"]), + Unit: "B", + Warning: check.warnThreshold, + Critical: check.critThreshold, + Min: &Zero, + }) + } if needAge { check.result.Metrics = append(check.result.Metrics, &CheckMetric{ diff --git a/pkg/snclient/check_files_helper_linux.go b/pkg/snclient/check_files_helper_linux.go index 319414d0..8469e714 100644 --- a/pkg/snclient/check_files_helper_linux.go +++ b/pkg/snclient/check_files_helper_linux.go @@ -36,3 +36,18 @@ func getFileInode(fi fs.FileInfo) (uint64, bool) { return stat.Ino, true } + +// POSIX st_blocks is always reported in 512 byte units, this is independent of the filesystem block size +const statBlockSizeBytes = 512 + +func getFileDiskSize(fileInfo fs.FileInfo, _ string) (uint64, error) { + stat, ok := fileInfo.Sys().(*syscall.Stat_t) + if !ok { + return 0, fmt.Errorf("type assertion for fileInfo.Sys() failed") + } + if stat.Blocks < 0 { + return 0, fmt.Errorf("invalid negative block count: %d", stat.Blocks) + } + + return uint64(stat.Blocks) * statBlockSizeBytes, nil +} diff --git a/pkg/snclient/check_files_helper_osx_bsd.go b/pkg/snclient/check_files_helper_osx_bsd.go index 753af6fd..f976e96c 100644 --- a/pkg/snclient/check_files_helper_osx_bsd.go +++ b/pkg/snclient/check_files_helper_osx_bsd.go @@ -38,3 +38,18 @@ func getFileInode(fi fs.FileInfo) (uint64, bool) { return stat.Ino, true } + +// POSIX st_blocks is always reported in 512 byte units, this is independent of the filesystem block size +const statBlockSizeBytes = 512 + +func getFileDiskSize(fileInfo fs.FileInfo, _ string) (uint64, error) { + stat, ok := fileInfo.Sys().(*syscall.Stat_t) + if !ok { + return 0, fmt.Errorf("type assertion for fileInfo.Sys() failed") + } + if stat.Blocks < 0 { + return 0, fmt.Errorf("invalid negative block count: %d", stat.Blocks) + } + + return uint64(stat.Blocks) * statBlockSizeBytes, nil +} diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index fa9ffe7e..69b114cc 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -3,8 +3,11 @@ package snclient import ( "fmt" "os" + "os/exec" "path/filepath" + "regexp" "runtime" + "strconv" "strings" "testing" "time" @@ -530,6 +533,42 @@ func TestCheckFilesSizePerfdata(t *testing.T) { outputString = string(res.BuildPluginOutput()) assert.Containsf(t, outputString, "OK - All 4 files are ok: (4.00 MiB)", "output matches") + // check if add-disk-size populates the disksize attribute + res = snc.RunCheck("check_files", []string{"path=" + generationDirectory, "add-disk-size=true", "filter='type == file and disksize > 0'"}) + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "OK - All 16 files are ok", "output matches") + + // the disksize attribute should not be populated when add-disk-size is not enabled. + res = snc.RunCheck("check_files", []string{"path=" + generationDirectory, "filter='disksize > 0'"}) + outputString = string(res.BuildPluginOutput()) + assert.NotContainsf(t, outputString, "OK - All", "disksize should not be populated without add-disk-size") + + // check if total_disksize metric is calculated when add-disk-size is enabled + res = snc.RunCheck("check_files", []string{"path=" + generationDirectory, "add-disk-size=true", "crit='total_disksize > 0'", "filter='type == file'"}) + outputString = string(res.BuildPluginOutput()) + assert.Containsf(t, outputString, "'total_disksize'=", "output matches") + + // total_disksize equals the sum of the matched files' disksize values + res = snc.RunCheck("check_files", []string{"path=" + generationDirectory, "add-disk-size=true", "warn='total_disksize > 0'", "crit='disksize < 0'", "filter='type == file'"}) + outputString = string(res.BuildPluginOutput()) + totalMatch := regexp.MustCompile(`'total_disksize'=(\d+)B`).FindStringSubmatch(outputString) + require.Lenf(t, totalMatch, 2, "total_disksize metric missing: %s", outputString) + totalDiskSize, err := strconv.ParseUint(totalMatch[1], 10, 64) + require.NoError(t, err) + perFileRe := regexp.MustCompile(`'[^']* disksize'=(\d+)B`) + var sumDiskSize uint64 + for _, m := range perFileRe.FindAllStringSubmatch(outputString, -1) { + v, parseErr := strconv.ParseUint(m[1], 10, 64) + require.NoError(t, parseErr) + sumDiskSize += v + } + assert.Equalf(t, sumDiskSize, totalDiskSize, "total_disksize is the sum of per-file disksize values") + + // total_disksize is not emitted when add-disk-size is off + res = snc.RunCheck("check_files", []string{"path=" + generationDirectory, "crit='total_disksize > 0'", "filter='type == file'"}) + outputString = string(res.BuildPluginOutput()) + assert.NotContainsf(t, outputString, "'total_disksize'=", "no total_disksize metric without add-disk-size") + StopTestAgent(t, snc) } @@ -778,3 +817,177 @@ func TestCheckFilesFilesystemLinks2(t *testing.T) { StopTestAgent(t, snc) } + +// duBytes returns the on-disk allocated size of a file in bytes as reported by `du -B1`. +func duBytes(t *testing.T, path string) uint64 { + t.Helper() + out, err := exec.Command("du", "-B1", path).Output() + require.NoErrorf(t, err, "du -B1 %s failed: %s", path, err) + fields := strings.Fields(string(out)) + require.Lenf(t, fields, 2, "unexpected du output for %s: %s", path, out) + v, err := strconv.ParseUint(fields[0], 10, 64) + require.NoErrorf(t, err, "could not parse du output %q for %s", fields[0], path) + + return v +} + +func TestCheckFilesDiskSizeDuComparison(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("du comparison is unix only") + } + snc := StartTestAgent(t, "") + dir := t.TempDir() + + regular := filepath.Join(dir, "regular.bin") + require.NoError(t, os.WriteFile(regular, make([]byte, 7000), 0o600)) + + empty := filepath.Join(dir, "empty.bin") + require.NoError(t, os.WriteFile(empty, nil, 0o600)) + + // sparse file: logical size larger than allocated size + sparse := filepath.Join(dir, "sparse.bin") + f, err := os.Create(sparse) + require.NoError(t, err) + require.NoError(t, f.Truncate(int64(10)<<20)) + require.NoError(t, f.Close()) + + res := snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == file'"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + output := string(res.BuildPluginOutput()) + + for _, name := range []string{"regular.bin", "empty.bin", "sparse.bin"} { + want := duBytes(t, filepath.Join(dir, name)) + assert.Regexpf(t, regexp.QuoteMeta(fmt.Sprintf("'%s disksize'=%dB", name, want)), output, + "disksize matches du -B1 for %s (want %d)", name, want) + } + + StopTestAgent(t, snc) +} + +func TestCheckFilesDiskSizePerFileMetric(t *testing.T) { + snc := StartTestAgent(t, "") + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.bin"), make([]byte, 100), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.bin"), make([]byte, 100), 0o600)) + + // add-disk-size enabled and a threshold references disksize -> per-file metrics emitted + res := snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == file'"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + output := string(res.BuildPluginOutput()) + assert.Contains(t, output, "'a.bin disksize'=") + assert.Contains(t, output, "'b.bin disksize'=") + + // add-disk-size disabled but threshold set -> no per-file disksize metric (FR-014) + res = snc.RunCheck("check_files", []string{"path=" + dir, "crit='disksize < 0'", "filter='type == file'"}) + output = string(res.BuildPluginOutput()) + assert.NotContains(t, output, " disksize'=", "no per-file disksize metric without add-disk-size") + + StopTestAgent(t, snc) +} + +func TestCheckFilesDiskSizeGracefulFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix specific: chmod based") + } + if os.Geteuid() == 0 { + t.Skip("running as root, permission bits are not enforced") + } + snc := StartTestAgent(t, "") + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "readable.bin"), make([]byte, 100), 0o600)) + unreadable := filepath.Join(dir, "secret.bin") + require.NoError(t, os.WriteFile(unreadable, make([]byte, 100), 0o600)) + require.NoError(t, os.Chmod(unreadable, 0)) + + // an unreadable file must not fail the check nor prevent disksize for the other files + res := snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == file'"}) + assert.Equalf(t, CheckExitOK, res.State, "check passes despite unreadable file") + output := string(res.BuildPluginOutput()) + assert.Contains(t, output, "All 2 files are ok") + assert.Contains(t, output, "'readable.bin disksize'=") + + StopTestAgent(t, snc) +} + +func TestCheckFilesDiskSizeHardLinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("hard link test is unix specific") + } + snc := StartTestAgent(t, "") + dir := t.TempDir() + orig := filepath.Join(dir, "orig.bin") + require.NoError(t, os.WriteFile(orig, make([]byte, 100), 0o600)) + require.NoError(t, os.Link(orig, filepath.Join(dir, "hardlink.bin"))) + + res := snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == file'"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + output := string(res.BuildPluginOutput()) + // both paths are counted once each + assert.Contains(t, output, "All 2 files are ok") + + origMatch := regexp.MustCompile(`'orig\.bin disksize'=(\d+)B`).FindStringSubmatch(output) + require.Lenf(t, origMatch, 2, "orig.bin disksize metric missing: %s", output) + linkMatch := regexp.MustCompile(`'hardlink\.bin disksize'=(\d+)B`).FindStringSubmatch(output) + require.Lenf(t, linkMatch, 2, "hardlink.bin disksize metric missing: %s", output) + assert.Equal(t, origMatch[1], linkMatch[1], "hard links report the same disksize") + + StopTestAgent(t, snc) +} + +func TestCheckFilesDiskSizeLargeFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sparse large file test is unix specific") + } + snc := StartTestAgent(t, "") + dir := t.TempDir() + big := filepath.Join(dir, "big.bin") + f, err := os.Create(big) + require.NoError(t, err) + // 5 GiB logical size, sparse (no data written) + require.NoError(t, f.Truncate(int64(5)<<30)) + require.NoError(t, f.Close()) + + // logical size > 4 GiB reported without overflow + res := snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='size < 0'", "filter='type == file and filename == big.bin'"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + output := string(res.BuildPluginOutput()) + assert.Contains(t, output, fmt.Sprintf("'big.bin size'=%dB", int64(5)<<30)) + + // disksize equals the allocated bytes per du (sparse -> smaller than logical), no overflow + want := duBytes(t, big) + res = snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == file and filename == big.bin'"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + output = string(res.BuildPluginOutput()) + assert.Regexpf(t, regexp.QuoteMeta(fmt.Sprintf("'big.bin disksize'=%dB", want)), output, + "disksize matches du -B1 for large sparse file (want %d)", want) + + StopTestAgent(t, snc) +} + +func TestCheckFilesDiskSizeSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink test is unix specific") + } + snc := StartTestAgent(t, "") + dir := t.TempDir() + target := filepath.Join(dir, "target.bin") + require.NoError(t, os.WriteFile(target, make([]byte, 3000), 0o600)) + require.NoError(t, os.Symlink(target, filepath.Join(dir, "link.bin"))) + + want := duBytes(t, target) + res := snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == file'"}) + assert.Equalf(t, CheckExitOK, res.State, "state OK") + output := string(res.BuildPluginOutput()) + // symlink reports the disksize of its target + assert.Regexpf(t, regexp.QuoteMeta(fmt.Sprintf("'link.bin disksize'=%dB", want)), output, + "symlink disksize matches target du -B1 (want %d)", want) + + // a broken symlink is recorded as an errored entry; the check does not crash + require.NoError(t, os.Symlink(filepath.Join(dir, "missing.bin"), filepath.Join(dir, "broken.bin"))) + res = snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "filter='type == file'"}) + output = string(res.BuildPluginOutput()) + assert.Contains(t, output, "broken.bin") + assert.Contains(t, output, "no such file or directory") + + StopTestAgent(t, snc) +} diff --git a/pkg/snclient/check_files_windows.go b/pkg/snclient/check_files_windows.go new file mode 100644 index 00000000..4f6a2ecb --- /dev/null +++ b/pkg/snclient/check_files_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package snclient + +import ( + "fmt" + "io/fs" + "unsafe" + + "golang.org/x/sys/windows" +) + +// fileStandardInfo is the output of GetFileInformationByHandleEx when called with the FileStandardInfo info class. +// https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_standard_info +type fileStandardInfo struct { + AllocationSize int64 + EndOfFile int64 + NumberOfLinks uint32 + DeletePending bool + Directory bool +} + +// getFileDiskSize returns the actual disk size of the file or directory. +// The win32 file attributes used by os.FileInfo.Sys() do not include the allocated size, so an additional API call is required. +// The file is opened without FILE_FLAG_OPEN_REPARSE_POINT, so symlinks are resolved and the size of the target is returned. +func getFileDiskSize(_ fs.FileInfo, path string) (uint64, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, fmt.Errorf("could not convert path to UTF16: %s", path) + } + + handle, err := windows.CreateFile(pathPtr, 0, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return 0, fmt.Errorf("could not open file %s: %s", path, err.Error()) + } + defer LogDebug(windows.CloseHandle(handle)) + + var info fileStandardInfo + err = windows.GetFileInformationByHandleEx(handle, windows.FileStandardInfo, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))) + if err != nil { + return 0, fmt.Errorf("could not get file information for %s: %s", path, err.Error()) + } + if info.AllocationSize < 0 { + return 0, fmt.Errorf("invalid negative allocation size: %d", info.AllocationSize) + } + + return uint64(info.AllocationSize), nil +} diff --git a/pkg/snclient/check_files_windows_test.go b/pkg/snclient/check_files_windows_test.go index 775c4904..993e8012 100644 --- a/pkg/snclient/check_files_windows_test.go +++ b/pkg/snclient/check_files_windows_test.go @@ -1,10 +1,16 @@ package snclient import ( + "fmt" "os" + "path/filepath" + "regexp" + "runtime" + "strconv" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCheckDriveLetterPaths(t *testing.T) { @@ -116,3 +122,46 @@ func TestCheckPathSpecifications(t *testing.T) { StopTestAgent(t, snc) } + +// TestCheckFilesDiskSizeWindows verifies disksize matches the OS-reported allocation size +// (Explorer "Size on disk") for a small file and a directory. +func TestCheckFilesDiskSizeWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("windows specific") + } + snc := StartTestAgent(t, "") + dir := t.TempDir() + + // 1-byte file: disksize == allocated cluster size (>= logical size) + small := filepath.Join(dir, "small.bin") + require.NoError(t, os.WriteFile(small, []byte{1}, 0o600)) + + res := snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == file'"}) + require.Equalf(t, CheckExitOK, res.State, "state OK") + output := string(res.BuildPluginOutput()) + + // the reported disksize must equal the OS allocation size (Explorer "Size on disk") + info, err := os.Stat(small) + require.NoError(t, err) + wantDiskSize, err := getFileDiskSize(info, small) + require.NoError(t, err) + assert.Containsf(t, output, fmt.Sprintf("'small.bin disksize'=%dB", wantDiskSize), + "disksize matches OS allocation size (want %d)", wantDiskSize) + assert.GreaterOrEqualf(t, wantDiskSize, uint64(1), "a 1-byte file allocates at least one cluster") + + // a directory entry reports its own allocated size (not the sum of its contents) + res = snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == dir'"}) + require.Equalf(t, CheckExitOK, res.State, "state OK") + output = string(res.BuildPluginOutput()) + re := regexp.MustCompile(`'.+ disksize'=(\d+)B`) + m := re.FindStringSubmatch(output) + require.Lenf(t, m, 2, "directory disksize metric missing: %s", output) + dirDiskSize, err := strconv.ParseUint(m[1], 10, 64) + require.NoError(t, err) + // the directory's own allocation is far smaller than the file's contents would suggest; + // assert it is a positive, small value + assert.Positivef(t, dirDiskSize, "directory has an allocated size") + assert.Lessf(t, dirDiskSize, uint64(1<<20), "directory entry allocation is small (< 1 MiB)") + + StopTestAgent(t, snc) +} From 84fa66023cf82df726be2e932b808f7868465eaa Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 31 Aug 2026 12:35:36 +0200 Subject: [PATCH 2/7] fix du comparison test for bsd/macos du -B1 semantics BSD/macOS du interprets -B1 as one 512 byte block, unlike GNU du where it means 1 byte. Scale the test's du output by 512 on darwin/freebsd so the disksize comparison stays consistent with the production st_blocks * 512. --- pkg/snclient/check_files_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index 69b114cc..a4ebb9a9 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -818,7 +818,9 @@ func TestCheckFilesFilesystemLinks2(t *testing.T) { StopTestAgent(t, snc) } -// duBytes returns the on-disk allocated size of a file in bytes as reported by `du -B1`. +// duBytes returns the on-disk allocated size of a file in bytes as reported by `du`. +// GNU du interprets -B1 as a 1 byte unit, while BSD/macOS du interprets it as one +// 512 byte block, so the BSD output is scaled to bytes to stay consistent. func duBytes(t *testing.T, path string) uint64 { t.Helper() out, err := exec.Command("du", "-B1", path).Output() @@ -827,6 +829,11 @@ func duBytes(t *testing.T, path string) uint64 { require.Lenf(t, fields, 2, "unexpected du output for %s: %s", path, out) v, err := strconv.ParseUint(fields[0], 10, 64) require.NoErrorf(t, err, "could not parse du output %q for %s", fields[0], path) + switch runtime.GOOS { + case "darwin", "freebsd": + // BSD du: -B1 means one 512 byte block + v *= 512 + } return v } From 966ffd29c3c8668a19bf299365dbf49ebcdaed27 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 31 Aug 2026 12:40:13 +0200 Subject: [PATCH 3/7] windows: resolve 8.3 short paths before querying disk size GetFileInformationByHandleEx fails with an invalid-handle error when the path contains 8.3 short names (ex.: C:\Users\RUNNER~1), which the CI temp dir on windows produces. Resolve the long path form first via GetLongPathName. Moves the existing resolveLongPath helper out of the vhdx test into a shared windows util so check_files can use it as well. --- .../check_drivesize_windows_vhdx_test.go | 21 ------------- pkg/snclient/check_files_windows.go | 9 +++++- pkg/snclient/path_utils_windows.go | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 22 deletions(-) create mode 100644 pkg/snclient/path_utils_windows.go diff --git a/pkg/snclient/check_drivesize_windows_vhdx_test.go b/pkg/snclient/check_drivesize_windows_vhdx_test.go index 8ecf2551..71f56a77 100644 --- a/pkg/snclient/check_drivesize_windows_vhdx_test.go +++ b/pkg/snclient/check_drivesize_windows_vhdx_test.go @@ -36,27 +36,6 @@ func hasElevatedPrivileges() bool { return token.IsElevated() } -// resolveLongPath expands 8.3 short names (ex.: C:\Users\RUNNER~1) to their long form. -// diskpart stores the vhd backing file path and the mount point as given, so later -// select/detach calls need to use the exact same path string. -func resolveLongPath(path string) (string, error) { - pathPtr, err := windows.UTF16PtrFromString(path) - if err != nil { - return "", fmt.Errorf("GetLongName error when creating UTF16 string pointer from path %w", err) - } - size, _ := windows.GetLongPathName(pathPtr, nil, 0) - if size == 0 { - return "", fmt.Errorf("GetLongPathName returned no size for %s", path) - } - buf := make([]uint16, size) - res, _ := windows.GetLongPathName(pathPtr, &buf[0], size) - if res == 0 { - return "", fmt.Errorf("GetLongPathName returned 0 for %s", path) - } - - return windows.UTF16ToString(buf[:res]), nil -} - func execDiskpart(t *testing.T, script string) (output string, err error) { t.Helper() diff --git a/pkg/snclient/check_files_windows.go b/pkg/snclient/check_files_windows.go index 4f6a2ecb..9deffb1e 100644 --- a/pkg/snclient/check_files_windows.go +++ b/pkg/snclient/check_files_windows.go @@ -24,7 +24,14 @@ type fileStandardInfo struct { // The win32 file attributes used by os.FileInfo.Sys() do not include the allocated size, so an additional API call is required. // The file is opened without FILE_FLAG_OPEN_REPARSE_POINT, so symlinks are resolved and the size of the target is returned. func getFileDiskSize(_ fs.FileInfo, path string) (uint64, error) { - pathPtr, err := windows.UTF16PtrFromString(path) + // 8.3 short names (ex.: C:\Users\RUNNER~1) cause GetFileInformationByHandleEx to + // fail with an invalid-handle error, so resolve the long form first. + longPath, err := resolveLongPath(path) + if err != nil { + longPath = path + } + + pathPtr, err := windows.UTF16PtrFromString(longPath) if err != nil { return 0, fmt.Errorf("could not convert path to UTF16: %s", path) } diff --git a/pkg/snclient/path_utils_windows.go b/pkg/snclient/path_utils_windows.go new file mode 100644 index 00000000..51d8e274 --- /dev/null +++ b/pkg/snclient/path_utils_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package snclient + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// resolveLongPath expands 8.3 short names (ex.: C:\Users\RUNNER~1) to their long form. +// Some Win32 APIs (e.g. GetFileInformationByHandleEx with the FileStandardInfo class) +// fail with an invalid-handle error when given a short path, so paths are resolved to +// their long form before use. +func resolveLongPath(path string) (string, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", fmt.Errorf("GetLongName error when creating UTF16 string pointer from path %w", err) + } + size, _ := windows.GetLongPathName(pathPtr, nil, 0) + if size == 0 { + return "", fmt.Errorf("GetLongPathName returned no size for %s", path) + } + buf := make([]uint16, size) + res, _ := windows.GetLongPathName(pathPtr, &buf[0], size) + if res == 0 { + return "", fmt.Errorf("GetLongPathName returned 0 for %s", path) + } + + return windows.UTF16ToString(buf[:res]), nil +} From 390b458e8b7fa34db79b3f0a5c76c888dbf9d94f Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 31 Aug 2026 12:48:53 +0200 Subject: [PATCH 4/7] fix golangci varnamelen --- pkg/snclient/check_files_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/snclient/check_files_test.go b/pkg/snclient/check_files_test.go index a4ebb9a9..9e6a97ae 100644 --- a/pkg/snclient/check_files_test.go +++ b/pkg/snclient/check_files_test.go @@ -827,15 +827,15 @@ func duBytes(t *testing.T, path string) uint64 { require.NoErrorf(t, err, "du -B1 %s failed: %s", path, err) fields := strings.Fields(string(out)) require.Lenf(t, fields, 2, "unexpected du output for %s: %s", path, out) - v, err := strconv.ParseUint(fields[0], 10, 64) + value, err := strconv.ParseUint(fields[0], 10, 64) require.NoErrorf(t, err, "could not parse du output %q for %s", fields[0], path) switch runtime.GOOS { case "darwin", "freebsd": // BSD du: -B1 means one 512 byte block - v *= 512 + value *= 512 } - return v + return value } func TestCheckFilesDiskSizeDuComparison(t *testing.T) { From 8452d67bec4f8e4e2c4f61ff6de6310aede410e6 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 31 Aug 2026 12:51:01 +0200 Subject: [PATCH 5/7] windows: open handle with FILE_READ_ATTRIBUTES for size query GetFileInformationByHandleEx returns ERROR_INVALID_HANDLE when the handle was opened with dwDesiredAccess=0; the handle must be opened with the FILE_READ_ATTRIBUTES access right. This is enforced on newer Windows versions, which is why the CI runner failed while the check otherwise reported the path fine. --- pkg/snclient/check_files_windows.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/snclient/check_files_windows.go b/pkg/snclient/check_files_windows.go index 9deffb1e..09009308 100644 --- a/pkg/snclient/check_files_windows.go +++ b/pkg/snclient/check_files_windows.go @@ -24,8 +24,10 @@ type fileStandardInfo struct { // The win32 file attributes used by os.FileInfo.Sys() do not include the allocated size, so an additional API call is required. // The file is opened without FILE_FLAG_OPEN_REPARSE_POINT, so symlinks are resolved and the size of the target is returned. func getFileDiskSize(_ fs.FileInfo, path string) (uint64, error) { - // 8.3 short names (ex.: C:\Users\RUNNER~1) cause GetFileInformationByHandleEx to - // fail with an invalid-handle error, so resolve the long form first. + // GetFileInformationByHandleEx requires the handle to be opened with the + // FILE_READ_ATTRIBUTES access right, an invalid-handle error is returned otherwise. + // 8.3 short names (ex.: C:\Users\RUNNER~1) are resolved to their long form first, + // since short paths can also cause the query to fail. longPath, err := resolveLongPath(path) if err != nil { longPath = path @@ -33,21 +35,21 @@ func getFileDiskSize(_ fs.FileInfo, path string) (uint64, error) { pathPtr, err := windows.UTF16PtrFromString(longPath) if err != nil { - return 0, fmt.Errorf("could not convert path to UTF16: %s", path) + return 0, fmt.Errorf("could not convert path to UTF16: %s", longPath) } - handle, err := windows.CreateFile(pathPtr, 0, + handle, err := windows.CreateFile(pathPtr, windows.FILE_READ_ATTRIBUTES, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0) if err != nil { - return 0, fmt.Errorf("could not open file %s: %s", path, err.Error()) + return 0, fmt.Errorf("could not open file %s: %s", longPath, err.Error()) } defer LogDebug(windows.CloseHandle(handle)) var info fileStandardInfo err = windows.GetFileInformationByHandleEx(handle, windows.FileStandardInfo, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))) if err != nil { - return 0, fmt.Errorf("could not get file information for %s: %s", path, err.Error()) + return 0, fmt.Errorf("could not get file information for %s: %s", longPath, err.Error()) } if info.AllocationSize < 0 { return 0, fmt.Errorf("invalid negative allocation size: %d", info.AllocationSize) From 09c00fc3a9fe263f5a7df91eacd09764904d41b1 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Mon, 31 Aug 2026 14:04:48 +0200 Subject: [PATCH 6/7] check_files: windows fix the file handle in defer call golang evaluates arguments to a defer call immediately upon defining the deferred function in getFileDiskSize, the handle was being closed directly. then it was failing the later calls uisng the same handle now put the closing into an anonymous function without arguments, so that its closed when needed. --- pkg/snclient/check_files_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/snclient/check_files_windows.go b/pkg/snclient/check_files_windows.go index 09009308..09557c83 100644 --- a/pkg/snclient/check_files_windows.go +++ b/pkg/snclient/check_files_windows.go @@ -44,7 +44,7 @@ func getFileDiskSize(_ fs.FileInfo, path string) (uint64, error) { if err != nil { return 0, fmt.Errorf("could not open file %s: %s", longPath, err.Error()) } - defer LogDebug(windows.CloseHandle(handle)) + defer func() { LogDebug(windows.CloseHandle(handle)) }() var info fileStandardInfo err = windows.GetFileInformationByHandleEx(handle, windows.FileStandardInfo, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))) From 2ba7d21265f628b36dc517553735612f9f60307b Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Mon, 31 Aug 2026 14:08:46 +0200 Subject: [PATCH 7/7] check_files: ai assisted, fix the disksize test about the subdirectory. getfiledisksize on a directory reports the allocation for the directory itself, not including its contents --- pkg/snclient/check_files_windows_test.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/pkg/snclient/check_files_windows_test.go b/pkg/snclient/check_files_windows_test.go index 993e8012..8f9760fd 100644 --- a/pkg/snclient/check_files_windows_test.go +++ b/pkg/snclient/check_files_windows_test.go @@ -4,9 +4,7 @@ import ( "fmt" "os" "path/filepath" - "regexp" "runtime" - "strconv" "testing" "github.com/stretchr/testify/assert" @@ -150,18 +148,23 @@ func TestCheckFilesDiskSizeWindows(t *testing.T) { assert.GreaterOrEqualf(t, wantDiskSize, uint64(1), "a 1-byte file allocates at least one cluster") // a directory entry reports its own allocated size (not the sum of its contents) + sub := filepath.Join(dir, "subdir") + require.NoError(t, os.Mkdir(sub, 0o700)) + // put more than 1 MiB into the directory so the disksize would exceed 1 MiB if it summed the contents + require.NoError(t, os.WriteFile(filepath.Join(sub, "child.bin"), make([]byte, 2<<20), 0o600)) + res = snc.RunCheck("check_files", []string{"path=" + dir, "add-disk-size=true", "crit='disksize < 0'", "filter='type == dir'"}) require.Equalf(t, CheckExitOK, res.State, "state OK") output = string(res.BuildPluginOutput()) - re := regexp.MustCompile(`'.+ disksize'=(\d+)B`) - m := re.FindStringSubmatch(output) - require.Lenf(t, m, 2, "directory disksize metric missing: %s", output) - dirDiskSize, err := strconv.ParseUint(m[1], 10, 64) + + info, err = os.Stat(sub) + require.NoError(t, err) + wantDirDiskSize, err := getFileDiskSize(info, sub) require.NoError(t, err) - // the directory's own allocation is far smaller than the file's contents would suggest; - // assert it is a positive, small value - assert.Positivef(t, dirDiskSize, "directory has an allocated size") - assert.Lessf(t, dirDiskSize, uint64(1<<20), "directory entry allocation is small (< 1 MiB)") + assert.Containsf(t, output, fmt.Sprintf("'subdir disksize'=%dB", wantDirDiskSize), + "disksize matches OS allocation size (want %d)", wantDirDiskSize) + // the directory's own allocation is far smaller than its contents would suggest + assert.Lessf(t, wantDirDiskSize, uint64(1<<20), "directory entry allocation is small (< 1 MiB)") StopTestAgent(t, snc) }