diff --git a/followparser.go b/followparser.go index 98bee8d..96dfbb3 100644 --- a/followparser.go +++ b/followparser.go @@ -167,7 +167,7 @@ func currentUserID() int { func (parser *Parser) Parse(posFileName, logFile string) ([]Parsed, error) { parser.parseInit(logFile) - parser.posFile = newPosFile(filepath.Join(parser.WorkDir, fmt.Sprintf("%s-%d", posFileName, currentUserID()))) + parser.posFile = newPosFile(parser.WorkDir, fmt.Sprintf("%s-%d", posFileName, currentUserID())) lastPos, duration, lastFstat, err := parser.posFile.read() if err != nil { return nil, fmt.Errorf("failed to load pos file :%w", err) diff --git a/followparser_bench_test.go b/followparser_bench_test.go new file mode 100644 index 0000000..143f08a --- /dev/null +++ b/followparser_bench_test.go @@ -0,0 +1,101 @@ +package followparser + +import ( + "bufio" + "bytes" + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +func benchScannerFile(b *testing.B, fname string) { + b.ReportAllocs() + for range b.N { + fh, err := os.Open(fname) + if err != nil { + b.Fatal(err) + } + parser := &dummyParser{} + scanner := bufio.NewScanner(fh) + scanner.Buffer(make([]byte, DefaultStartBufSize), DefaultMaxBufSize) + for scanner.Scan() { + if err := parser.Parse(scanner.Bytes()); err != nil { + b.Fatal(err) + } + } + if err := scanner.Err(); err != nil { + b.Fatal(err) + } + fh.Close() + } +} + +func benchScanFile(b *testing.B, fname string) { + b.ReportAllocs() + for range b.N { + fh, err := os.Open(fname) + if err != nil { + b.Fatal(err) + } + parser := &dummyParser{} + p := &Parser{ + Callback: parser, + StartBufSize: DefaultStartBufSize, + MaxBufSize: DefaultMaxBufSize, + MaxReadSize: DefaultMaxReadSize, + } + _, _, err = p.scanFile(fh, true) + if err != nil && !errors.Is(err, io.EOF) { + b.Fatal(err) + } + fh.Close() + } +} + +func BenchmarkScanner_SmallLines(b *testing.B) { + dir := b.TempDir() + fname := filepath.Join(dir, "small.log") + line := "short line example\n" + // ~10k lines + if err := writeTestFile(fname, line, 10000); err != nil { + b.Fatal(err) + } + b.ResetTimer() + benchScannerFile(b, fname) +} + +func BenchmarkScanFile_SmallLines(b *testing.B) { + dir := b.TempDir() + fname := filepath.Join(dir, "small.log") + line := "short line example\n" + if err := writeTestFile(fname, line, 10000); err != nil { + b.Fatal(err) + } + b.ResetTimer() + benchScanFile(b, fname) +} + +func BenchmarkScanner_LongLine(b *testing.B) { + dir := b.TempDir() + fname := filepath.Join(dir, "long.log") + longLine := string(bytes.Repeat([]byte("A"), DefaultStartBufSize+100)) + "\n" + // single long line + if err := writeTestFile(fname, longLine, 1); err != nil { + b.Fatal(err) + } + b.ResetTimer() + benchScannerFile(b, fname) +} + +func BenchmarkScanFile_LongLine(b *testing.B) { + dir := b.TempDir() + fname := filepath.Join(dir, "long.log") + longLine := string(bytes.Repeat([]byte("A"), DefaultStartBufSize+100)) + "\n" + if err := writeTestFile(fname, longLine, 1); err != nil { + b.Fatal(err) + } + b.ResetTimer() + benchScanFile(b, fname) +} diff --git a/followparser_test.go b/followparser_test.go index eeb20fc..cd4da78 100644 --- a/followparser_test.go +++ b/followparser_test.go @@ -1,11 +1,10 @@ package followparser import ( - "bufio" "bytes" - "errors" "fmt" "io" + "log" "os" "path/filepath" "strings" @@ -15,6 +14,10 @@ import ( "github.com/stretchr/testify/require" ) +func init() { + log.SetOutput(io.Discard) +} + type testParser struct { buf *bytes.Buffer duration float64 @@ -35,96 +38,6 @@ func writeTestFile(path string, line string, count int) error { return fh.Sync() } -func benchScannerFile(b *testing.B, fname string) { - b.ReportAllocs() - for range b.N { - fh, err := os.Open(fname) - if err != nil { - b.Fatal(err) - } - parser := &dummyParser{} - scanner := bufio.NewScanner(fh) - scanner.Buffer(make([]byte, DefaultStartBufSize), DefaultMaxBufSize) - for scanner.Scan() { - if err := parser.Parse(scanner.Bytes()); err != nil { - b.Fatal(err) - } - } - if err := scanner.Err(); err != nil { - b.Fatal(err) - } - fh.Close() - } -} - -func benchScanFile(b *testing.B, fname string) { - b.ReportAllocs() - for range b.N { - fh, err := os.Open(fname) - if err != nil { - b.Fatal(err) - } - parser := &dummyParser{} - p := &Parser{ - Callback: parser, - StartBufSize: DefaultStartBufSize, - MaxBufSize: DefaultMaxBufSize, - MaxReadSize: DefaultMaxReadSize, - } - _, _, err = p.scanFile(fh, true) - if err != nil && !errors.Is(err, io.EOF) { - b.Fatal(err) - } - fh.Close() - } -} - -func BenchmarkScanner_SmallLines(b *testing.B) { - dir := b.TempDir() - fname := filepath.Join(dir, "small.log") - line := "short line example\n" - // ~10k lines - if err := writeTestFile(fname, line, 10000); err != nil { - b.Fatal(err) - } - b.ResetTimer() - benchScannerFile(b, fname) -} - -func BenchmarkScanFile_SmallLines(b *testing.B) { - dir := b.TempDir() - fname := filepath.Join(dir, "small.log") - line := "short line example\n" - if err := writeTestFile(fname, line, 10000); err != nil { - b.Fatal(err) - } - b.ResetTimer() - benchScanFile(b, fname) -} - -func BenchmarkScanner_LongLine(b *testing.B) { - dir := b.TempDir() - fname := filepath.Join(dir, "long.log") - longLine := string(bytes.Repeat([]byte("A"), DefaultStartBufSize+100)) + "\n" - // single long line - if err := writeTestFile(fname, longLine, 1); err != nil { - b.Fatal(err) - } - b.ResetTimer() - benchScannerFile(b, fname) -} - -func BenchmarkScanFile_LongLine(b *testing.B) { - dir := b.TempDir() - fname := filepath.Join(dir, "long.log") - longLine := string(bytes.Repeat([]byte("A"), DefaultStartBufSize+100)) + "\n" - if err := writeTestFile(fname, longLine, 1); err != nil { - b.Fatal(err) - } - b.ResetTimer() - benchScanFile(b, fname) -} - func (p *testParser) Parse(b []byte) error { p.buf.Write(b) p.buf.WriteString("\n") @@ -498,3 +411,73 @@ func TestTruncated(t *testing.T) { require.Len(t, r2, 1, "truncated result len must be 1") require.Equal(t, 1, r2[0].Rows, "truncated result[0].Rows must be 1") } + +// TestParseSymlinkSameDir verifies that parsing works when logFile is a +// symlink pointing to the actual log file in the same directory, including +// rotation detection when the symlink target changes. +func TestParseSymlinkSameDir(t *testing.T) { + tmpdir := t.TempDir() + + // Create the initial actual log file and a symlink to it. + actualLog1 := filepath.Join(tmpdir, "access_log.20260828") + symlinkLog := filepath.Join(tmpdir, "access.log") + fh, err := os.Create(actualLog1) + require.NoError(t, err, "failed to create initial actual log file") + + err = os.Symlink(actualLog1, symlinkLog) + require.NoError(t, err, "failed to create symlink to initial log file") + + msg1 := fmt.Sprintf("msg msg %08d\n", 1) + _, err = fh.WriteString(msg1) + require.NoError(t, err, "failed to write to initial actual log file") + fh.Close() + + // First parse: should read through the symlink. + buf := bytes.NewBufferString("") + parser := &testParser{buf: buf} + fp := &Parser{ + WorkDir: tmpdir, + Callback: parser, + Silent: true, + } + r, err := fp.Parse("logPosSymlink", symlinkLog) + require.NoError(t, err, "failed to parse symlinked log file") + require.Equal(t, msg1, parser.Slurp().String(), "first read output does not match expected") + require.Len(t, r, 1, "first result len must be 1") + require.Equal(t, 1, r[0].Rows, "first result[0].Rows must be 1") + + // Simulate rotation: create a new actual log file and repoint the symlink. + actualLog2 := filepath.Join(tmpdir, "access_log.20260829") + fh, err = os.Create(actualLog2) + require.NoError(t, err, "failed to create rotated actual log file") + + msg2 := fmt.Sprintf("msg msg %08d\n", 2) + _, err = fh.WriteString(msg2) + require.NoError(t, err, "failed to write to rotated actual log file") + fh.Close() + + err = os.Remove(symlinkLog) + require.NoError(t, err, "failed to remove old symlink") + err = os.Symlink(actualLog2, symlinkLog) + require.NoError(t, err, "failed to create symlink to rotated log file") + + // Second parse: should detect rotation and read from the new symlink target. + buf2 := bytes.NewBufferString("") + parser2 := &testParser{buf: buf2} + fp2 := &Parser{ + WorkDir: tmpdir, + Callback: parser2, + ArchiveDir: tmpdir, + Silent: true, + } + r2, err := fp2.Parse("logPosSymlink", symlinkLog) + require.NoError(t, err, "failed to parse after symlink rotation") + // Rotation is detected because the symlink now points at a different inode. + // The previous target is still present in ArchiveDir (same as tmpdir here), + // so parseRotated also parses it, but it contributes zero new rows because + // it was already fully read. + require.Equal(t, msg2, parser2.Slurp().String(), "second read output does not match expected") + require.Len(t, r2, 2, "second result len must be 2") + require.Equal(t, 0, r2[0].Rows, "second result[0].Rows must be 0 (old file already fully read)") + require.Equal(t, 1, r2[1].Rows, "second result[1].Rows must be 1") +} diff --git a/fpos.go b/fpos.go index e925412..d73f2d7 100644 --- a/fpos.go +++ b/fpos.go @@ -1,14 +1,10 @@ package followparser import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "syscall" "time" "github.com/avast/retry-go/v4" + "github.com/monitoring-forge/saferio" ) type fPos struct { @@ -18,22 +14,20 @@ type fPos struct { Dev uint64 `json:"dev"` } -type fStat struct { - Inode uint64 - Dev uint64 - Size int64 -} - type posFile struct { + workDir string filename string } -func newPosFile(filename string) *posFile { - return &posFile{filename} +func newPosFile(workdir, filename string) *posFile { + return &posFile{ + workDir: workdir, + filename: filename, + } } func (pf *posFile) read() (int64, float64, *fStat, error) { - s, err := os.Stat(pf.filename) + s, err := saferio.Stat(pf.workDir, pf.filename) if err != nil || s.Size() == 0 { return 0, 0, nil, nil } @@ -41,11 +35,7 @@ func (pf *posFile) read() (int64, float64, *fStat, error) { fp := fPos{} err = retry.Do( func() error { - d, err := os.ReadFile(pf.filename) - if err != nil { - return err - } - err = json.Unmarshal(d, &fp) + err := saferio.ReadJSON(pf.workDir, pf.filename, &fp) if err != nil { return err } @@ -77,70 +67,5 @@ func (pf *posFile) write(pos int64, fstat *fStat) error { Inode: fstat.Inode, Dev: fstat.Dev, } - jb, err := json.Marshal(fp) - if err != nil { - return err - } - // To avoid race condition, we create a temporary file and then rename it to the target filename. - f, err := os.CreateTemp(filepath.Dir(pf.filename), filepath.Base(pf.filename)) - if err != nil { - return err - } - defer f.Close() - _, err = f.Write(jb) - if err != nil { - return err - } - err = f.Sync() - if err != nil { - return err - } - err = f.Close() - if err != nil { - return err - } - return os.Rename(f.Name(), pf.filename) -} - -func fileStat(filename string) (*fStat, error) { - s, err := os.Stat(filename) - if err != nil { - return nil, err - } - s2 := s.Sys().(*syscall.Stat_t) - if s2 == nil { - return nil, fmt.Errorf("could not get inode") - } - return &fStat{ - Inode: s2.Ino, - Dev: uint64(s2.Dev), - Size: s.Size(), - }, nil -} - -func (fstat *fStat) isNotRotated(lastFstat *fStat) bool { - if lastFstat == nil { - return true - } - return lastFstat.Inode == 0 || lastFstat.Dev == 0 || (fstat.Inode == lastFstat.Inode && fstat.Dev == lastFstat.Dev) -} - -func (fstat *fStat) searchFileByInode(d string) (string, error) { - files, err := os.ReadDir(d) - if err != nil { - return "", err - } - for _, file := range files { - if file.IsDir() { - continue - } - s, err := fileStat(filepath.Join(d, file.Name())) - if err != nil { - continue - } - if s.Inode == fstat.Inode && s.Dev == fstat.Dev { - return filepath.Join(d, file.Name()), nil - } - } - return "", fmt.Errorf("there is no file by inode:%d in %s", fstat.Inode, d) + return saferio.WriteJSON(pf.workDir, pf.filename, fp) } diff --git a/fpos_test.go b/fpos_test.go index 08d77be..3b0cc65 100644 --- a/fpos_test.go +++ b/fpos_test.go @@ -1,15 +1,14 @@ package followparser import ( - "encoding/json" "io" "log" "os" - "path" "sync" "testing" "time" + "github.com/monitoring-forge/saferio" "github.com/stretchr/testify/require" ) @@ -60,14 +59,12 @@ func TestPosFileRead(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - filename := path.Join(tmpDir, tc.name) if tc.content != nil { - fileContent, _ := json.Marshal(tc.content) - errWF := os.WriteFile(filename, fileContent, 0666) + errWF := saferio.WriteJSON(tmpDir, tc.name, tc.content) require.NoError(t, errWF, "failed to write test content to file") } - pf := newPosFile(filename) + pf := newPosFile(tmpDir, tc.name) pos, _, fstat, err := pf.read() require.Equal(t, tc.expectedError, err != nil, "error expectation mismatch") require.Equal(t, tc.expectedPos, pos, "pos expectation mismatch") @@ -107,15 +104,13 @@ func TestPosFileWrite(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - filename := path.Join(tmpDir, "pos_file.json") - pf := newPosFile(filename) + pf := newPosFile(tmpDir, "pos_file.json") err := pf.write(tc.pos, tc.fstat) require.Equal(t, tc.expectedError, err != nil, "error expectation mismatch") if !tc.expectedError { - content, _ := os.ReadFile(filename) readFPos := &fPos{} - err := json.Unmarshal(content, readFPos) + err := saferio.ReadJSON(pf.workDir, pf.filename, readFPos) require.NoError(t, err, "failed to unmarshal content") require.Equal(t, tc.pos, readFPos.Pos, "pos expectation mismatch") @@ -136,8 +131,7 @@ func TestPosFileConcurrentReadAndWrite(t *testing.T) { _ = os.RemoveAll(tmpDir) }() - filename := path.Join(tmpDir, "pos_file.json") - pf := newPosFile(filename) + pf := newPosFile(tmpDir, "pos_file.json") initialPos := int64(789) initialFStat := &fStat{ diff --git a/fstat.go b/fstat.go new file mode 100644 index 0000000..49279c4 --- /dev/null +++ b/fstat.go @@ -0,0 +1,60 @@ +package followparser + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +type fStat struct { + Inode uint64 + Dev uint64 + Size int64 +} + +// fileStat retrieves the file statistics for the specified filename, returning an fStat struct containing the inode, device, and size information. +func fileStat(filename string) (*fStat, error) { + s, err := os.Stat(filename) + if err != nil { + return nil, err + } + s2 := s.Sys().(*syscall.Stat_t) + if s2 == nil { + return nil, fmt.Errorf("could not get inode") + } + return &fStat{ + Inode: s2.Ino, + Dev: uint64(s2.Dev), + Size: s.Size(), + }, nil +} + +// isNotRotated checks if the file represented by fstat has not been rotated compared to lastFstat. +func (fstat *fStat) isNotRotated(lastFstat *fStat) bool { + if lastFstat == nil { + return true + } + return lastFstat.Inode == 0 || lastFstat.Dev == 0 || (fstat.Inode == lastFstat.Inode && fstat.Dev == lastFstat.Dev) +} + +// searchFileByInode searches for a file in the specified directory that matches the given fStat (inode and device). +func (fstat *fStat) searchFileByInode(d string) (string, error) { + files, err := os.ReadDir(d) + if err != nil { + return "", err + } + for _, file := range files { + if file.IsDir() { + continue + } + s, err := fileStat(filepath.Join(d, file.Name())) + if err != nil { + continue + } + if s.Inode == fstat.Inode && s.Dev == fstat.Dev { + return filepath.Join(d, file.Name()), nil + } + } + return "", fmt.Errorf("there is no file by inode:%d in %s", fstat.Inode, d) +} diff --git a/fstat_test.go b/fstat_test.go new file mode 100644 index 0000000..e779dfc --- /dev/null +++ b/fstat_test.go @@ -0,0 +1,156 @@ +package followparser + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFileStat(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("regular file", func(t *testing.T) { + fname := filepath.Join(tmpDir, "test.log") + content := []byte("hello world\n") + err := os.WriteFile(fname, content, 0644) + require.NoError(t, err, "failed to create test file") + + fs, err := fileStat(fname) + require.NoError(t, err, "fileStat should not return error for existing file") + require.NotNil(t, fs, "fileStat should return non-nil fStat") + require.NotZero(t, fs.Inode, "inode should not be zero") + require.NotZero(t, fs.Dev, "dev should not be zero") + require.Equal(t, int64(len(content)), fs.Size, "size should match file content length") + }) + + t.Run("file does not exist", func(t *testing.T) { + fname := filepath.Join(tmpDir, "nonexistent.log") + fs, err := fileStat(fname) + require.Error(t, err, "fileStat should return error for non-existent file") + require.Nil(t, fs, "fileStat should return nil fStat for non-existent file") + }) + + t.Run("directory", func(t *testing.T) { + dname := filepath.Join(tmpDir, "testdir") + err := os.Mkdir(dname, 0755) + require.NoError(t, err, "failed to create test directory") + + fs, err := fileStat(dname) + require.NoError(t, err, "fileStat should not return error for directory") + require.NotNil(t, fs, "fileStat should return non-nil fStat for directory") + require.NotZero(t, fs.Inode, "inode should not be zero for directory") + }) +} + +func TestFStatIsNotRotated(t *testing.T) { + tests := []struct { + name string + fstat *fStat + lastFstat *fStat + expected bool + }{ + { + name: "lastFstat is nil", + fstat: &fStat{Inode: 1, Dev: 2}, + lastFstat: nil, + expected: true, + }, + { + name: "same inode and dev", + fstat: &fStat{Inode: 1, Dev: 2}, + lastFstat: &fStat{Inode: 1, Dev: 2}, + expected: true, + }, + { + name: "different inode same dev", + fstat: &fStat{Inode: 2, Dev: 2}, + lastFstat: &fStat{Inode: 1, Dev: 2}, + expected: false, + }, + { + name: "same inode different dev", + fstat: &fStat{Inode: 1, Dev: 3}, + lastFstat: &fStat{Inode: 1, Dev: 2}, + expected: false, + }, + { + name: "lastFstat inode is zero", + fstat: &fStat{Inode: 1, Dev: 2}, + lastFstat: &fStat{Inode: 0, Dev: 2}, + expected: true, + }, + { + name: "lastFstat dev is zero", + fstat: &fStat{Inode: 1, Dev: 2}, + lastFstat: &fStat{Inode: 1, Dev: 0}, + expected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual := tc.fstat.isNotRotated(tc.lastFstat) + require.Equal(t, tc.expected, actual, "isNotRotated result mismatch") + }) + } +} + +func TestFStatSearchFileByInode(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("find matching file", func(t *testing.T) { + fname := filepath.Join(tmpDir, "target.log") + err := os.WriteFile(fname, []byte("target content\n"), 0644) + require.NoError(t, err, "failed to create target file") + + fs, err := fileStat(fname) + require.NoError(t, err, "failed to get file stat") + + found, err := fs.searchFileByInode(tmpDir) + require.NoError(t, err, "searchFileByInode should find matching file") + require.Equal(t, fname, found, "found file path should match target") + }) + + t.Run("no matching file", func(t *testing.T) { + fs := &fStat{Inode: 999999999, Dev: 999999999} + _, err := fs.searchFileByInode(tmpDir) + require.Error(t, err, "searchFileByInode should return error when no matching file") + }) + + t.Run("directory does not exist", func(t *testing.T) { + fs := &fStat{Inode: 1, Dev: 2} + _, err := fs.searchFileByInode(filepath.Join(tmpDir, "nonexistent")) + require.Error(t, err, "searchFileByInode should return error for non-existent directory") + }) + + t.Run("skip directories", func(t *testing.T) { + dname := filepath.Join(tmpDir, "subdir") + err := os.Mkdir(dname, 0755) + require.NoError(t, err, "failed to create subdirectory") + + fs := &fStat{Inode: 999999999, Dev: 999999999} + _, err = fs.searchFileByInode(tmpDir) + require.Error(t, err, "searchFileByInode should not match directories") + }) + + t.Run("ignore files that cannot be stated", func(t *testing.T) { + goodFile := filepath.Join(tmpDir, "good.log") + err := os.WriteFile(goodFile, []byte("good content\n"), 0644) + require.NoError(t, err, "failed to create good file") + + // searchFileByInode ignores files that fail fileStat. Create a symlink + // to a non-existent target so that fileStat returns an error and is skipped. + symlink := filepath.Join(tmpDir, "broken_symlink") + err = os.Symlink(filepath.Join(tmpDir, "does_not_exist"), symlink) + require.NoError(t, err, "failed to create broken symlink") + + fs, err := fileStat(goodFile) + require.NoError(t, err, "failed to get good file stat") + + found, err := fs.searchFileByInode(tmpDir) + require.NoError(t, err, "searchFileByInode should find good file while ignoring broken symlink") + require.Equal(t, goodFile, found, "found file path should match good file") + }) +} diff --git a/go.mod b/go.mod index 0f4bcaf..97f80c7 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/monitoring-forge/followparser -go 1.25 +go 1.25.0 require ( github.com/avast/retry-go/v4 v4.7.0 - github.com/stretchr/testify v1.12.0 + github.com/stretchr/testify v1.12.1 ) -require gopkg.in/yaml.v3 v3.0.1 // indirect +require go.yaml.in/yaml/v3 v3.0.5 // indirect + +require github.com/monitoring-forge/saferio v0.0.3 diff --git a/go.sum b/go.sum index c6c5464..e697d97 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/monitoring-forge/saferio v0.0.3 h1:1i3va3QQ9QPunwsgekDzC8RV91KT1Xcb++Z0Dnl9zFY= +github.com/monitoring-forge/saferio v0.0.3/go.mod h1:1o6nyDJ4isee9VH3eUx5uw4C7LEgPFsq7M3tO+VMyvM= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=