From 4cc308278de644f566c70b1fdfef135c01106a58 Mon Sep 17 00:00:00 2001 From: CSM Bot <105446864+csmbot@users.noreply.github.com> Date: Tue, 12 May 2026 15:23:46 -0400 Subject: [PATCH] Mirror internal repository with cleaned references --- Makefile | 2 +- README.md | 1 + go.mod | 4 +- go.sum | 4 +- gofsutil.go | 10 + gofsutil_fs_test.go | 4 +- gofsutil_fsck.go | 405 +++++ gofsutil_fsck_test.go | 1302 +++++++++++++++++ gofsutil_mock.go | 67 + gofsutil_mount_linux.go | 218 ++- gofsutil_mount_linux_test.go | 208 +++ gofsutil_reclaim.go | 78 + gofsutil_reclaim_linux.go | 186 +++ gofsutil_reclaim_linux_test.go | 548 +++++++ gofsutil_reclaim_test.go | 282 ++++ gofsutil_test.go | 7 +- gofsutil_utils.go | 20 + gofsutil_utils_test.go | 266 ++++ tests/fsckdata/README.md | 38 + .../img_0_d_corrupt_journal_nr_users.gz | Bin 0 -> 8816 bytes tests/fsckdata/img_0_f_bad_bbitmap.gz | Bin 0 -> 2598 bytes .../fsckdata/img_0_f_first_meta_bg_too_big.gz | Bin 0 -> 586 bytes tests/fsckdata/img_0_f_zero_inode_size.gz | Bin 0 -> 10862 bytes tests/fsckdata/img_12_f_badbblocks.gz | Bin 0 -> 410 bytes tests/fsckdata/img_12_f_baddir.gz | Bin 0 -> 14381 bytes tests/fsckdata/img_12_f_clear_orphan_file.gz | Bin 0 -> 12867 bytes tests/fsckdata/img_12_f_ext_journal.gz | Bin 0 -> 49577 bytes .../img_4_f_bad_disconnected_inode.gz | Bin 0 -> 1541 bytes tests/fsckdata/img_4_f_badcluster.gz | Bin 0 -> 3208 bytes tests/fsckdata/img_4_f_jnl_etb_alloc_fail.gz | Bin 0 -> 3549 bytes tests/fsckdata/img_4_f_zero_super.gz | Bin 0 -> 13336 bytes tests/fsckdata/img_8_f_crashdisk.gz | Bin 0 -> 1062 bytes tests/fsckdata/img_8_f_extent_too_deep.gz | Bin 0 -> 2644 bytes tests/fsckdata/img_8_f_illitable.gz | Bin 0 -> 427 bytes 34 files changed, 3594 insertions(+), 56 deletions(-) create mode 100644 gofsutil_fsck.go create mode 100644 gofsutil_fsck_test.go create mode 100644 gofsutil_reclaim.go create mode 100644 gofsutil_reclaim_linux.go create mode 100644 gofsutil_reclaim_linux_test.go create mode 100644 gofsutil_reclaim_test.go create mode 100644 tests/fsckdata/README.md create mode 100644 tests/fsckdata/img_0_d_corrupt_journal_nr_users.gz create mode 100644 tests/fsckdata/img_0_f_bad_bbitmap.gz create mode 100644 tests/fsckdata/img_0_f_first_meta_bg_too_big.gz create mode 100644 tests/fsckdata/img_0_f_zero_inode_size.gz create mode 100644 tests/fsckdata/img_12_f_badbblocks.gz create mode 100644 tests/fsckdata/img_12_f_baddir.gz create mode 100644 tests/fsckdata/img_12_f_clear_orphan_file.gz create mode 100644 tests/fsckdata/img_12_f_ext_journal.gz create mode 100644 tests/fsckdata/img_4_f_bad_disconnected_inode.gz create mode 100644 tests/fsckdata/img_4_f_badcluster.gz create mode 100644 tests/fsckdata/img_4_f_jnl_etb_alloc_fail.gz create mode 100644 tests/fsckdata/img_4_f_zero_super.gz create mode 100644 tests/fsckdata/img_8_f_crashdisk.gz create mode 100644 tests/fsckdata/img_8_f_extent_too_deep.gz create mode 100644 tests/fsckdata/img_8_f_illitable.gz diff --git a/Makefile b/Makefile index 284f53c..524c656 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ clean: rm -f go-code-tester *.log *.out cover* go-code-tester: - git clone --depth 1 git@github.com:CSM/actions.git temp-repo + git clone --depth 1 git@github.com:dell/actions.git temp-repo cp temp-repo/go-code-tester/entrypoint.sh ./go-code-tester chmod +x go-code-tester rm -rf temp-repo diff --git a/README.md b/README.md index b074c70..97652a3 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,4 @@ You may obtain a copy of the License at # Mount A portable Go library for filesystem related operations such as mount, format, etc. + diff --git a/go.mod b/go.mod index 6506e99..b90f244 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/dell/gofsutil -go 1.25 +go 1.26 require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.0 - golang.org/x/sys v0.39.0 + golang.org/x/sys v0.43.0 ) require ( diff --git a/go.sum b/go.sum index 898b2a3..e499f3f 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gofsutil.go b/gofsutil.go index 7b6bc0f..98cb963 100644 --- a/gofsutil.go +++ b/gofsutil.go @@ -49,6 +49,11 @@ type FSinterface interface { fsInfo(ctx context.Context, path string) (int64, int64, int64, int64, int64, int64, error) getNVMeController(device string) (string, error) + // Space reclamation — private (architecture-specific) + fstrim(ctx context.Context, mountPoint string) (*FstrimResult, error) + blkdiscard(ctx context.Context, devicePath string) (*BlkdiscardResult, error) + checkDiscardSupport(ctx context.Context, devicePath string) (*DiscardCapability, error) + // Architecture agnostic implementations, generally just wrappers GetDiskFormat(ctx context.Context, disk string) (string, error) Format(ctx context.Context, source, target, fsType string, options ...string) error @@ -75,6 +80,11 @@ type FSinterface interface { GetMpathNameFromDevice(ctx context.Context, device string) (string, error) FsInfo(ctx context.Context, path string) (int64, int64, int64, int64, int64, int64, error) GetNVMeController(device string) (string, error) + + // Space reclamation — public (architecture-agnostic wrappers) + Fstrim(ctx context.Context, mountPoint string) (*FstrimResult, error) + Blkdiscard(ctx context.Context, devicePath string) (*BlkdiscardResult, error) + CheckDiscardSupport(ctx context.Context, devicePath string) (*DiscardCapability, error) } // MultipathDevDiskByIDPrefix is a pathname prefix for items located in /dev/disk/by-id diff --git a/gofsutil_fs_test.go b/gofsutil_fs_test.go index 3b0d942..9571720 100644 --- a/gofsutil_fs_test.go +++ b/gofsutil_fs_test.go @@ -1322,13 +1322,13 @@ func TestFS_FindFSType(t *testing.T) { wantErr bool }{ { - name: "Success", + name: "Error_Mount_Path_Not_Found", args: args{ ctx: context.Background(), mountpoint: "mount_path", }, wantFsType: "", - wantErr: false, + wantErr: true, }, } for _, tt := range tests { diff --git a/gofsutil_fsck.go b/gofsutil_fsck.go new file mode 100644 index 0000000..bfde72a --- /dev/null +++ b/gofsutil_fsck.go @@ -0,0 +1,405 @@ +package gofsutil + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + StartedFSCheckEvent = "Starting file system check" + FoundNoErrorsEvent = "Found no errors in file system" + FoundErrorsEvent = "Found errors in file system" + FoundDirtyLogEvent = "Found dirty log in file system" + FSCheckTimedOutEvent = "File system check timed out" + FSCheckFailedEvent = "File system check failed" + + StartFSRepairEvent = "Starting file system repair" + FinishedFSRepairEvent = "File system errors fixed" + FSRepairTimedOutEvent = "File system repair timed out" + FSRepairFailedEvent = "File system errors could not be fixed" + StartLogReplayEvent = "Starting file system log replay" + LogReplayFailedEvent = "File system log replay failed" + LogReplayDoneEvent = "File system log replay done" +) + +var FSCheckLinesToLog = 20 + +// e2fsck exit codes: https://www.man7.org/linux/man-pages/man8/e2fsck.8.html +type extExitCode int + +func (e extExitCode) isFoundErrors() bool { + // 1 or 2 were never observed, but according to e2fsck documentation + // they indicate presense of repairable errors. + return e&1 != 0 || e&2 != 0 || e&4 != 0 +} + +func (e extExitCode) isCanceledByUser() bool { + return e&32 != 0 +} + +// xfsrepair exit codes: https://www.man7.org/linux/man-pages/man8/xfs_repair.8.html +const ( + xfsCodeNoErrorsFound = 0 + xfsCodeFoundRepairableErrors = 1 + xfsCodeFoundDirtyLog = 2 + xfsCodeErrorsRepaired = 0 +) + +// FSChecker can do file system error checks using "e2fsck -nf" for ext and "xfs_repair -n" for xfs. +// It can also fix repairable errors using "e2fsck -p" or "xfs_repair" respectively. +// FSChecker instance for a given file system is created using GetFSChecker. +type FSChecker interface { + // Check checks the fs for errors. If repairable errors are found and doRepair is true, + // Check tries to do a safe repair. If repairable errors are found and doRepair is false, + // Check returns an error. If unrepairable errors are found, or repair fails, Check returns an error. + // If the context passed to Check times out or is closed before fs check and repair completes, + // the process in interrupted and Check returns an error. + Check(ctx context.Context, doRepair bool) error + // repair is called when needed by the Check function to fix repairable file system errors. + repair(ctx context.Context) error +} + +// Allows monitoring fs check and repair lifecycle +type FSCheckObserver interface { + OnEvent(message string) +} + +type fsCheckerCommon struct { + devPath string + observer FSCheckObserver +} + +type ( + extChecker fsCheckerCommon + xfsChecker fsCheckerCommon +) + +type NoopFSCheckObserver struct{} + +func (n *NoopFSCheckObserver) OnEvent(_ string) {} + +func GetFSChecker(devPath, fsType string, observer FSCheckObserver) (FSChecker, error) { + if observer == nil { + observer = &NoopFSCheckObserver{} + } + + ch := &fsCheckerCommon{ + devPath: devPath, + observer: observer, + } + + fsType = strings.ToLower(fsType) + + switch fsType { + case "ext4", "ext3", "ext2", "ext": + return (*extChecker)(ch), nil + case "xfs": + return (*xfsChecker)(ch), nil + } + + return nil, fmt.Errorf("unsupported fs type: %s", fsType) +} + +func (ch *extChecker) Check(ctx context.Context, doRepair bool) error { + defer logDuration("ext check/repair", time.Now()) + + ch.observer.OnEvent(StartedFSCheckEvent) + + // Do up to 2 iterations: first for check and optional repair, second for post-repair check + for pass := 0; pass < 2; pass++ { + // Check for file system errors + rc, err := OSExecFn(ctx, "e2fsck", "-nf", ch.devPath) + code := extExitCode(rc) + + if err == nil { + // Implies exit code 0 + if pass == 0 { + ch.observer.OnEvent(FoundNoErrorsEvent) + } else { + ch.observer.OnEvent(FinishedFSRepairEvent) + } + return nil + + } else if code.isCanceledByUser() || isProcKilled(err) { + ch.observer.OnEvent(FSCheckTimedOutEvent) + return fmt.Errorf("checking file system on %s interrupted due to context timeout (%d)", ch.devPath, rc) + + } else if code.isFoundErrors() { + if pass == 0 { + // First pass found fs errors + ch.observer.OnEvent(FoundErrorsEvent) + if doRepair { + err := ch.repair(ctx) + if err != nil { + // Error out on timeout + return err + } + // Do another pass for final check + continue + } + return fmt.Errorf("file system on %s has errors (%d)", ch.devPath, rc) + } + + // Final (post-repair) pass still found fs errors + ch.observer.OnEvent(FSRepairFailedEvent) + return fmt.Errorf("repairing file system on %s failed (%d): %w", ch.devPath, rc, err) + } + + if pass == 0 { + ch.observer.OnEvent(FSCheckFailedEvent) + return fmt.Errorf("checking file system on %s failed (%d): %w", ch.devPath, rc, err) + } + ch.observer.OnEvent(FSRepairFailedEvent) + return fmt.Errorf("repairing file system on %s failed (%d): %w", ch.devPath, rc, err) + } + + // Should never get here, but compiler requires this. + return fmt.Errorf("unexpected error") +} + +func (ch *extChecker) repair(ctx context.Context) error { + defer logDuration("ext repair", time.Now()) + + ch.observer.OnEvent(StartFSRepairEvent) + + // Safely repair ext with the preen option. Not all errors can be fixed + // in this mode, but this will be verified during the final full check. + + rc, err := OSExecFn(ctx, "e2fsck", "-p", ch.devPath) + code := extExitCode(rc) + + log.Debugf("e2fsck -p %s: exit code %d", ch.devPath, rc) + + if code.isCanceledByUser() || isProcKilled(err) { + ch.observer.OnEvent(FSRepairTimedOutEvent) + return fmt.Errorf("repairing file system on %s interrupted due to context timeout (%d)", ch.devPath, rc) + } + // All other errors and exit codes in the preen mode are not indicative of success + // or failure (see tests/fsckdata/README.md), so we'll rely on the final errors check. + + return nil +} + +func (ch *xfsChecker) Check(ctx context.Context, doRepair bool) error { + defer logDuration("xfs check/repair", time.Now()) + + ch.observer.OnEvent(StartedFSCheckEvent) + + // Normally do one pass. Second pass is needed if dirty log is found. + for pass := 0; pass < 2; pass++ { + + rc, err := OSExecFn(ctx, "xfs_repair", "-n", ch.devPath) + + if err == nil && rc == xfsCodeNoErrorsFound { + ch.observer.OnEvent(FoundNoErrorsEvent) + return nil + + } else if rc == xfsCodeFoundRepairableErrors { + ch.observer.OnEvent(FoundErrorsEvent) + if doRepair { + return ch.repair(ctx) + } + return fmt.Errorf("file system on %s has errors that can be fixed (%d)", ch.devPath, rc) + + } else if rc == xfsCodeFoundDirtyLog { + if pass == 0 { + ch.observer.OnEvent(FoundDirtyLogEvent) + err := ch.replayLog(ctx) + if err != nil { + return fmt.Errorf("failed to replay log of file system on %s: %w", ch.devPath, err) + } + // Re-run fs check again and possibly do repair + continue + } + return fmt.Errorf("file system on %s still has dirty log after replay (%d)", ch.devPath, rc) + + } else if isProcKilled(err) { + ch.observer.OnEvent(FSCheckTimedOutEvent) + return fmt.Errorf("checking file system on %s interrupted due to context timeout: %w", ch.devPath, err) + } + + ch.observer.OnEvent(FSCheckFailedEvent) + return fmt.Errorf("checking file system on %s failed (%d): %w", ch.devPath, rc, err) + } + + // Should never get here, but compiler requires this. + return fmt.Errorf("unexpected error") +} + +func (ch *xfsChecker) repair(ctx context.Context) error { + defer logDuration("xfs repair", time.Now()) + + ch.observer.OnEvent(StartFSRepairEvent) + + rc, err := OSExecFn(ctx, "xfs_repair", ch.devPath) + + if err == nil && rc == xfsCodeErrorsRepaired { + ch.observer.OnEvent(FinishedFSRepairEvent) + return nil + } else if isProcKilled(err) { + ch.observer.OnEvent(FSRepairTimedOutEvent) + return fmt.Errorf("repairing file system on %s interrupted due to context timeout: %w", ch.devPath, err) + } + + ch.observer.OnEvent(FSRepairFailedEvent) + return fmt.Errorf("repairing file system on %s failed (%d): %w", ch.devPath, rc, err) +} + +// try mount-unmounting to a temp dir to cause the log replay by kernel +func (ch *xfsChecker) replayLog(ctx context.Context) (retErr error) { + ch.observer.OnEvent(StartLogReplayEvent) + defer func() { + if retErr != nil { + ch.observer.OnEvent(LogReplayFailedEvent) + } else { + ch.observer.OnEvent(LogReplayDoneEvent) + } + }() + + fsMounted := false + tmpMountPoint, err := os.MkdirTemp("", "replay") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer func() { + // SAFETY: Don't attempt to remove tmpMountPoint if the fs is still mounted to it. + if !fsMounted { + // SAFETY: Intentionally not using RemoveAll, since this directory is expected to be empty after unmount. + err := os.Remove(tmpMountPoint) + if err != nil { + log.Errorf("Failed to remove %s: %v", tmpMountPoint, err) + } + } + }() + + // SAFETY: Mounting fs read-only to prevent accidental data alteration. + rc, err := OSExecFn(ctx, "mount", "-o", "ro", ch.devPath, tmpMountPoint) + + if err == nil && rc == 0 { + fsMounted = true + rc, err := OSExecFn(ctx, "umount", tmpMountPoint) + + if err == nil && rc == 0 { + fsMounted = false + return nil + } + return fmt.Errorf("failed to unmount file system on %s to %s (%d): %w", ch.devPath, tmpMountPoint, rc, err) + } + return fmt.Errorf("failed to mount file system %s to %s (%d): %w", ch.devPath, tmpMountPoint, rc, err) +} + +// Mockable function for testing +var OSExecFn = execOSCommand + +func execOSCommand(ctx context.Context, name string, args ...string) (rc int, err error) { + cmd := exec.CommandContext(ctx, name, args...) // #nosec G702 + + // Start the child process in a new process group + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // Give the Cancel (SIGINT) some time to work before a hard kill + cmd.WaitDelay = 2 * time.Second + + cmd.Cancel = func() error { + // Send SIGINT to the child's process group to also signal its children. + return syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) + } + + // Collect all output in one buffer + errBuffer := bytes.Buffer{} + cmd.Stdout = &errBuffer + cmd.Stderr = &errBuffer + + err = cmd.Run() + if err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + rc = exitError.ExitCode() + } else { + rc = -1 + } + out := truncOutput(errBuffer, name, args...) + if out.Len() > 0 { + log.Errorln(out.String()) + } + } + + return rc, err +} + +func isProcKilled(err error) bool { + var ee *exec.ExitError + if errors.As(err, &ee) { + if ws, ok := ee.Sys().(syscall.WaitStatus); ok { + if ws.Signaled() { + // The process terminated due to an unhandled signal. + // Normally, ws.Signal() would be syscall.SIGINT or syscall.SIGKILL + // that we sent upon the context expiration. But we will not check + // for specific signal here, since the process can be killed externally, + // and we still want to interpret this as interruption. + return true + } + } + } + return false +} + +func logDuration(stage string, startTime time.Time) { + log.Infof("%s took %.3fs", stage, time.Since(startTime).Seconds()) +} + +// Collect up to FSCheckLinesToLog last lines in the output. Empty lines are ignored. +func truncOutput(errBuf bytes.Buffer, name string, args ...string) *bytes.Buffer { + outBuf := bytes.Buffer{} + + if errBuf.Len() == 0 { + return &outBuf + } + + n := FSCheckLinesToLog + + // Split by newline and filter out empty lines + allLines := strings.Split(errBuf.String(), "\n") + + lines := make([]string, 0, n) + + // Iterate all err lines in the reverse order and filter out empty lines + for i := len(allLines) - 1; i >= 0; i-- { + if len(lines) >= n { + lines = append(lines, "...") + break + } + if trimmed := strings.TrimRight(allLines[i], " \t"); trimmed != "" { + lines = append(lines, trimmed) + } + } + + if len(lines) == 0 { + return &outBuf + } + + cmdStr := name + if len(args) > 0 { + cmdStr += " " + strings.Join(args, " ") + } + + outBuf.WriteString("stderr from command: " + cmdStr + "\n") + + // Write the collected lines to outBuf taking into account the reverse order + for i := len(lines) - 1; i >= 0; i-- { + outBuf.WriteString(lines[i]) + if i > 0 { + outBuf.WriteByte('\n') + } + } + + return &outBuf +} diff --git a/gofsutil_fsck_test.go b/gofsutil_fsck_test.go new file mode 100644 index 0000000..84e71f6 --- /dev/null +++ b/gofsutil_fsck_test.go @@ -0,0 +1,1302 @@ +package gofsutil + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "reflect" + "regexp" + "strings" + "syscall" + "testing" + "time" +) + +// ---- Test entrypoint - either runs tests (default) or acts as a helper process (if -helperProc is passed) ---- + +func TestMain(m *testing.M) { + helperProc := flag.Bool("helperProc", false, "start a helper process instead of running tests") + helperReadyDir := flag.String("helperReadyDir", "", "ready dir for helper process") + helperNoTrap := flag.Bool("helperNoTrap", false, "helper process without SIGINT trap") + + flag.Parse() + + if *helperProc == true { + // Act as the helper child process; do NOT run the test suite. + if err := os.MkdirAll(*helperReadyDir, 0o755); err != nil { + panic(err) + } + fmt.Println("== helper started") + sigCh := make(chan os.Signal, 1) + if *helperNoTrap == false { + signal.Notify(sigCh, syscall.SIGINT) + } + select { + case <-sigCh: + fmt.Println("== helper got SIGINT") + os.Exit(32) + case <-time.After(time.Second * 30): + fmt.Println("== helper finished") + os.Exit(0) + } + } + + // Normal test execution + os.Exit(m.Run()) +} + +// ---- Test utilities --------------------------------------------------------- + +type capturingObserver struct { + events []string +} + +func (c *capturingObserver) OnEvent(msg string) { + c.events = append(c.events, msg) +} + +func setMockExec(f func(ctx context.Context, name string, args ...string) (int, error)) func() { + orig := OSExecFn + OSExecFn = f + return func() { OSExecFn = orig } +} + +func assertEvents(t *testing.T, got []string, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected events.\n got : %#v\n want: %#v", got, want) + } +} + +// ---- GetFSChecker ----------------------------------------------------------- + +func TestGetFSChecker_Supported(t *testing.T) { + obs := &capturingObserver{} + + for _, fsType := range []string{"ext4", "ext3", "ext2", "ext"} { + ch, err := GetFSChecker("/dev/sda1", fsType, obs) + if err != nil { + t.Fatalf("unexpected error for %s: %v", fsType, err) + } + if ch == nil { + t.Fatalf("expected non-nil checker for %s", fsType) + } + if _, ok := ch.(*extChecker); !ok { + t.Fatalf("expected extChecker for %s, got %T", fsType, ch) + } + } + + ch2, err := GetFSChecker("/dev/sdb1", "xfs", obs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch2 == nil { + t.Fatalf("expected non-nil checker for xfs") + } + if _, ok := ch2.(*xfsChecker); !ok { + t.Fatalf("expected xfsChecker, got %T", ch2) + } +} + +func TestGetFSChecker_UnsupportedReturnsNil(t *testing.T) { + obs := &capturingObserver{} + ch, err := GetFSChecker("/dev/sdc1", "btrfs", obs) + if err == nil { + t.Fatalf("expected error, got nil") + } + if ch != nil { + t.Fatalf("expected nil checker for unsupported fs") + } +} + +// ---- EXT: Check ------------------------------------------------------------- + +func TestEXTChecker_Check_NoErrors(t *testing.T) { + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) == 2 && args[0] == "-nf" { + return 0, nil // no errors + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{ + StartedFSCheckEvent, + FoundNoErrorsEvent, + } + assertEvents(t, obs.events, want) +} + +func TestEXTChecker_Check_Errors_NoRepair(t *testing.T) { + // e2fsck -nf -> rc=1 with non-nil err (errors found), doRepair=false + // err != nil required to bypass the err==nil->FoundNoErrors path + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) >= 1 && args[0] == "-nf" { + return 1, errors.New("exit status 1") + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), false) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + } + assertEvents(t, obs.events, want) +} + +func TestEXTChecker_Check_Errors_DoRepair_Success(t *testing.T) { + // Check first (rc=1, err non-nil), then repair (rc=0), then final check (rc=0) + // e2fsck -nf -> rc=1, err non-nil (errors found) + // e2fsck -p -> rc=0, err nil (repair succeeds) + // e2fsck -nf -> rc=0, err nil -> FinishedFSRepairEvent + callCount := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) >= 1 && args[0] == "-nf" { + callCount++ + if callCount == 1 { + // First check finds errors + return 1, errors.New("exit status 1") + } + // Second check after repair finds no errors + return 0, nil + } + if name == "e2fsck" && len(args) >= 1 && args[0] == "-p" { + return 0, nil + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FinishedFSRepairEvent, + } + assertEvents(t, obs.events, want) +} + +func TestEXTChecker_Check_Errors_DoRepair_TimedOut(t *testing.T) { + // Check first (rc=1, err non-nil), then repair times out (rc=32) + // e2fsck -nf -> rc=1, err non-nil (errors found) + // e2fsck -p -> rc=32 (canceled by user); check never runs + callCount := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) >= 1 && args[0] == "-nf" { + callCount++ + if callCount == 1 { + // First check finds errors + return 1, errors.New("exit status 1") + } + } + if name == "e2fsck" && len(args) >= 1 && args[0] == "-p" { + return 32, errors.New("signal: killed") + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), true) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FSRepairTimedOutEvent, + } + assertEvents(t, obs.events, want) +} + +func TestEXTChecker_Check_Errors_DoRepair_Failed(t *testing.T) { + // Check first (rc=1, err non-nil), then repair (rc=4, err nil), then final check still finds errors (rc=4) + // e2fsck -nf -> rc=1, err non-nil (errors found) + // e2fsck -p -> rc=4, err nil (repair fails silently) + // e2fsck -nf -> rc=4, err non-nil (final check finds errors) + callCount := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) >= 1 && args[0] == "-nf" { + callCount++ + if callCount == 1 { + // First check finds errors + return 1, errors.New("exit status 1") + } + // Second check after repair still finds errors + return 4, errors.New("exit status 4") + } + if name == "e2fsck" && len(args) >= 1 && args[0] == "-p" { + return 4, nil // repair fails silently, no error + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), true) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FSRepairFailedEvent, + } + assertEvents(t, obs.events, want) +} + +func TestEXTChecker_Check_DoRepair_Success_CheckStillFindsErrors(t *testing.T) { + // Check first (rc=1, err non-nil), then repair succeeds (rc=0), but final check still finds errors (rc=1) + // e2fsck -nf -> rc=1, err non-nil (errors found) + // e2fsck -p -> rc=0, err nil (repair succeeds) + // e2fsck -nf -> rc=1, err non-nil (final check still finds errors) + callCount := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) >= 1 && args[0] == "-nf" { + callCount++ + if callCount == 1 { + // First check finds errors + return 1, errors.New("exit status 1") + } + // Second check after repair still finds errors + return 1, errors.New("exit status 1") + } + if name == "e2fsck" && len(args) >= 1 && args[0] == "-p" { + return 0, nil // repair succeeds + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), true) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FSRepairFailedEvent, + } + assertEvents(t, obs.events, want) +} + +func TestEXTChecker_Check_TimedOut(t *testing.T) { + // e2fsck -nf -> rc=32, non-nil err (isCanceledByUser) + // err != nil bypasses the err==nil path; isCanceledByUser() -> FSCheckTimedOutEvent + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) >= 1 && args[0] == "-nf" { + return 32, errors.New("signal: killed") + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), false) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FSCheckTimedOutEvent, + } + assertEvents(t, obs.events, want) +} + +func TestEXTChecker_Check_Failed_Generic(t *testing.T) { + // e2fsck -nf -> rc=8 and non-nil err -> generic failure path + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) >= 1 && args[0] == "-nf" { + return 8, errors.New("exit status 8") + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), false) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FSCheckFailedEvent, + } + assertEvents(t, obs.events, want) +} + +func TestEXTChecker_Check_FinalPass_GenericFailure(t *testing.T) { + // Test the uncovered final error path: second pass with unexpected exit code + // First pass: rc=1 (errors found) -> repair -> second pass: rc=8 (unexpected) + callCount := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "e2fsck" && len(args) >= 1 && args[0] == "-nf" { + callCount++ + if callCount == 1 { + // First pass finds errors to trigger repair + return 1, errors.New("exit status 1") + } + // Second pass returns unexpected exit code (not 1, 2, 4, or 32) + return 8, errors.New("exit status 8") + } + if name == "e2fsck" && len(args) >= 1 && args[0] == "-p" { + return 0, nil // repair succeeds + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sda1", "ext4", obs) + err := ch.Check(context.Background(), true) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FSRepairFailedEvent, + } + assertEvents(t, obs.events, want) +} + +// ---- XFS: Check + Replay + Repair ------------------------------------------ + +func TestXFSChecker_Check_NoErrors(t *testing.T) { + // xfs_repair -n -> 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "xfs_repair" && len(args) == 2 && args[0] == "-n" { + return 0, nil + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{ + StartedFSCheckEvent, + FoundNoErrorsEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_Repairable_NoRepair(t *testing.T) { + // xfs_repair -n -> 1 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "xfs_repair" && len(args) >= 1 && args[0] == "-n" { + return 1, nil + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), false) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_Repairable_DoRepair_Success(t *testing.T) { + // -n -> 1, repair -> rc=0 + call := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + switch name { + case "xfs_repair": + if len(args) >= 1 && args[0] == "-n" { + call++ + return 1, nil + } + // repair without -n + if len(args) == 1 { + call++ + return 0, nil + } + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FinishedFSRepairEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_Repairable_DoRepair_Failed(t *testing.T) { + // -n -> 1, repair -> rc!=0 or err!=nil + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "xfs_repair" && len(args) >= 1 && args[0] == "-n" { + return 1, nil + } + if name == "xfs_repair" && len(args) == 1 { + return 1, errors.New("repair failed") + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), true) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FSRepairFailedEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_DirtyLog_Replay_Success_Then_NoErrors(t *testing.T) { + // first -n -> 2 (dirty log) + // mount -> 0, umount -> 0 + // second -n -> 0 + checkCount := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + switch name { + case "xfs_repair": + if len(args) >= 1 && args[0] == "-n" { + checkCount++ + if checkCount == 1 { + return 2, nil + } + return 0, nil + } + case "mount": + return 0, nil + case "umount": + return 0, nil + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{ + StartedFSCheckEvent, + FoundDirtyLogEvent, + StartLogReplayEvent, + LogReplayDoneEvent, + FoundNoErrorsEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_DirtyLog_Replay_Success_Then_Repairable_DoRepair_Success(t *testing.T) { + // first -n -> 2 + // mount/umount -> 0 + // second -n -> 1 (repairable) + // repair -> 0 + checkCount := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + switch name { + case "xfs_repair": + if len(args) >= 1 && args[0] == "-n" { + checkCount++ + if checkCount == 1 { + return 2, nil + } + return 1, nil + } + // actual repair + if len(args) == 1 { + return 0, nil + } + case "mount": + return 0, nil + case "umount": + return 0, nil + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{ + StartedFSCheckEvent, + FoundDirtyLogEvent, + StartLogReplayEvent, + LogReplayDoneEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FinishedFSRepairEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_DirtyLog_Replay_Success_Then_StillDirty(t *testing.T) { + // first -n -> 2 + // mount/umount -> 0 + // second -n -> 2 again (still dirty) + checkCount := 0 + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + switch name { + case "xfs_repair": + if len(args) >= 1 && args[0] == "-n" { + checkCount++ + return 2, nil + } + case "mount": + return 0, nil + case "umount": + return 0, nil + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), false) + if err == nil { + t.Fatalf("expected error, got nil") + } + + // Note: On second dirty log, the implementation returns an error *without* + // emitting FSCheckFailedEvent. We assert exactly the emitted events. + want := []string{ + StartedFSCheckEvent, + FoundDirtyLogEvent, + StartLogReplayEvent, + LogReplayDoneEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_DirtyLog_Replay_MountFail(t *testing.T) { + // -n -> 2; mount fails + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + switch name { + case "xfs_repair": + if len(args) >= 1 && args[0] == "-n" { + return 2, nil + } + case "mount": + return 1, errors.New("mount failed") + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), false) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundDirtyLogEvent, + StartLogReplayEvent, + LogReplayFailedEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_DirtyLog_Replay_UmountFail(t *testing.T) { + // -n -> 2; mount ok; umount fails + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + switch name { + case "xfs_repair": + if len(args) >= 1 && args[0] == "-n" { + return 2, nil + } + case "mount": + return 0, nil + case "umount": + return 1, errors.New("umount failed") + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), false) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FoundDirtyLogEvent, + StartLogReplayEvent, + LogReplayFailedEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Check_Failed_Generic(t *testing.T) { + // -n -> rc=7, err non-nil => generic failed branch + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "xfs_repair" && len(args) >= 1 && args[0] == "-n" { + return 7, errors.New("xfs_repair failed") + } + return -1, errors.New("unexpected call") + }) + defer restore() + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + err := ch.Check(context.Background(), false) + if err == nil { + t.Fatalf("expected error, got nil") + } + + want := []string{ + StartedFSCheckEvent, + FSCheckFailedEvent, + } + assertEvents(t, obs.events, want) +} + +// nil observer -> NoopFSCheckObserver is used; xfs_repair -n returns 0 +func TestXFSChecker_WithNilObserver(t *testing.T) { + checkCalled := false + restore := setMockExec(func(_ context.Context, name string, args ...string) (int, error) { + if name == "xfs_repair" && len(args) == 2 && args[0] == "-n" { + checkCalled = true + return 0, nil // happy path: no errors + } + return -1, errors.New("unexpected call") + }) + defer restore() + + // Pass observer=nil to force NoopFSCheckObserver + checker, err := GetFSChecker("/dev/sdb1", "xfs", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if checker == nil { + t.Fatalf("expected non-nil checker") + } + + // Run check - this should internally emit events to NoopFSCheckObserver.OnEvent() + if err := checker.Check(context.Background(), false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !checkCalled { + t.Fatalf("expected check to be called") + } +} + +// ---- Test ExecOSCommand --------------------------------------------------------- + +// 1) Success path: command exits with 0 +func TestExecFn_Success(t *testing.T) { + // Use /bin/sh -c "true" to guarantee rc=0 + rc, err := execOSCommand(context.Background(), "/bin/sh", "-c", "true") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if rc != 0 { + t.Fatalf("expected rc=0, got %d", rc) + } +} + +// 2) Non-zero exit code: verify we get rc from ExitError and err != nil +func TestExecFn_NonZeroExit(t *testing.T) { + // exit 7 ensures a specific rc is propagated + rc, err := execOSCommand(context.Background(), "/bin/sh", "-c", "exit 7") + if err == nil { + t.Fatalf("expected non-nil error, got nil") + } + if rc != 7 { + t.Fatalf("expected rc=7, got %d", rc) + } +} + +// 3) Command not found: rc should be -1 (since it's not an ExitError) and err != nil +func TestExecFn_CommandNotFound(t *testing.T) { + rc, err := execOSCommand(context.Background(), "this-command-should-not-exist-xyz") + if err == nil { + t.Fatalf("expected error for non-existent command, got nil") + } + if rc != -1 { + t.Fatalf("expected rc=-1 for non-ExitError cases, got %d", rc) + } +} + +// 4) Non-zero exit code with stderr output: verify we get stderr from the command +func TestExecFn_ExitWithStderr(t *testing.T) { + // command writes to stderr and exits with code 7 + rc, err := execOSCommand(context.Background(), "/bin/sh", + "-c", "echo command-error-message >&2; exit 7") + //"-c", "for i in $(seq 15); do echo error-line-$i >&2; done; exit 7")) + if err == nil { + t.Fatalf("expected non-nil error, got nil") + } + if rc != 7 { + t.Fatalf("expected rc=7, got %d", rc) + } +} + +// 5) Deterministic cancellation after confirming the command started. +// The command (helper process) installs a SIGINT trap that exits with rc=32, +// then creates a "ready" dir and sleeps. We wait for the ready dir, +// then cancel the context and expect rc=32 with non-nil error. +func TestExecFn_CancelAfterStarted_TrapSIGINT_RC32(t *testing.T) { + // Prepare a temp path for readiness signal. + tmpDir := t.TempDir() + readyDir := filepath.Join(tmpDir, "ready") + + // The helper process: + // - create the ready file to signal it's actually started + // - monitors INT signal + // - exits 0 upon completion or 32 upon SIGINT receival + + // Context we will cancel after we see the ready dir created. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + type result struct { + rc int + err error + } + done := make(chan result, 1) + + // Run execOSCommand in a goroutine so we can wait for readiness. + go func() { + rc, err := execOSCommand(ctx, os.Args[0], "-helperProc=true", "-helperReadyDir="+readyDir) + done <- result{rc: rc, err: err} + }() + + // Wait deterministically until the helper created the dir (or time out the test). + waitCtx, stopWait := context.WithTimeout(context.Background(), 15*time.Second) + defer stopWait() + + // Poll for the ready dir existence. + for { + select { + case <-waitCtx.Done(): + t.Fatalf("timed out waiting for helper readiness dir %s", readyDir) + default: + if _, err := os.Stat(readyDir); err == nil { + goto READY + } + time.Sleep(300 * time.Millisecond) + } + } + +READY: + t.Logf("The process has started, interrupting it by cancelling ctx") + cancel() + + // Wait for the command to exit and verify rc and error. + select { + case r := <-done: + if r.err == nil { + t.Fatalf("expected non-nil error after context cancel, got nil (rc=%d)", r.rc) + } + if r.rc != 32 { + t.Fatalf("expected rc=32 from SIGINT trap, got %d (err=%v)", r.rc, r.err) + } + // Context should be canceled + if ctx.Err() == nil { + t.Fatalf("expected ctx.Err() to be non-nil (context canceled), got nil") + } + case <-time.After(15 * time.Second): + t.Fatalf("command did not exit in time after cancellation") + } +} + +func TestXFSChecker_Check_TimedOut(t *testing.T) { + // Simulate xfs_repair -n invocation executing a helper process that does NOT trap SIGINT. + // When we cancel the context, execOSCommand sends SIGINT to the process group, the helper + // dies due to the signal, and isProcKilled(err) must evaluate to true, emitting FSCheckTimedOutEvent. + tmpDir := t.TempDir() + readyDir := filepath.Join(tmpDir, "ready") + + restore := setMockExec(func(ctx context.Context, name string, args ...string) (int, error) { + if name == "xfs_repair" && len(args) == 2 && args[0] == "-n" { + // Run the no-trap helper to be killed by signal. + return execOSCommand(ctx, os.Args[0], "-helperProc=true", "-helperNoTrap=true", "-helperReadyDir="+readyDir) + } + return -1, errors.New("unexpected call") + }) + defer restore() + + // Create cancelable context; we'll cancel once helper signals readiness. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + type result struct { + err error + } + done := make(chan result, 1) + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + + go func() { + done <- result{err: ch.Check(ctx, false)} + }() + + // Wait deterministically for helper readiness, then cancel. + waitCtx, stopWait := context.WithTimeout(context.Background(), 15*time.Second) + defer stopWait() + for { + select { + case <-waitCtx.Done(): + t.Fatalf("timed out waiting for helper readiness dir %s", readyDir) + default: + if _, err := os.Stat(readyDir); err == nil { + cancel() + goto CANCELED_CHECK + } + time.Sleep(200 * time.Millisecond) + } + } + +CANCELED_CHECK: + + select { + case r := <-done: + if r.err == nil { + t.Fatalf("expected error after timeout, got nil") + } + case <-time.After(15 * time.Second): + t.Fatalf("xfs check did not exit in time after cancellation") + } + + want := []string{ + StartedFSCheckEvent, + FSCheckTimedOutEvent, + } + assertEvents(t, obs.events, want) +} + +func TestXFSChecker_Repair_TimedOut(t *testing.T) { + // First phase: xfs_repair -n should report repairable (rc=1). + // Second phase: actual repair should run helper without SIGINT trap so that + // canceling the context results in a signal-terminated process, triggering + // FSRepairTimedOutEvent via isProcKilled(err). + tmpDir := t.TempDir() + readyDir := filepath.Join(tmpDir, "ready-repair") + + // We need to distinguish between the -n check and the repair call. + restore := setMockExec(func(ctx context.Context, name string, args ...string) (int, error) { + if name == "xfs_repair" { + // Check call + if len(args) >= 1 && args[0] == "-n" { + return 1, nil // repairable + } + // Repair call (no -n): run helper-no-trap to be killed by signal + if len(args) == 1 { + return execOSCommand(ctx, os.Args[0], "-helperProc=true", "-helperNoTrap=true", "-helperReadyDir="+readyDir) + } + } + return -1, errors.New("unexpected call") + }) + defer restore() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + type result struct { + err error + } + done := make(chan result, 1) + + obs := &capturingObserver{} + ch, _ := GetFSChecker("/dev/sdb1", "xfs", obs) + + go func() { + done <- result{err: ch.Check(ctx, true)} + }() + + // Wait for the repair helper readiness, then cancel. + waitCtx, stopWait := context.WithTimeout(context.Background(), 15*time.Second) + defer stopWait() + for { + select { + case <-waitCtx.Done(): + t.Fatalf("timed out waiting for helper readiness dir %s", readyDir) + default: + if _, err := os.Stat(readyDir); err == nil { + cancel() + goto CANCELED_REPAIR + } + time.Sleep(200 * time.Millisecond) + } + } + +CANCELED_REPAIR: + + select { + case r := <-done: + if r.err == nil { + t.Fatalf("expected error after repair timeout, got nil") + } + case <-time.After(10 * time.Second): + t.Fatalf("xfs repair did not exit in time after cancellation") + } + + want := []string{ + StartedFSCheckEvent, + FoundErrorsEvent, + StartFSRepairEvent, + FSRepairTimedOutEvent, + } + assertEvents(t, obs.events, want) +} + +// Helper to set FSCheckLinesToLog for a test and restore afterwards. +// Requires FSCheckLinesToLog to be a var, not const. +func withFSN(t *testing.T, n int, fn func()) { + t.Helper() + orig := FSCheckLinesToLog + FSCheckLinesToLog = n + defer func() { FSCheckLinesToLog = orig }() + fn() +} + +func TestCollectStderr_EmptyBuffer(t *testing.T) { + withFSN(t, 3, func() { + var errBuf bytes.Buffer // empty + + got := truncOutput(errBuf, "cmd") + if got == nil { + t.Fatalf("expected non-nil buffer") + } + if got.Len() != 0 { + t.Fatalf("expected empty buffer, got %q", got.String()) + } + }) +} + +func TestTruncOutput_WhitespaceOnly(t *testing.T) { + withFSN(t, 3, func() { + var errBuf bytes.Buffer + errBuf.WriteString(" \n\t\n \n") + + got := truncOutput(errBuf, "cmd", "-v") + if got.Len() != 0 { + t.Fatalf("expected empty buffer for whitespace-only input, got %q", got.String()) + } + }) +} + +func TestTruncOutput_FewerThanN(t *testing.T) { + withFSN(t, 3, func() { + var errBuf bytes.Buffer + // Leading spaces preserved, trailing spaces removed on non-empty lines. + errBuf.WriteString(" line1 \n\n\tline2\t\t\n") + + got := truncOutput(errBuf, "cmd", "a", "b") + + want := strings.Join([]string{ + "stderr from command: cmd a b", + " line1", + "\tline2", + }, "\n") + + if got.String() != want { + t.Fatalf("unexpected output\n--- got ---\n%q\n--- want ---\n%q", got.String(), want) + } + }) +} + +func TestTruncOutput_ExactlyN(t *testing.T) { + withFSN(t, 3, func() { + var errBuf bytes.Buffer + // Exactly 3 non-empty (after trim-space emptiness check). + errBuf.WriteString("one \n two\t \n\tthree\n") + + got := truncOutput(errBuf, "tool") + + want := strings.Join([]string{ + "stderr from command: tool", + "one", // trailing space trimmed + " two", // leading spaces preserved, trailing trimmed + "\tthree", + }, "\n") + + if got.String() != want { + t.Fatalf("unexpected output\n--- got ---\n%q\n--- want ---\n%q", got.String(), want) + } + }) +} + +func TestTruncOutput_MoreThanN(t *testing.T) { + withFSN(t, 3, func() { + var errBuf bytes.Buffer + // Non-empty trimmed lines (show leading variations): a, " b", "\tc", "d", " e " + // Also include blanks to ensure they are skipped. + errBuf.WriteString(" \n a \n b\t \n\tc\t\t\n\n d\n e \n") + + got := truncOutput(errBuf, "tool", "--flag") + + // Expect ellipsis and last 3 non-empty: "\tc" (leading tab, trailing trimmed), + // "d" (no change), " e" (leading preserved, trailing trimmed). + want := strings.Join([]string{ + "stderr from command: tool --flag", + "...", + "\tc", + " d", + " e", + }, "\n") + + if got.String() != want { + t.Fatalf("unexpected output for >N with ellipsis\n--- got ---\n%q\n--- want ---\n%q", got.String(), want) + } + }) +} + +// Test EXT checker with real filesystem images. +// The images are sample images found in the e2fsprogs test suite . + +func TestEXTChecker_WithRealImages(t *testing.T) { + // Check if e2fsck and gunzip are available in PATH + if _, err := exec.LookPath("e2fsck"); err != nil { + t.Skip("e2fsck binary not found in PATH, skipping test") + } + if _, err := exec.LookPath("gunzip"); err != nil { + t.Skip("gunzip binary not found in PATH, skipping test") + } + if !canSetupLoopback() { + t.Skip("insufficient permissions to setup loopback devices, skipping test") + } + + // Find all img_*.gz files in tests/fsckdata + fsckdataDir := filepath.Join("tests", "fsckdata") + entries, err := os.ReadDir(fsckdataDir) + if err != nil { + t.Fatalf("failed to read fsckdata directory: %v", err) + } + + // Process each archived image + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".gz") { + continue + } + + t.Run(entry.Name(), func(t *testing.T) { + testEXTCheckerWithImage(t, fsckdataDir, entry.Name()) + }) + } +} + +func testEXTCheckerWithImage(t *testing.T, fsckdataDir, gzFile string) { + t.Helper() + + // Extract expected exit code from filename + // Pattern: img_[number]_.... + var expectedCode int + n, err := fmt.Sscanf(gzFile, "img_%d_", &expectedCode) + if err != nil || n != 1 { + t.Fatalf("invalid filename format: %s", gzFile) + } + + // Create temporary directory for this test + tempDir := t.TempDir() + imagePath := filepath.Join(fsckdataDir, gzFile) + unpackedPath := filepath.Join(tempDir, "image") + + // Unpack the gz file + t.Logf("Unpacking %s to %s", imagePath, unpackedPath) + if err := unpackGz(imagePath, unpackedPath); err != nil { + t.Fatalf("failed to unpack %s: %v", gzFile, err) + } + + // Setup loopback device + loopDev, err := setupLoopback(unpackedPath) + if err != nil { + t.Fatalf("failed to setup loopback device: %v", err) + } + defer func() { + if err := detachLoopback(loopDev); err != nil { + t.Logf("warning: failed to detach loopback %s: %v", loopDev, err) + } + }() + + // Create ext checker and run in check-and-repair mode + obs := &capturingObserver{} + checker, err := GetFSChecker(loopDev, "ext", obs) + if err != nil { + t.Fatalf("failed to get fs checker: %v", err) + } + + // Run check with repair enabled + err = checker.Check(context.Background(), true) + + // Verify expectations based on expected code + if expectedCode == 0 { + // Should succeed + if err != nil { + t.Fatalf("expected check to succeed for %s, got error: %v", gzFile, err) + } + t.Logf("✓ %s: check succeeded as expected", gzFile) + } else { + // Should fail with error containing the exit code + if err == nil { + t.Fatalf("expected check to fail for %s, but it succeeded", gzFile) + } + + // Check if error message contains the expected exit code + expectedErrPattern := fmt.Sprintf("\\(%d\\)", expectedCode) + matched, regexErr := regexp.MatchString(expectedErrPattern, err.Error()) + if regexErr != nil { + t.Fatalf("failed to match error pattern: %v", regexErr) + } + if !matched { + t.Fatalf("error message should contain (%d), got: %s", expectedCode, err.Error()) + } + t.Logf("✓ %s: check failed with expected error code %d", gzFile, expectedCode) + } +} + +// unpackGz unpacks a gz file to the target path +func unpackGz(gzPath, targetPath string) error { + gzFile, err := os.Open(gzPath) + if err != nil { + return fmt.Errorf("failed to open gz file: %w", err) + } + defer gzFile.Close() + + targetFile, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("failed to create target file: %w", err) + } + defer targetFile.Close() + + // Use gunzip command to unpack + cmd := exec.Command("gunzip", "-c", gzPath) + cmd.Stdout = targetFile + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to unpack gz file: %w", err) + } + + return nil +} + +// canSetupLoopback checks if user has permissions to setup loopback devices +func canSetupLoopback() bool { + // Try to find a free loop device + cmd := exec.Command("losetup", "-f") + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + + // Check if we got a valid loop device path + loopDevice := strings.TrimSpace(string(output)) + if !strings.HasPrefix(loopDevice, "/dev/loop") { + return false + } + + // Test if we can actually access the device + if _, err := os.Stat(loopDevice); err != nil { + return false + } + + return true +} + +// setupLoopback creates a loopback device for the given image file +func setupLoopback(imagePath string) (string, error) { + // Use losetup to create loopback device + cmd := exec.Command("losetup", "-f", "--show", imagePath) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to setup loopback: %w", err) + } + + loopDev := strings.TrimSpace(string(output)) + if loopDev == "" { + return "", errors.New("empty loopback device path") + } + + return loopDev, nil +} + +// detachLoopback detaches a loopback device +func detachLoopback(loopDev string) error { + cmd := exec.Command("losetup", "-d", loopDev) + return cmd.Run() +} diff --git a/gofsutil_mock.go b/gofsutil_mock.go index 4766506..2dac90d 100644 --- a/gofsutil_mock.go +++ b/gofsutil_mock.go @@ -45,6 +45,13 @@ var ( // GONVMEValidDevices mocks existing devices GONVMEValidDevices map[string]bool + // GOFSMockFstrimResult is returned by Fstrim in mock mode when set. + GOFSMockFstrimResult *FstrimResult + // GOFSMockBlkdiscardResult is returned by Blkdiscard in mock mode when set. + GOFSMockBlkdiscardResult *BlkdiscardResult + // GOFSMockDiscardCapability is returned by CheckDiscardSupport in mock mode when set. + GOFSMockDiscardCapability *DiscardCapability + // GOFSMock allows you to induce errors in the various routine. GOFSMock struct { InduceBindMountError bool @@ -71,6 +78,9 @@ var ( InduceGetMpathNameFromDeviceError bool InduceFilesystemInfoError bool InduceGetNVMeControllerError bool + InduceFstrimError bool + InduceBlkdiscardError bool + InduceCheckDiscardSupportError bool } ) @@ -569,3 +579,60 @@ func (fs *mockfs) getNVMeController(device string) (string, error) { } return "", fmt.Errorf("controller not found for device %s", device) } + +// ==================================================================== +// Space reclamation mock implementations + +func (fs *mockfs) fstrim(_ context.Context, _ string) (*FstrimResult, error) { + if GOFSMock.InduceFstrimError { + return nil, errors.New("fstrim induced error") + } + if GOFSMockFstrimResult != nil { + return GOFSMockFstrimResult, nil + } + return &FstrimResult{ + BytesTrimmed: 1073741824, // 1 GiB + Duration: 500 * time.Millisecond, + }, nil +} + +// Fstrim delegates to fs.fstrim following the gofsutil mock pattern. +func (fs *mockfs) Fstrim(ctx context.Context, mountPoint string) (*FstrimResult, error) { + return fs.fstrim(ctx, mountPoint) +} + +func (fs *mockfs) blkdiscard(_ context.Context, _ string) (*BlkdiscardResult, error) { + if GOFSMock.InduceBlkdiscardError { + return nil, errors.New("blkdiscard induced error") + } + if GOFSMockBlkdiscardResult != nil { + return GOFSMockBlkdiscardResult, nil + } + return &BlkdiscardResult{ + BytesDiscarded: 107374182400, // 100 GiB + Duration: 2 * time.Second, + }, nil +} + +// Blkdiscard delegates to fs.blkdiscard following the gofsutil mock pattern. +func (fs *mockfs) Blkdiscard(ctx context.Context, devicePath string) (*BlkdiscardResult, error) { + return fs.blkdiscard(ctx, devicePath) +} + +func (fs *mockfs) checkDiscardSupport(_ context.Context, _ string) (*DiscardCapability, error) { + if GOFSMock.InduceCheckDiscardSupportError { + return nil, errors.New("checkDiscardSupport induced error") + } + if GOFSMockDiscardCapability != nil { + return GOFSMockDiscardCapability, nil + } + return &DiscardCapability{ + Supported: true, + DiscardMaxBytes: 4294967295, + }, nil +} + +// CheckDiscardSupport delegates to fs.checkDiscardSupport following the gofsutil mock pattern. +func (fs *mockfs) CheckDiscardSupport(ctx context.Context, devicePath string) (*DiscardCapability, error) { + return fs.checkDiscardSupport(ctx, devicePath) +} diff --git a/gofsutil_mount_linux.go b/gofsutil_mount_linux.go index 41446e2..f42e2c5 100644 --- a/gofsutil_mount_linux.go +++ b/gofsutil_mount_linux.go @@ -43,17 +43,57 @@ var ( ) // getDiskFormat uses 'lsblk' to see if the given disk is unformatted +// For SDC devices (/dev/scini*), it uses 'blkid' instead as lsblk doesn't work with character devices func (fs *FS) getDiskFormat(_ context.Context, disk string) (string, error) { path := filepath.Clean(disk) if err := validatePath(path); err != nil { return "", err } - args := []string{"-n", "-o", "FSTYPE", disk} - f := log.Fields{ "disk": disk, } + + // Check if this is an SDC device (character device /dev/scini*) + // SDC devices are character devices, not block devices, so lsblk doesn't work + if strings.HasPrefix(disk, "/dev/scini") { + log.WithFields(f).Info("checking if SDC disk is formatted using blkid") + buf, err := getExecCommandCombinedOutput("blkid", "-o", "value", "-s", "TYPE", disk) + out := strings.TrimSpace(string(buf)) + log.WithField("output", out).Debug("blkid output") + + if err != nil { + // blkid returns exit code 2 when no filesystem is found (unformatted) + // This is expected for unformatted devices + var exitCode int + if exitError, ok := err.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + } + + if exitCode == 2 || out == "" { + // Exit code 2 or empty output means unformatted device + log.WithFields(f).WithField("exitCode", exitCode).Debug("blkid indicates unformatted SDC device") + return "", nil + } + + // Other errors are actual failures + log.WithFields(f).WithField("exitCode", exitCode).WithError(err).Error("blkid failed for SDC device") + return "", err + } + + if out != "" { + // The device is formatted + log.WithFields(f).WithField("fstype", out).Info("SDC device has filesystem") + return out, nil + } + + // Empty output means unformatted + return "", nil + } + + // For non-SDC devices, use lsblk as before + args := []string{"-n", "-o", "FSTYPE", disk} + log.WithFields(f).WithField("args", args).Info( "checking if disk is formatted using lsblk") buf, err := getExecCommandCombinedOutput("lsblk", args...) @@ -76,7 +116,25 @@ func (fs *FS) getDiskFormat(_ context.Context, disk string) (string, error) { } if len(lines) == 1 { - // The device is unformatted and has no dependent devices + // lsblk returned empty for a single device. This could mean the device + // is truly unformatted, OR lsblk could not read the FS type (e.g. + // device-mapper symlinks inside containers where sysfs is incomplete). + // Fall back to blkid which reads the superblock directly. + blkidArgs := []string{"-o", "value", "-s", "TYPE", disk} + log.WithFields(f).WithField("args", blkidArgs).Info( + "lsblk returned empty; probing with blkid as fallback") + blkBuf, blkErr := getExecCommandCombinedOutput("blkid", blkidArgs...) + blkOut := strings.TrimSpace(string(blkBuf)) + log.WithField("output", blkOut).Debug("blkid fallback output") + + if blkErr == nil && blkOut != "" { + // blkid found a filesystem that lsblk missed + log.WithFields(f).WithField("fstype", blkOut).Info( + "blkid fallback detected filesystem") + return blkOut, nil + } + + // blkid also returned empty or errored — device is truly unformatted return "", nil } @@ -295,8 +353,8 @@ func (fs *FS) bindMount( // isLsblkNew returns true if lsblk version is greater than 2.3 and false otherwise func (fs *FS) isLsblkNew() (bool, error) { lsblkNew := false - checkVersCmd := "lsblk -V" - bufcheck, errcheck := exec.Command("bash", "-c", checkVersCmd).Output() + // Use exec.Command with argv-style arguments instead of bash -c to avoid shell injection + bufcheck, errcheck := exec.Command("lsblk", "-V").Output() if errcheck != nil { return lsblkNew, errcheck } @@ -326,24 +384,49 @@ func (fs *FS) getMpathNameFromDevice( return "", err } - var cmd string + // Validate device to prevent OS command injection (CWE-78) + if err := validateDeviceID(device); err != nil { + return "", fmt.Errorf("invalid device for getMpathNameFromDevice: %w", err) + } + + var args []string lsblkNew, err := fs.isLsblkNew() if err != nil { return "", err } if lsblkNew { - cmd = "lsblk -Px MODE | awk '/" + device + "/{c=2}c&&c--' | grep TYPE=\\\"mpath\\\"" + args = []string{"-Px", "MODE"} } else { - cmd = "lsblk -P | awk '/" + device + "/{c=2}c&&c--' | grep TYPE=\\\"mpath\\\"" + args = []string{"-P"} } - fmt.Println(cmd) - buf, _ := exec.Command("bash", "-c", cmd).Output() // #nosec G204 + // Use exec.Command with argv-style arguments instead of bash -c + buf, err := exec.Command("lsblk", args...).Output() + if err != nil { + return "", nil // lsblk may fail if device doesn't exist, return empty + } output := string(buf) - mpathDeviceRegx := regexp.MustCompile(`NAME="\S+"`) - mpath := mpathDeviceRegx.FindString(output) - if mpath != "" { - return strings.Split(mpath, "\"")[1], nil + + // Filter output in Go instead of using awk/grep in shell + mpathDeviceRegx := regexp.MustCompile(`NAME="(\S+)"`) + lines := strings.Split(output, "\n") + foundDevice := false + linesToCheck := 0 + + for _, line := range lines { + if strings.Contains(line, device) { + foundDevice = true + linesToCheck = 2 + } + if foundDevice && linesToCheck > 0 { + if strings.Contains(line, `TYPE="mpath"`) { + match := mpathDeviceRegx.FindStringSubmatch(line) + if len(match) > 1 { + return match[1], nil + } + } + linesToCheck-- + } } return "", nil @@ -403,56 +486,101 @@ func (fs *FS) getMountInfoFromDevice( return nil, err } - var cmd string + // Validate devID to prevent OS command injection (CWE-78) + // This is critical as devID is used in pattern matching against lsblk output + if err := validateDeviceID(devID); err != nil { + return nil, fmt.Errorf("invalid device ID for getMountInfoFromDevice: %w", err) + } + var output string lsblkNew, err := fs.isLsblkNew() if err != nil { return nil, err } - //check if devID has powerpath devices - /* #nosec G204 */ - checkCmd := "lsblk --pairs --output NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT | awk '/emcpower.+" + devID + "/ {print $0}'" - log.Debugf("ppath checkcommand values is %s", checkCmd) - /* #nosec G204 */ - buf, err := exec.Command("bash", "-c", checkCmd).Output() + // check if devID has powerpath devices + // Use exec.Command with argv-style arguments instead of bash -c to avoid shell injection + // Get lsblk output and filter in Go instead of using awk + buf, err := exec.Command("lsblk", "--pairs", "--output", "NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT").Output() if err != nil { return nil, err } - output = string(buf) + lsblkOutput := string(buf) + lines := strings.Split(lsblkOutput, "\n") + + // Filter for powerpath devices in Go instead of awk - use regexp.QuoteMeta for safe pattern matching + ppathRegex := regexp.MustCompile(`emcpower.+` + regexp.QuoteMeta(devID)) + var ppathMatches []string + for _, line := range lines { + if ppathRegex.MatchString(line) { + ppathMatches = append(ppathMatches, line) + } + } + output = strings.Join(ppathMatches, "\n") if output == "" { // output is nil, powerpath device not found, continuing for multipath or single device log.Info("powerpath command output is nil, continuing for multipath or single device") - /* #nosec G204 */ - checkCmd = "lsblk --pairs --output NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT | awk '/mpath.+" + devID + "/ {print $0}'" - log.Debugf("mpath checkcommand values is %s", checkCmd) - /* #nosec G204 */ - buf, err = exec.Command("bash", "-c", checkCmd).Output() - if err != nil { - return nil, err + // Filter for multipath devices in Go instead of awk + mpathRegex := regexp.MustCompile(`mpath.+` + regexp.QuoteMeta(devID)) + var mpathMatches []string + for _, line := range lines { + if mpathRegex.MatchString(line) { + mpathMatches = append(mpathMatches, line) + } } - output = string(buf) + output = strings.Join(mpathMatches, "\n") log.Debugf("multipath exec command output is : %+v", output) + var lsblkArgs []string if output != "" { + log.Info("Multipath device found") if lsblkNew { - cmd = "lsblk --pairs --sort MODE --output NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT | awk '/" + devID + "/{if (a && a !~ /" + devID + "/) print a; print} {a=$0}'" + lsblkArgs = []string{"--pairs", "--sort", "MODE", "--output", "NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT"} } else { - cmd = "lsblk --pairs --output NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT | awk '/" + devID + "/{if (a && a !~ /" + devID + "/) print a; print} {a=$0}'" + lsblkArgs = []string{"--pairs", "--output", "NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT"} } } else { + log.Info("Multipath device not found") // multipath device not found, continue as single device - /* #nosec G204 */ - cmd = "lsblk --pairs --output NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT | awk '/" + devID + "/ {print $0}'" + lsblkArgs = []string{"--pairs", "--output", "NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT"} } - log.Debugf("command value is %s", cmd) - /* #nosec G204 */ - buf, err = exec.Command("bash", "-c", cmd).Output() + + // Execute lsblk with argv-style arguments instead of bash -c + buf, err = exec.Command("lsblk", lsblkArgs...).Output() if err != nil { return nil, err } - output = string(buf) + lsblkOutput = string(buf) + lines = strings.Split(lsblkOutput, "\n") + + // Filter output in Go instead of awk - use regexp.QuoteMeta for safe pattern matching + devRegex := regexp.MustCompile(regexp.QuoteMeta(devID)) + if output != "" { + // Multipath device found - replicate awk: '/{devID}/{if (a && a !~ /{devID}/) print a; print} {a=$0}' + var matchedLines []string + var prevLine string + for _, line := range lines { + if devRegex.MatchString(line) { + if prevLine != "" && !devRegex.MatchString(prevLine) { + matchedLines = append(matchedLines, prevLine) + } + matchedLines = append(matchedLines, line) + } + prevLine = line + } + output = strings.Join(matchedLines, "\n") + } else { + // Single device - replicate awk: '/{devID}/ {print $0}' + var matchedLines []string + for _, line := range lines { + if devRegex.MatchString(line) { + matchedLines = append(matchedLines, line) + } + } + output = strings.Join(matchedLines, "\n") + } log.Debugf("command output is : %+v", output) } + if output == "" { return nil, fmt.Errorf("Device not found") } @@ -512,13 +640,12 @@ func (fs *FS) findFSType( return "", fmt.Errorf("Failed to validate path: %s error %v", mountpoint, err) } - cmd := "findmnt -n \"" + path + "\" | awk '{print $3}'" - /* #nosec G204 */ - buf, err := exec.Command("bash", "-c", cmd).Output() + // Use exec.Command with argv-style arguments instead of bash -c to avoid shell injection + buf, err := exec.Command("findmnt", "-n", "-o", "FSTYPE", path).Output() if err != nil { return "", fmt.Errorf("Failed to find mount information for (%s) error (%v)", mountpoint, err) } - fsType = strings.TrimSuffix(string(buf), "\n") + fsType = strings.TrimSpace(string(buf)) return fsType, err } @@ -630,11 +757,10 @@ func (fs *FS) deviceRescan(_ context.Context, return err } device := path + "/device/rescan" - args := []string{"-c", "echo 1 > " + device} log.Infof("Executing rescan command on device (%s)", devicePath) - /* #nosec G204 */ - buf, err := exec.Command("bash", args...).CombinedOutput() - out := string(buf) + // Write directly to sysfs instead of executing bash -c "echo 1 > ...". + out := "1\n" + err := os.WriteFile(device, []byte(out), 0o644) log.WithField("output", out).Debug("Rescan output") if err != nil { log.Errorf("Failed to rescan device with error (%s)", err.Error()) diff --git a/gofsutil_mount_linux_test.go b/gofsutil_mount_linux_test.go index 056cead..edc7db2 100644 --- a/gofsutil_mount_linux_test.go +++ b/gofsutil_mount_linux_test.go @@ -85,6 +85,214 @@ func TestGetDiskFormatUnknownData(t *testing.T) { } } +func TestGetDiskFormatSDCWithFilesystem(t *testing.T) { + // Create a test FS + fs := &FS{} + + // Create an SDC disk path + disk := "/dev/scinia" + + // Mock the output + defaultGetExecCommandCombinedOutput := getExecCommandCombinedOutput + defer func() { + getExecCommandCombinedOutput = defaultGetExecCommandCombinedOutput + }() + + getExecCommandCombinedOutput = func(name string, args ...string) ([]byte, error) { + // Verify blkid is called with correct arguments + if name != "blkid" { + t.Errorf("expected blkid command, got %s", name) + } + expectedArgs := []string{"-o", "value", "-s", "TYPE", disk} + if len(args) != len(expectedArgs) { + t.Errorf("expected %d arguments, got %d", len(expectedArgs), len(args)) + } + for i, arg := range expectedArgs { + if args[i] != arg { + t.Errorf("expected arg[%d] = %s, got %s", i, arg, args[i]) + } + } + return []byte("xfs"), nil + } + + // Call getDiskFormat + fstype, err := fs.getDiskFormat(context.Background(), disk) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if fstype != "xfs" { + t.Errorf("expected xfs, got %s", fstype) + } +} + +func TestGetDiskFormatSDCUnformatted(t *testing.T) { + // Create a test FS + fs := &FS{} + + // Create an SDC disk path + disk := "/dev/scinia" + + // Mock the output + defaultGetExecCommandCombinedOutput := getExecCommandCombinedOutput + defer func() { + getExecCommandCombinedOutput = defaultGetExecCommandCombinedOutput + }() + + getExecCommandCombinedOutput = func(name string, _ ...string) ([]byte, error) { + // Verify blkid is called + if name != "blkid" { + t.Errorf("expected blkid command, got %s", name) + } + // Simulate blkid with empty output (unformatted device) + return []byte(""), nil + } + + // Call getDiskFormat + fstype, err := fs.getDiskFormat(context.Background(), disk) + if err != nil { + t.Errorf("expected no error for unformatted SDC device, got %v", err) + } + if fstype != "" { + t.Errorf("expected empty fstype for unformatted device, got %s", fstype) + } +} + +func TestGetDiskFormatSDCNonSCDDevice(t *testing.T) { + // Create a test FS + fs := &FS{} + + // Create a non-SDC disk path (should use lsblk) + disk := "/dev/sda1" + + // Mock the output + defaultGetExecCommandCombinedOutput := getExecCommandCombinedOutput + defer func() { + getExecCommandCombinedOutput = defaultGetExecCommandCombinedOutput + }() + + getExecCommandCombinedOutput = func(name string, _ ...string) ([]byte, error) { + // Verify lsblk is called for non-SDC devices + if name != "lsblk" { + t.Errorf("expected lsblk command for non-SDC device, got %s", name) + } + return []byte("ext4"), nil + } + + // Call getDiskFormat + fstype, err := fs.getDiskFormat(context.Background(), disk) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if fstype != "ext4" { + t.Errorf("expected ext4, got %s", fstype) + } +} + +func TestGetDiskFormatBlkidFallbackDetectsFS(t *testing.T) { + fs := &FS{} + disk := "/dev/disk/by-id/dm-uuid-mpath-test123" + + defaultGetExecCommandCombinedOutput := getExecCommandCombinedOutput + defer func() { + getExecCommandCombinedOutput = defaultGetExecCommandCombinedOutput + }() + + var lsblkCalled, blkidCalled bool + getExecCommandCombinedOutput = func(name string, args ...string) ([]byte, error) { + if name == "lsblk" { + lsblkCalled = true + return []byte("\n"), nil + } + if name == "blkid" { + blkidCalled = true + expectedArgs := []string{"-o", "value", "-s", "TYPE", disk} + if len(args) != len(expectedArgs) { + t.Errorf("expected %d blkid arguments, got %d", len(expectedArgs), len(args)) + } + for i, arg := range expectedArgs { + if i < len(args) && args[i] != arg { + t.Errorf("expected blkid arg[%d] = %s, got %s", i, arg, args[i]) + } + } + return []byte("ext4"), nil + } + t.Errorf("unexpected command: %s", name) + return nil, errors.New("unexpected command") + } + + fstype, err := fs.getDiskFormat(context.Background(), disk) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if fstype != "ext4" { + t.Errorf("expected ext4, got %s", fstype) + } + if !lsblkCalled { + t.Errorf("expected lsblk to be called first") + } + if !blkidCalled { + t.Errorf("expected blkid to be called as fallback") + } +} + +func TestGetDiskFormatBlkidFallbackUnformatted(t *testing.T) { + fs := &FS{} + disk := "/dev/disk/by-id/dm-uuid-mpath-test456" + + defaultGetExecCommandCombinedOutput := getExecCommandCombinedOutput + defer func() { + getExecCommandCombinedOutput = defaultGetExecCommandCombinedOutput + }() + + getExecCommandCombinedOutput = func(name string, _ ...string) ([]byte, error) { + if name == "lsblk" { + return []byte("\n"), nil + } + if name == "blkid" { + return []byte(""), nil + } + t.Errorf("unexpected command: %s", name) + return nil, errors.New("unexpected command") + } + + fstype, err := fs.getDiskFormat(context.Background(), disk) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if fstype != "" { + t.Errorf("expected empty fstype, got %s", fstype) + } +} + +func TestGetDiskFormatBlkidFallbackError(t *testing.T) { + fs := &FS{} + disk := "/dev/disk/by-id/dm-uuid-mpath-test789" + + defaultGetExecCommandCombinedOutput := getExecCommandCombinedOutput + defer func() { + getExecCommandCombinedOutput = defaultGetExecCommandCombinedOutput + }() + + getExecCommandCombinedOutput = func(name string, _ ...string) ([]byte, error) { + if name == "lsblk" { + return []byte("\n"), nil + } + if name == "blkid" { + return []byte(""), errors.New("exit status 2") + } + t.Errorf("unexpected command: %s", name) + return nil, errors.New("unexpected command") + } + + fstype, err := fs.getDiskFormat(context.Background(), disk) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if fstype != "" { + t.Errorf("expected empty fstype, got %s", fstype) + } +} + func Test_formatAndMount(t *testing.T) { fs := &MockFS{} ctx := context.WithValue(context.Background(), ContextKey("RequestID"), "test-req-id") diff --git a/gofsutil_reclaim.go b/gofsutil_reclaim.go new file mode 100644 index 0000000..11432df --- /dev/null +++ b/gofsutil_reclaim.go @@ -0,0 +1,78 @@ +// Copyright © 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gofsutil + +import ( + "context" + "time" +) + +// FstrimResult holds the result of an fstrim operation on a mounted filesystem. +type FstrimResult struct { + // BytesTrimmed is the number of bytes reported as trimmed by fstrim -v. + BytesTrimmed int64 + // Duration is the wall-clock time the fstrim command took. + Duration time.Duration +} + +// BlkdiscardResult holds the result of a blkdiscard operation on a block device. +type BlkdiscardResult struct { + // BytesDiscarded is the total size of the device (all bytes are discarded). + BytesDiscarded int64 + // Duration is the wall-clock time the blkdiscard command took. + Duration time.Duration +} + +// DiscardCapability describes whether a block device supports discard operations +// (SCSI UNMAP / NVMe Deallocate). +type DiscardCapability struct { + // Supported is true when the device reports discard_max_bytes > 0. + Supported bool + // DiscardMaxBytes is the value read from /sys/block//queue/discard_max_bytes. + DiscardMaxBytes int64 + // Reason is a human-readable explanation when Supported is false. + Reason string +} + +// Fstrim runs fstrim on the specified mount point, returning the bytes trimmed. +// The context deadline/timeout controls the maximum execution time. +func Fstrim(ctx context.Context, mountPoint string) (*FstrimResult, error) { + return fs.Fstrim(ctx, mountPoint) +} + +// Blkdiscard runs blkdiscard on the specified block device path, returning the +// bytes discarded. The context deadline/timeout controls the maximum execution time. +func Blkdiscard(ctx context.Context, devicePath string) (*BlkdiscardResult, error) { + return fs.Blkdiscard(ctx, devicePath) +} + +// CheckDiscardSupport checks whether a block device supports discard operations +// (SCSI UNMAP / NVMe Deallocate) by reading sysfs attributes. +func CheckDiscardSupport(ctx context.Context, devicePath string) (*DiscardCapability, error) { + return fs.CheckDiscardSupport(ctx, devicePath) +} + +// Fstrim runs fstrim on the specified mount point, returning the bytes trimmed. +func (f *FS) Fstrim(ctx context.Context, mountPoint string) (*FstrimResult, error) { + return f.fstrim(ctx, mountPoint) +} + +// Blkdiscard runs blkdiscard on the specified block device, returning the bytes discarded. +func (f *FS) Blkdiscard(ctx context.Context, devicePath string) (*BlkdiscardResult, error) { + return f.blkdiscard(ctx, devicePath) +} + +// CheckDiscardSupport checks whether a block device supports discard (UNMAP/Deallocate). +func (f *FS) CheckDiscardSupport(ctx context.Context, devicePath string) (*DiscardCapability, error) { + return f.checkDiscardSupport(ctx, devicePath) +} diff --git a/gofsutil_reclaim_linux.go b/gofsutil_reclaim_linux.go new file mode 100644 index 0000000..9c16bd7 --- /dev/null +++ b/gofsutil_reclaim_linux.go @@ -0,0 +1,186 @@ +// Copyright © 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gofsutil + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +// reclaimExecFn is the mockable function variable used to execute system commands +// for reclaim operations (fstrim, blkdiscard). It can be overridden in tests. +var reclaimExecFn = defaultReclaimExec + +func defaultReclaimExec(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 + // Start the child process in a new process group + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.WaitDelay = 2 * time.Second + cmd.Cancel = func() error { + return syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) + } + return cmd.CombinedOutput() +} + +// fstrim runs the fstrim command on the specified mount point. +func (f *FS) fstrim(ctx context.Context, mountPoint string) (*FstrimResult, error) { + start := time.Now() + output, err := reclaimExecFn(ctx, "fstrim", "-v", mountPoint) + duration := time.Since(start) + + if err != nil { + if strings.Contains(err.Error(), "killed") { + return nil, fmt.Errorf("fstrim timed out on %s: %w", mountPoint, err) + } + return nil, fmt.Errorf("fstrim failed on %s: %w", mountPoint, err) + } + + bytesTrimmed := parseFstrimBytes(string(output)) + return &FstrimResult{ + BytesTrimmed: bytesTrimmed, + Duration: duration, + }, nil +} + +// blkdiscard runs the blkdiscard command on the specified block device. +func (f *FS) blkdiscard(ctx context.Context, devicePath string) (*BlkdiscardResult, error) { + deviceSize, err := getBlockDeviceSize(devicePath) + if err != nil { + return nil, fmt.Errorf("failed to get device size for %s: %w", devicePath, err) + } + + start := time.Now() + _, err = reclaimExecFn(ctx, "blkdiscard", devicePath) + duration := time.Since(start) + + if err != nil { + if strings.Contains(err.Error(), "killed") { + return nil, fmt.Errorf("blkdiscard timed out on %s: %w", devicePath, err) + } + return nil, fmt.Errorf("blkdiscard failed on %s: %w", devicePath, err) + } + + return &BlkdiscardResult{ + BytesDiscarded: deviceSize, + Duration: duration, + }, nil +} + +// checkDiscardSupport checks sysfs to determine discard capability. +func (f *FS) checkDiscardSupport(_ context.Context, devicePath string) (*DiscardCapability, error) { + // Resolve symlinks to get the real device name + resolved, err := filepath.EvalSymlinks(devicePath) + if err != nil { + resolved = devicePath + } + devName := filepath.Base(resolved) + + // Read discard_max_bytes from sysfs + discardMaxPath := filepath.Join(sysBlockDir, devName, "queue", "discard_max_bytes") + data, err := os.ReadFile(discardMaxPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", discardMaxPath, err) + } + + maxBytesStr := strings.TrimSpace(string(data)) + maxBytes, err := strconv.ParseInt(maxBytesStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse discard_max_bytes from %s: %w", discardMaxPath, err) + } + + // If discard_max_bytes is 0, device does not support discard + if maxBytes == 0 { + return &DiscardCapability{ + Supported: false, + DiscardMaxBytes: 0, + Reason: "discard_max_bytes=0", + }, nil + } + + // Check for dm devices device by reading dm/uuid + if strings.HasPrefix(devName, "dm-") { + dmUUIDPath := filepath.Join(sysBlockDir, devName, "dm", "uuid") + dmUUID, err := os.ReadFile(dmUUIDPath) + if err == nil { + uuid := strings.TrimSpace(string(dmUUID)) + if strings.HasPrefix(strings.ToUpper(uuid), "CRYPT-") { + // dm-crypt device: check discard_granularity for allow-discards + granularityPath := filepath.Join(sysBlockDir, devName, "queue", "discard_granularity") + granData, gErr := os.ReadFile(granularityPath) + if gErr != nil || strings.TrimSpace(string(granData)) == "0" { + return &DiscardCapability{ + Supported: false, + DiscardMaxBytes: maxBytes, + Reason: "dm-crypt device without allow-discards", + }, nil + } + } + } + } + + return &DiscardCapability{ + Supported: true, + DiscardMaxBytes: maxBytes, + }, nil +} + +// fstrimBytesRegex matches the byte count in fstrim -v output. +var fstrimBytesRegex = regexp.MustCompile(`(\d+)\s+bytes?`) + +// parseFstrimBytes extracts the bytes value from fstrim -v output. +// Example output: "/mnt/data: 42949672960 bytes were trimmed" +func parseFstrimBytes(output string) int64 { + matches := fstrimBytesRegex.FindStringSubmatch(output) + if len(matches) < 2 { + return 0 + } + val, err := strconv.ParseInt(matches[1], 10, 64) + if err != nil { + return 0 + } + return val +} + +// getBlockDeviceSize returns the size in bytes of a block device by reading +// /sys/block//size (which reports the size in 512-byte sectors). +// Symlinks are resolved via filepath.EvalSymlinks to handle device mapper paths. +func getBlockDeviceSize(devicePath string) (int64, error) { + // Resolve symlinks to get the real device name + resolved, err := filepath.EvalSymlinks(devicePath) + if err != nil { + resolved = devicePath + } + devName := filepath.Base(resolved) + + sizePath := filepath.Join(sysBlockDir, devName, "size") + data, err := os.ReadFile(sizePath) + if err != nil { + return 0, fmt.Errorf("failed to read %s: %w", sizePath, err) + } + + sizeStr := strings.TrimSpace(string(data)) + sectors, err := strconv.ParseInt(sizeStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse size from %s: %w", sizePath, err) + } + + return sectors * 512, nil +} diff --git a/gofsutil_reclaim_linux_test.go b/gofsutil_reclaim_linux_test.go new file mode 100644 index 0000000..eeb8d53 --- /dev/null +++ b/gofsutil_reclaim_linux_test.go @@ -0,0 +1,548 @@ +// Copyright © 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gofsutil + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---- Linux Test Helpers ----------------------------------------------------- + +// setupMockSysfs creates a temp directory mimicking /sys/block// with +// the specified files and their content. Returns the temp base path. +// The caller should override sysBlockDir to point to this path. +func setupMockSysfs(t *testing.T, devName string, files map[string]string) string { + t.Helper() + tmpDir := t.TempDir() + devDir := filepath.Join(tmpDir, devName) + for relPath, content := range files { + fullPath := filepath.Join(devDir, relPath) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + require.NoError(t, err, "failed to create sysfs mock directory") + err = os.WriteFile(fullPath, []byte(content), 0o644) + require.NoError(t, err, "failed to write sysfs mock file") + } + return tmpDir +} + +// setupReclaimMockExec overrides reclaimExecFn for testing and returns a +// cleanup function that restores the original. Follows the setMockExec +// pattern from gofsutil_fsck_test.go. +func setupReclaimMockExec(handler func(ctx context.Context, name string, args ...string) ([]byte, error)) func() { + orig := reclaimExecFn + reclaimExecFn = handler + return func() { reclaimExecFn = orig } +} + +// overrideSysBlockDir temporarily overrides the sysBlockDir package variable +// for tests that need mock sysfs access. Returns a cleanup function. +func overrideSysBlockDir(t *testing.T, newDir string) { + t.Helper() + orig := sysBlockDir + sysBlockDir = newDir + t.Cleanup(func() { sysBlockDir = orig }) +} + +// ---- Unit Tests: parseFstrimBytes (U-001 to U-008) ------------------------- + +// Test ID: U-001 +// Test ID: U-002 +// Test ID: U-003 +// Test ID: U-004 +// Test ID: U-005 +// Test ID: U-006 +// Test ID: U-007 +// Test ID: U-008 +func TestParseFstrimBytes(t *testing.T) { + tests := []struct { + testID string + name string + input string + expected int64 + }{ + { + testID: "U-001", + name: "standard_output", + input: "/mnt/data: 42949672960 bytes were trimmed", + expected: 42949672960, + }, + { + testID: "U-002", + name: "zero_bytes_trimmed", + input: "/mnt/data: 0 bytes were trimmed", + expected: 0, + }, + { + testID: "U-003", + name: "singular_byte", + input: "/mnt: 1 byte trimmed", + expected: 1, + }, + { + testID: "U-004", + name: "empty_string", + input: "", + expected: 0, + }, + { + testID: "U-005", + name: "no_match", + input: "fstrim: some error occurred", + expected: 0, + }, + { + testID: "U-006", + name: "large_value", + input: "/mnt: 9223372036854775807 bytes were trimmed", + expected: 9223372036854775807, // math.MaxInt64 + }, + { + testID: "U-007", + name: "bytes_without_were", + input: "/mnt: 1024 bytes trimmed", + expected: 1024, + }, + { + testID: "U-008", + name: "multiline_output", + input: "\n/mnt: 512 bytes were trimmed\n", + expected: 512, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseFstrimBytes(tt.input) + assert.Equal(t, tt.expected, result, + "[%s] parseFstrimBytes(%q) should return %d", tt.testID, tt.input, tt.expected) + }) + } +} + +// ---- Unit Tests: getBlockDeviceSize (U-009 to U-013) ------------------------ + +// Test ID: U-009 +func TestGetBlockDeviceSize_ValidDevice(t *testing.T) { + // Create temp sysfs with size file: 2097152 sectors = 1 GiB + mockDir := setupMockSysfs(t, "sda", map[string]string{ + "size": "2097152\n", + }) + overrideSysBlockDir(t, mockDir) + + size, err := getBlockDeviceSize("/dev/sda") + + require.NoError(t, err, "getBlockDeviceSize should not return error for valid device") + assert.Equal(t, int64(1073741824), size, + "Expected 2097152 sectors * 512 = 1073741824 bytes") +} + +// Test ID: U-010 +func TestGetBlockDeviceSize_MissingSysfsFile(t *testing.T) { + // Point to a non-existent directory + overrideSysBlockDir(t, "/tmp/nonexistent-sysfs-path-for-test") + + _, err := getBlockDeviceSize("/dev/nonexistent") + + require.Error(t, err, "getBlockDeviceSize should return error for missing sysfs file") +} + +// Test ID: U-011 +func TestGetBlockDeviceSize_InvalidContent(t *testing.T) { + mockDir := setupMockSysfs(t, "sdb", map[string]string{ + "size": "not_a_number\n", + }) + overrideSysBlockDir(t, mockDir) + + _, err := getBlockDeviceSize("/dev/sdb") + + require.Error(t, err, "getBlockDeviceSize should return error for non-numeric content") + assert.Contains(t, err.Error(), "failed to parse size", + "Error should mention 'failed to parse size'") +} + +// Test ID: U-012 +func TestGetBlockDeviceSize_ZeroSectors(t *testing.T) { + mockDir := setupMockSysfs(t, "sdc", map[string]string{ + "size": "0\n", + }) + overrideSysBlockDir(t, mockDir) + + size, err := getBlockDeviceSize("/dev/sdc") + + require.NoError(t, err, "getBlockDeviceSize should not return error for zero sectors") + assert.Equal(t, int64(0), size, + "Expected 0 sectors * 512 = 0 bytes") +} + +// Test ID: U-013 +func TestGetBlockDeviceSize_MapperDeviceSymlink(t *testing.T) { + // Create temp sysfs with dm-0/size and a symlink from mapper/vol -> dm-0 + tmpDir := t.TempDir() + + // Create the dm-0 device in sysfs + dm0Dir := filepath.Join(tmpDir, "dm-0") + require.NoError(t, os.MkdirAll(dm0Dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dm0Dir, "size"), []byte("4194304\n"), 0o644)) + + overrideSysBlockDir(t, tmpDir) + + // Create a symlink: /tmp/.../mapper/vol -> /tmp/.../devdir/dm-0 + mapperDir := filepath.Join(tmpDir, "mapper") + require.NoError(t, os.MkdirAll(mapperDir, 0o755)) + devDir := filepath.Join(tmpDir, "devdir") + require.NoError(t, os.MkdirAll(devDir, 0o755)) + // Create the actual dm-0 device file (symlink target) + dmTarget := filepath.Join(devDir, "dm-0") + require.NoError(t, os.WriteFile(dmTarget, []byte{}, 0o644)) + // Create symlink + volLink := filepath.Join(mapperDir, "vol") + require.NoError(t, os.Symlink(dmTarget, volLink)) + + // Call with mapper path - stub won't resolve but test verifies compilation + size, err := getBlockDeviceSize(volLink) + + // The stub returns 0, nil. In the real implementation, it would resolve the + // symlink and read dm-0/size (4194304 sectors * 512 = 2147483648 bytes). + require.NoError(t, err) + assert.Equal(t, int64(2147483648), size, + "Expected 4194304 sectors * 512 = 2147483648 bytes for mapper device") +} + +// ---- Unit Tests: checkDiscardSupport (U-014 to U-021) ----------------------- + +// Test ID: U-014 +func TestCheckDiscardSupport_SupportedDevice(t *testing.T) { + mockDir := setupMockSysfs(t, "sda", map[string]string{ + "queue/discard_max_bytes": "4294967295\n", + }) + overrideSysBlockDir(t, mockDir) + + fsObj := &FS{} + result, err := fsObj.checkDiscardSupport(context.Background(), "/dev/sda") + + require.NoError(t, err, "checkDiscardSupport should not error for supported device") + require.NotNil(t, result, "Expected non-nil DiscardCapability") + assert.True(t, result.Supported, "Expected Supported == true") + assert.Equal(t, int64(4294967295), result.DiscardMaxBytes, + "Expected DiscardMaxBytes == 4294967295") + assert.Empty(t, result.Reason, "Expected empty Reason for supported device") +} + +// Test ID: U-015 +func TestCheckDiscardSupport_UnsupportedZeroMaxBytes(t *testing.T) { + mockDir := setupMockSysfs(t, "sdb", map[string]string{ + "queue/discard_max_bytes": "0\n", + }) + overrideSysBlockDir(t, mockDir) + + fsObj := &FS{} + result, err := fsObj.checkDiscardSupport(context.Background(), "/dev/sdb") + + require.NoError(t, err, "checkDiscardSupport should not error for unsupported device") + require.NotNil(t, result, "Expected non-nil DiscardCapability") + assert.False(t, result.Supported, "Expected Supported == false when discard_max_bytes=0") + assert.Equal(t, int64(0), result.DiscardMaxBytes, + "Expected DiscardMaxBytes == 0") + assert.Contains(t, result.Reason, "discard_max_bytes=0", + "Reason should mention discard_max_bytes=0") +} + +// Test ID: U-016 +func TestCheckDiscardSupport_MissingSysfsPath(t *testing.T) { + overrideSysBlockDir(t, "/tmp/nonexistent-sysfs-path-for-test") + + fsObj := &FS{} + _, err := fsObj.checkDiscardSupport(context.Background(), "/dev/nonexistent") + + require.Error(t, err, "checkDiscardSupport should error for missing sysfs path") + assert.Contains(t, err.Error(), "failed to read", + "Error should mention 'failed to read'") +} + +// Test ID: U-017 +func TestCheckDiscardSupport_InvalidDiscardMaxBytes(t *testing.T) { + mockDir := setupMockSysfs(t, "sdc", map[string]string{ + "queue/discard_max_bytes": "abc\n", + }) + overrideSysBlockDir(t, mockDir) + + fsObj := &FS{} + _, err := fsObj.checkDiscardSupport(context.Background(), "/dev/sdc") + + require.Error(t, err, "checkDiscardSupport should error for non-numeric discard_max_bytes") + assert.Contains(t, err.Error(), "failed to parse discard_max_bytes", + "Error should mention 'failed to parse discard_max_bytes'") +} + +// Test ID: U-018 +func TestCheckDiscardSupport_DmCryptWithoutAllowDiscards(t *testing.T) { + mockDir := setupMockSysfs(t, "dm-0", map[string]string{ + "queue/discard_max_bytes": "4294967295\n", + "dm/uuid": "CRYPT-LUKS2-abc123\n", + "queue/discard_granularity": "0\n", + }) + overrideSysBlockDir(t, mockDir) + + fsObj := &FS{} + result, err := fsObj.checkDiscardSupport(context.Background(), "/dev/dm-0") + + require.NoError(t, err, "checkDiscardSupport should not error") + require.NotNil(t, result, "Expected non-nil DiscardCapability") + assert.False(t, result.Supported, + "Expected Supported == false for dm-crypt without allow-discards") + assert.Contains(t, result.Reason, "dm-crypt", + "Reason should mention dm-crypt") + assert.Contains(t, result.Reason, "allow-discards", + "Reason should mention allow-discards") +} + +// Test ID: U-019 +func TestCheckDiscardSupport_DmCryptWithAllowDiscards(t *testing.T) { + mockDir := setupMockSysfs(t, "dm-1", map[string]string{ + "queue/discard_max_bytes": "4294967295\n", + "dm/uuid": "CRYPT-LUKS2-abc123\n", + "queue/discard_granularity": "4096\n", + }) + overrideSysBlockDir(t, mockDir) + + fsObj := &FS{} + result, err := fsObj.checkDiscardSupport(context.Background(), "/dev/dm-1") + + require.NoError(t, err, "checkDiscardSupport should not error") + require.NotNil(t, result, "Expected non-nil DiscardCapability") + assert.True(t, result.Supported, + "Expected Supported == true for dm-crypt with allow-discards") + assert.Equal(t, int64(4294967295), result.DiscardMaxBytes, + "Expected DiscardMaxBytes to match sysfs value") +} + +// Test ID: U-020 +func TestCheckDiscardSupport_NonCryptDmDevice(t *testing.T) { + mockDir := setupMockSysfs(t, "dm-2", map[string]string{ + "queue/discard_max_bytes": "4294967295\n", + "dm/uuid": "LVM-abc123\n", + }) + overrideSysBlockDir(t, mockDir) + + fsObj := &FS{} + result, err := fsObj.checkDiscardSupport(context.Background(), "/dev/dm-2") + + require.NoError(t, err, "checkDiscardSupport should not error") + require.NotNil(t, result, "Expected non-nil DiscardCapability") + assert.True(t, result.Supported, + "Expected Supported == true for non-CRYPT dm device with valid discard") +} + +// Test ID: U-021 +func TestCheckDiscardSupport_NoDmUuidFile(t *testing.T) { + mockDir := setupMockSysfs(t, "sdd", map[string]string{ + "queue/discard_max_bytes": "4294967295\n", + // No dm/uuid file - not a DM device + }) + overrideSysBlockDir(t, mockDir) + + fsObj := &FS{} + result, err := fsObj.checkDiscardSupport(context.Background(), "/dev/sdd") + + require.NoError(t, err, "checkDiscardSupport should not error") + require.NotNil(t, result, "Expected non-nil DiscardCapability") + assert.True(t, result.Supported, + "Expected Supported == true for device without dm/uuid and discard > 0") +} + +// ---- Unit Tests: fstrim command execution (U-022 to U-025) ------------------ + +// Test ID: U-022 +func TestFstrim_Success(t *testing.T) { + restore := setupReclaimMockExec(func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "fstrim" { + return []byte("/mnt/data: 1048576 bytes were trimmed"), nil + } + return nil, nil + }) + defer restore() + + fsObj := &FS{} + result, err := fsObj.fstrim(context.Background(), "/mnt/data") + + require.NoError(t, err, "fstrim should succeed") + require.NotNil(t, result, "Expected non-nil FstrimResult") + assert.Equal(t, int64(1048576), result.BytesTrimmed, + "Expected BytesTrimmed == 1048576") + assert.Greater(t, result.Duration.Nanoseconds(), int64(0), + "Expected Duration > 0") +} + +// Test ID: U-023 +func TestFstrim_CommandFailure(t *testing.T) { + restore := setupReclaimMockExec(func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "fstrim" { + return []byte("fstrim: /mnt/data: the discard operation is not supported"), os.ErrPermission + } + return nil, nil + }) + defer restore() + + fsObj := &FS{} + _, err := fsObj.fstrim(context.Background(), "/mnt/data") + + require.Error(t, err, "fstrim should fail when command exits non-zero") + assert.Contains(t, err.Error(), "fstrim failed on", + "Error should contain 'fstrim failed on'") +} + +// Test ID: U-024 +func TestFstrim_ContextTimeout(t *testing.T) { + restore := setupReclaimMockExec(func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "fstrim" { + // Simulate a killed process error + return nil, &timeoutExecError{msg: "signal: killed"} + } + return nil, nil + }) + defer restore() + + fsObj := &FS{} + _, err := fsObj.fstrim(context.Background(), "/mnt/data") + + require.Error(t, err, "fstrim should fail on context timeout") + assert.Contains(t, err.Error(), "fstrim timed out", + "Error should contain 'fstrim timed out'") +} + +// Test ID: U-025 +func TestFstrim_ZeroBytesOutput(t *testing.T) { + restore := setupReclaimMockExec(func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "fstrim" { + return []byte("/mnt: 0 bytes were trimmed"), nil + } + return nil, nil + }) + defer restore() + + fsObj := &FS{} + result, err := fsObj.fstrim(context.Background(), "/mnt") + + require.NoError(t, err, "fstrim should succeed with 0 bytes trimmed") + require.NotNil(t, result, "Expected non-nil FstrimResult") + assert.Equal(t, int64(0), result.BytesTrimmed, + "Expected BytesTrimmed == 0") +} + +// ---- Unit Tests: blkdiscard command execution (U-026 to U-029) -------------- + +// Test ID: U-026 +func TestBlkdiscard_Success(t *testing.T) { + // Mock sysfs for device size + mockDir := setupMockSysfs(t, "sda", map[string]string{ + "size": "2097152\n", // 1 GiB in 512-byte sectors + }) + overrideSysBlockDir(t, mockDir) + + restore := setupReclaimMockExec(func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "blkdiscard" { + return []byte(""), nil + } + return nil, nil + }) + defer restore() + + fsObj := &FS{} + result, err := fsObj.blkdiscard(context.Background(), "/dev/sda") + + require.NoError(t, err, "blkdiscard should succeed") + require.NotNil(t, result, "Expected non-nil BlkdiscardResult") + assert.Equal(t, int64(1073741824), result.BytesDiscarded, + "Expected BytesDiscarded == device size (1073741824)") +} + +// Test ID: U-027 +func TestBlkdiscard_DeviceSizeReadFailure(t *testing.T) { + // Point to non-existent sysfs + overrideSysBlockDir(t, "/tmp/nonexistent-sysfs-path-for-test") + + fsObj := &FS{} + _, err := fsObj.blkdiscard(context.Background(), "/dev/nonexistent") + + require.Error(t, err, "blkdiscard should fail when device size cannot be read") + assert.Contains(t, err.Error(), "failed to get device size", + "Error should contain 'failed to get device size'") +} + +// Test ID: U-028 +func TestBlkdiscard_CommandFailure(t *testing.T) { + // Mock sysfs for device size (so we get past the size check) + mockDir := setupMockSysfs(t, "sda", map[string]string{ + "size": "2097152\n", + }) + overrideSysBlockDir(t, mockDir) + + restore := setupReclaimMockExec(func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "blkdiscard" { + return []byte("blkdiscard: /dev/sda: BLKDISCARD ioctl failed: Operation not permitted"), os.ErrPermission + } + return nil, nil + }) + defer restore() + + fsObj := &FS{} + _, err := fsObj.blkdiscard(context.Background(), "/dev/sda") + + require.Error(t, err, "blkdiscard should fail when command exits non-zero") + assert.Contains(t, err.Error(), "blkdiscard failed on", + "Error should contain 'blkdiscard failed on'") +} + +// Test ID: U-029 +func TestBlkdiscard_ContextTimeout(t *testing.T) { + // Mock sysfs for device size + mockDir := setupMockSysfs(t, "sda", map[string]string{ + "size": "2097152\n", + }) + overrideSysBlockDir(t, mockDir) + + restore := setupReclaimMockExec(func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "blkdiscard" { + // Simulate a killed process error + return nil, &timeoutExecError{msg: "signal: killed"} + } + return nil, nil + }) + defer restore() + + fsObj := &FS{} + _, err := fsObj.blkdiscard(context.Background(), "/dev/sda") + + require.Error(t, err, "blkdiscard should fail on context timeout") + assert.Contains(t, err.Error(), "blkdiscard timed out", + "Error should contain 'blkdiscard timed out'") +} + +// ---- Test helpers for simulating exec errors -------------------------------- + +// timeoutExecError is a test helper that simulates a process-killed error +// for testing timeout/cancellation paths. It is NOT an exec.ExitError, but +// is used to represent the error that would be returned when a process is killed. +type timeoutExecError struct { + msg string +} + +func (e *timeoutExecError) Error() string { + return e.msg +} diff --git a/gofsutil_reclaim_test.go b/gofsutil_reclaim_test.go new file mode 100644 index 0000000..1e33d5a --- /dev/null +++ b/gofsutil_reclaim_test.go @@ -0,0 +1,282 @@ +// Copyright © 2025 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gofsutil + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---- Test Helpers ----------------------------------------------------------- + +// resetGOFSMock resets all reclamation-related GOFSMock flags and custom result +// variables to zero values. Called via t.Cleanup() to avoid cross-test pollution. +func resetGOFSMock(t *testing.T) { + t.Helper() + t.Cleanup(func() { + GOFSMock.InduceFstrimError = false + GOFSMock.InduceBlkdiscardError = false + GOFSMock.InduceCheckDiscardSupportError = false + GOFSMockFstrimResult = nil + GOFSMockBlkdiscardResult = nil + GOFSMockDiscardCapability = nil + }) +} + +// withMockFS sets the package-level fs to a mockfs instance and restores +// the original on test cleanup. Returns the original fs for inspection if needed. +func withMockFS(t *testing.T) { + t.Helper() + origFS := fs + UseMockFS() + t.Cleanup(func() { fs = origFS }) +} + +// ---- Contract Tests --------------------------------------------------------- + +// Test ID: C-001 +// Package-level Fstrim() delegates to fs.Fstrim() which delegates to fs.fstrim(). +// Verifies the full call chain through the FSinterface. +func TestFstrim_PackageLevelDelegatesToFS(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + + ctx := context.Background() + result, err := Fstrim(ctx, "/mnt/data") + + require.NoError(t, err) + require.NotNil(t, result, "Fstrim should return a non-nil result via mock") + assert.Equal(t, int64(1073741824), result.BytesTrimmed, + "Expected default mock BytesTrimmed of 1 GiB (1073741824)") +} + +// Test ID: C-002 +// Package-level Blkdiscard() delegates to fs.Blkdiscard() which delegates to fs.blkdiscard(). +// Verifies the full call chain through the FSinterface. +func TestBlkdiscard_PackageLevelDelegatesToFS(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + + ctx := context.Background() + result, err := Blkdiscard(ctx, "/dev/sda") + + require.NoError(t, err) + require.NotNil(t, result, "Blkdiscard should return a non-nil result via mock") + assert.Equal(t, int64(107374182400), result.BytesDiscarded, + "Expected default mock BytesDiscarded of 100 GiB (107374182400)") +} + +// Test ID: C-003 +// Package-level CheckDiscardSupport() delegates through the full call chain. +// Verifies the full call chain through the FSinterface. +func TestCheckDiscardSupport_PackageLevelDelegatesToFS(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + + ctx := context.Background() + result, err := CheckDiscardSupport(ctx, "/dev/sda") + + require.NoError(t, err) + require.NotNil(t, result, "CheckDiscardSupport should return a non-nil result via mock") + assert.True(t, result.Supported, "Expected default mock Supported == true") + assert.Equal(t, int64(4294967295), result.DiscardMaxBytes, + "Expected default mock DiscardMaxBytes of 4294967295") +} + +// Test ID: C-004 +// GOFSMock.InduceFstrimError causes Fstrim() to return error through mock chain. +func TestMockFstrim_InduceError(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + GOFSMock.InduceFstrimError = true + + ctx := context.Background() + result, err := Fstrim(ctx, "/mnt/data") + + require.Error(t, err, "Expected an error when InduceFstrimError is true") + assert.Nil(t, result, "Expected nil result when error is induced") + assert.Contains(t, err.Error(), "fstrim induced error", + "Error should contain 'fstrim induced error'") +} + +// Test ID: C-005 +// GOFSMock.InduceBlkdiscardError causes Blkdiscard() to return error through mock chain. +func TestMockBlkdiscard_InduceError(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + GOFSMock.InduceBlkdiscardError = true + + ctx := context.Background() + result, err := Blkdiscard(ctx, "/dev/sda") + + require.Error(t, err, "Expected an error when InduceBlkdiscardError is true") + assert.Nil(t, result, "Expected nil result when error is induced") + assert.Contains(t, err.Error(), "blkdiscard induced error", + "Error should contain 'blkdiscard induced error'") +} + +// Test ID: C-006 +// GOFSMock.InduceCheckDiscardSupportError causes CheckDiscardSupport() to return error. +func TestMockCheckDiscardSupport_InduceError(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + GOFSMock.InduceCheckDiscardSupportError = true + + ctx := context.Background() + result, err := CheckDiscardSupport(ctx, "/dev/sda") + + require.Error(t, err, "Expected an error when InduceCheckDiscardSupportError is true") + assert.Nil(t, result, "Expected nil result when error is induced") + assert.Contains(t, err.Error(), "checkDiscardSupport induced error", + "Error should contain 'checkDiscardSupport induced error'") +} + +// Test ID: C-007 +// GOFSMockFstrimResult overrides the default mock return value. +func TestMockFstrim_CustomResult(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + GOFSMockFstrimResult = &FstrimResult{BytesTrimmed: 42} + + ctx := context.Background() + result, err := Fstrim(ctx, "/mnt/data") + + require.NoError(t, err) + require.NotNil(t, result, "Expected non-nil custom result") + assert.Equal(t, int64(42), result.BytesTrimmed, + "Expected custom BytesTrimmed value of 42") +} + +// Test ID: C-008 +// GOFSMockBlkdiscardResult overrides the default mock return value. +func TestMockBlkdiscard_CustomResult(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + GOFSMockBlkdiscardResult = &BlkdiscardResult{BytesDiscarded: 999} + + ctx := context.Background() + result, err := Blkdiscard(ctx, "/dev/sda") + + require.NoError(t, err) + require.NotNil(t, result, "Expected non-nil custom result") + assert.Equal(t, int64(999), result.BytesDiscarded, + "Expected custom BytesDiscarded value of 999") +} + +// Test ID: C-009 +// GOFSMockDiscardCapability overrides the default mock return value. +func TestMockCheckDiscardSupport_CustomCapability(t *testing.T) { + withMockFS(t) + resetGOFSMock(t) + GOFSMockDiscardCapability = &DiscardCapability{Supported: false, Reason: "test"} + + ctx := context.Background() + result, err := CheckDiscardSupport(ctx, "/dev/sda") + + require.NoError(t, err) + require.NotNil(t, result, "Expected non-nil custom capability") + assert.False(t, result.Supported, "Expected custom Supported == false") + assert.Equal(t, "test", result.Reason, + "Expected custom Reason 'test'") +} + +// ---- Unit Tests: Mock Method Defaults (U-030 to U-032) --------------------- + +// Test ID: U-030 +// Mock fstrim() returns default FstrimResult when no overrides are set. +func TestMockFstrim_DefaultResult(t *testing.T) { + resetGOFSMock(t) + m := &mockfs{} + + ctx := context.Background() + result, err := m.fstrim(ctx, "/mnt/data") + + require.NoError(t, err) + require.NotNil(t, result, "Expected non-nil default mock result") + assert.Equal(t, int64(1073741824), result.BytesTrimmed, + "Expected default BytesTrimmed of 1 GiB") + assert.Equal(t, 500*time.Millisecond, result.Duration, + "Expected default Duration of 500ms") +} + +// Test ID: U-031 +// Mock blkdiscard() returns default BlkdiscardResult when no overrides are set. +func TestMockBlkdiscard_DefaultResult(t *testing.T) { + resetGOFSMock(t) + m := &mockfs{} + + ctx := context.Background() + result, err := m.blkdiscard(ctx, "/dev/sda") + + require.NoError(t, err) + require.NotNil(t, result, "Expected non-nil default mock result") + assert.Equal(t, int64(107374182400), result.BytesDiscarded, + "Expected default BytesDiscarded of 100 GiB") + assert.Equal(t, 2*time.Second, result.Duration, + "Expected default Duration of 2s") +} + +// Test ID: U-032 +// Mock checkDiscardSupport() returns default DiscardCapability when no overrides are set. +func TestMockCheckDiscardSupport_DefaultResult(t *testing.T) { + resetGOFSMock(t) + m := &mockfs{} + + ctx := context.Background() + result, err := m.checkDiscardSupport(ctx, "/dev/sda") + + require.NoError(t, err) + require.NotNil(t, result, "Expected non-nil default mock result") + assert.True(t, result.Supported, "Expected default Supported == true") + assert.Equal(t, int64(4294967295), result.DiscardMaxBytes, + "Expected default DiscardMaxBytes of 4294967295") + assert.Empty(t, result.Reason, "Expected empty Reason for supported device") +} + +// ---- Unit Tests: Type Zero Values (U-036 to U-038) ------------------------- + +// Test ID: U-036 +// FstrimResult zero value has BytesTrimmed == 0 and Duration == 0. +func TestFstrimResult_ZeroValue(t *testing.T) { + result := FstrimResult{} + assert.Equal(t, int64(0), result.BytesTrimmed, + "Zero-value FstrimResult should have BytesTrimmed == 0") + assert.Equal(t, time.Duration(0), result.Duration, + "Zero-value FstrimResult should have Duration == 0") +} + +// Test ID: U-037 +// BlkdiscardResult zero value has BytesDiscarded == 0 and Duration == 0. +func TestBlkdiscardResult_ZeroValue(t *testing.T) { + result := BlkdiscardResult{} + assert.Equal(t, int64(0), result.BytesDiscarded, + "Zero-value BlkdiscardResult should have BytesDiscarded == 0") + assert.Equal(t, time.Duration(0), result.Duration, + "Zero-value BlkdiscardResult should have Duration == 0") +} + +// Test ID: U-038 +// DiscardCapability zero value has Supported == false, DiscardMaxBytes == 0, Reason == "". +func TestDiscardCapability_ZeroValue(t *testing.T) { + dc := DiscardCapability{} + assert.False(t, dc.Supported, + "Zero-value DiscardCapability should have Supported == false") + assert.Equal(t, int64(0), dc.DiscardMaxBytes, + "Zero-value DiscardCapability should have DiscardMaxBytes == 0") + assert.Equal(t, "", dc.Reason, + "Zero-value DiscardCapability should have Reason == empty string") +} diff --git a/gofsutil_test.go b/gofsutil_test.go index 66b6d36..b85d7cf 100644 --- a/gofsutil_test.go +++ b/gofsutil_test.go @@ -367,10 +367,11 @@ func TestFindFSType(t *testing.T) { mountpoint := "/mnt/test" result, err := FindFSType(ctx, mountpoint) - if err != nil { - t.Errorf("expected no error, got %v", err) + // Expect an error since /mnt/test doesn't exist and findmnt will fail + if err == nil { + t.Errorf("expected error for non-existent mountpoint, got nil") } - t.Logf("Filesystem type: %s", result) + t.Logf("Filesystem type: %s, error: %v", result, err) } func TestDeviceRescan(t *testing.T) { diff --git a/gofsutil_utils.go b/gofsutil_utils.go index f9515ba..addf416 100644 --- a/gofsutil_utils.go +++ b/gofsutil_utils.go @@ -64,3 +64,23 @@ func validateMultipathArgs(options ...string) error { return nil } + +// validateDeviceID validates device identifiers to prevent OS command injection. +// Device IDs should only contain alphanumeric characters, underscores, hyphens, and dots. +// This follows the same pattern used in goiscsi for CVE-2022-34374 (DSA-2022-202). +func validateDeviceID(devID string) error { + if devID == "" { + return errors.New("device ID cannot be empty") + } + // Allow only alphanumeric characters, underscores, hyphens, and dots + // This covers valid device names like: sda, sda1, nvme0n1, mpath0, emcpowera, + // dm-0, 3600601xxxxxxx, vol-abc123, etc. + matched, err := regexp.MatchString(`^[a-zA-Z0-9_\-\.]+$`, devID) + if err != nil { + return errors.New("failed to validate device ID: " + err.Error()) + } + if !matched { + return errors.New("device ID contains invalid characters: " + devID) + } + return nil +} diff --git a/gofsutil_utils_test.go b/gofsutil_utils_test.go index 4032be4..2848590 100644 --- a/gofsutil_utils_test.go +++ b/gofsutil_utils_test.go @@ -196,3 +196,269 @@ func TestValidateMultipathArgs(t *testing.T) { }) } } + +// TestValidateDeviceID tests the validateDeviceID function for OS command injection prevention +// This test covers CVE-2022-34374 style vulnerabilities (CWE-78) +func TestValidateDeviceID(t *testing.T) { + tests := []struct { + name string + devID string + wantError bool + errorMsg string + }{ + // Valid device IDs + { + name: "valid simple device", + devID: "sda", + wantError: false, + }, + { + name: "valid device with number", + devID: "sda1", + wantError: false, + }, + { + name: "valid nvme device", + devID: "nvme0n1", + wantError: false, + }, + { + name: "valid nvme partition", + devID: "nvme0n1p1", + wantError: false, + }, + { + name: "valid mpath device", + devID: "mpath0", + wantError: false, + }, + { + name: "valid mpath device with letters", + devID: "mpatha", + wantError: false, + }, + { + name: "valid emcpower device", + devID: "emcpowera", + wantError: false, + }, + { + name: "valid dm device", + devID: "dm-0", + wantError: false, + }, + { + name: "valid device with underscore", + devID: "vol_abc123", + wantError: false, + }, + { + name: "valid device with hyphen", + devID: "vol-abc123", + wantError: false, + }, + { + name: "valid device with dot", + devID: "vol.abc123", + wantError: false, + }, + { + name: "valid WWN-style ID", + devID: "3600601xxxxxxx", + wantError: false, + }, + { + name: "valid long alphanumeric", + devID: "60000970000120001263533030313434", + wantError: false, + }, + // Invalid device IDs - command injection attempts + { + name: "empty device ID", + devID: "", + wantError: true, + errorMsg: "device ID cannot be empty", + }, + { + name: "single quote injection", + devID: "x'; id; echo '", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "semicolon injection", + devID: "sda; rm -rf /", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "backtick injection", + devID: "sda`id`", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "dollar sign injection", + devID: "sda$(id)", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "pipe injection", + devID: "sda|cat /etc/passwd", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "ampersand injection", + devID: "sda&id", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "newline injection", + devID: "sda\nid", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "space injection", + devID: "sda id", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "forward slash", + devID: "/dev/sda", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "backslash", + devID: "sda\\nid", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "double quote injection", + devID: `sda"; id; echo "`, + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "greater than redirect", + devID: "sda>/tmp/pwned", + wantError: true, + errorMsg: "device ID contains invalid characters", + }, + { + name: "less than redirect", + devID: "sda+Pni{ZpOmleVj`n|2s?Kz*$o}KgjcYb~)?(X^pjWooS z#z@PgV#l#`7Mq=QnuF0p^c$Ea#@9<(9^}#yr)>nBd^a+;CpFPJb!vbm(;ht zB%xbdL#sayZZ40q1P#T+@(ZIfwyf_!%j-ad~Mlk)(oY!T-iP| zq{ZbE#y&GhmQSj?R95YnR&w1yO6gG_#N+r$17>se#e3l~A$uOKQI(odhT+4fZsFjx zsWj_xWBDRV-frnEIssZ4N zAkSC;$bZ z02F`%Pyh-*0Vn_kpa2wr0#E=7KmjNK1)u;FfC5n9e-|JP6u2&%J2Dbr*1*`moco0s L64&h<%R~ME)GmU7 literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_0_f_bad_bbitmap.gz b/tests/fsckdata/img_0_f_bad_bbitmap.gz new file mode 100644 index 0000000000000000000000000000000000000000..8a26add0d0452791784e794b2e5d3e3214fa6e80 GIT binary patch literal 2598 zcmb2|=HPh9v?`NCkcWH>} zu&OKeZJjI{d*tB4VD{3DyjPRjw=IZveXFC?$LIP&siUKbPs=YUR%FYfM+-V)|Ac** zTru6HS5W?~cy-p2oC z?(FBhK$Q+}P1-)BY%$%xTk+4%fAh1n*8XQ?U}$L7kNm%Vt#zU5&3_lBl$ik)9;orS z_J6zeJU`Xj*Zu?f8!RIK1FbsnD17HXb|9Y!(9pVWLr#=DP-R5PrOH=C=)k4qfRpJ( zyPID(UwJj>taVoDvYC7D2!(5}j;)W4h|BRV{rg)ZMZ2%NtNM)J{gVPwhi;^#u9>#K z{?@MW<#Qc(>BV?kH<#4fPb*`4dCOvA8Jl8{OyBC)b3>2qUt#m<@9wtDzZp^g51vi= zbGm+AkM7cs?_XcsV(fAK=<3Y$3G4pM-eTO5|2O5|<;dKBlR4KO-S+E`dbHl=kDBxE x{Qs^OdTiRIzx=q|Jt{sL0;3@?8UmvsFd71*AwXdW9JuqFaYis>CIf>40{~^nH%kBj literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_0_f_first_meta_bg_too_big.gz b/tests/fsckdata/img_0_f_first_meta_bg_too_big.gz new file mode 100644 index 0000000000000000000000000000000000000000..ab750ae31ffc1e74bca772a82edcb23e4f93bb8b GIT binary patch literal 586 zcmb2|=HPh9v?`NUEA#7KnaEi zllARy@#vPlP-(U5a9Yv4`buP1f|_-QbD_XS=TH%r=BDnFf=%cC@hmJUHV~zkFMI`}KbQ_g!k$ zcW&SP_pzw-;nzREcBjkd>3qL_yGL+(#RIEo5K?pVr)>A$`|a_TW&82`U_ ziC9tZhO4@1p$AN2PZXtpPo7jRH>djlmy;R$r!KU4S0im=ZBl3VF39Zs;Wcxgdp@o| z`|JLjB|ooRc>eQ8b>#lU(~;-CZ~s{(Vf}xDoJ^+r;iR(l|Fx<#4}^R@e>iPLNc7E{ zH+ZgQt)4pX_WUg4tJbrlzn{B*YfpW7@*zh4Kihwm+C}`opPl)aSv63qum-q>Xz^;4s3@D%>@b1>X zWpbD2eESGfZG)0056s3i1E| literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_0_f_zero_inode_size.gz b/tests/fsckdata/img_0_f_zero_inode_size.gz new file mode 100644 index 0000000000000000000000000000000000000000..cd860307e824cb9cc2a94f08100913a231ebaf55 GIT binary patch literal 10862 zcmeIuYfM^a7zc3FY`S=Dy6Kv!*qXH4tT()C7_PLMwi0P-M;EmRok)lxOcWI_;I_7F zTuV2`Y-?@JT3fXhry%h%MCPUQ-pxpdRMb%CrYJ!Rhze}yeBK8?n(RHFo|FIoecvZP zbd8J4qGJmU2AX)(<|EQayv|WgoN!6aR8= zeEZ?yVjuS}V=p-SkPZ%zzZtyf9Fu8vO^FPNza86^eImoR;$uTGD}f7FFE5dliKfvjM4jnQt+HtKtdFIFtaWc!PS|Fr4O0af z`Sc&L_MQHf${^B)MLhQ8g;LJYO)g)4TC%(<#~NPOoks^xa=W@B#k;Ru_4LUnx62o1 z8gV#_)>`6?=9;ys_scCqa*;7QBfZkEj=UgeRAcxQs@S6DKjyBPZDrUEPV{|jgv1tt z+C{r1866wW;97i87o?~D9Y=|2p{N$OWOy_+T|%WDy0-F0qVP9ltbs?rb23y@M+AI= zjht9VGCrJPkt_zzOh{&fvpBCASKf!t4>%4)$`Lp_3Xe<3OTIVYbAj+V%sX%`8Obif zTcp5SoH+@vVnFUW02dxxbOxLAVxVHl`UP$%0llQfMh}M;_M!nTan(~xtuI%)^96eVwoX%Ljdb!H3k=v_H3cO7R`LliN z);C=(S&Q=;JBMC4Pzg~W>U_?N4u)9Yv8pnwJXL&Es%k1->W|v5AFow8#+FoTx_kNM zGL|LhIC|dKcTHAkF*b^8L*#~)vz-am$$C@Ve5sA$E~@7?1a8kb@i3FPqS{T*xusov zNu2$8=70XyNByDHI7%sa6;q0RZzM^*ZFiAKBhf00e*l5C8%|00;m9AOHk_01yBIKmZ8*-voGabu<2lH-8E9SkU|7y~ggGVg_KC zK9U*&;jtpSQ}kAll!B{nAPC4m&0hZk>JRW9^HLnSV(DRpeKmpU4(H;wSmu|TKQr}; z@;%#yS=OoeCMCY~?1|V6kHu)^$U*>D5e)@?v z%35WU^p#1N-%^*$@|wl%-0hg&X1-3UX}dIaW3y8*wmSEx;#p?=^B_}H)rdZjct?0E zDaBMQ-y)von)2Gwf}#AW*LKG}t{C=d&%pQAZ0+yqr%EThdtf^ bYCr%800AHX1opqcqlsgLL0thV@h<8gAL<8X literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_12_f_badbblocks.gz b/tests/fsckdata/img_12_f_badbblocks.gz new file mode 100644 index 0000000000000000000000000000000000000000..f16e4f208c226f9897a8960638f16891807af6bc GIT binary patch literal 410 zcmb2|=HPh9v?`NeP>*5O}u#`CdF=(KxB`O>$KpXDO;-AxEL7f11*!c2P>Dh zUq5^0ZCLu<-APt+=gf`Ik((Pk@7t@p>K{I@n)z+*e}C7j^$NmkcbPL>ar>_r|BL_U rrTP=o|2gmbBL8XW{|Wkk+iPChKk)`jF;Eo*>=qACYP-lVg^>XOX78Xo literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_12_f_baddir.gz b/tests/fsckdata/img_12_f_baddir.gz new file mode 100644 index 0000000000000000000000000000000000000000..72441f008fbf56d0504972b903e8f280bb2a4bac GIT binary patch literal 14381 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 Wv(JCyZ7=W}6f@KMA4nP~$o~bgZ8yRI literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_12_f_clear_orphan_file.gz b/tests/fsckdata/img_12_f_clear_orphan_file.gz new file mode 100644 index 0000000000000000000000000000000000000000..32f261fc7f9a7e6cacf4817fd0d9258c6cb82005 GIT binary patch literal 12867 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 B%Sr$M literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_12_f_ext_journal.gz b/tests/fsckdata/img_12_f_ext_journal.gz new file mode 100644 index 0000000000000000000000000000000000000000..c376c8170e70f9f30106e57cb3a3067b86687da7 GIT binary patch literal 49577 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 aOqr5~=6?Y2|Ma|TSFt%^))kPe0Qd*69O&8r literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_4_f_badcluster.gz b/tests/fsckdata/img_4_f_badcluster.gz new file mode 100644 index 0000000000000000000000000000000000000000..85b7e1585a59d5a6ff1ba2a27508ecf83dc4e31d GIT binary patch literal 3208 zcmeH`eNfT|7{@VNc2|ws`O;k(S?fB2q?S`QUnnapJ-5nL8Y=nPz=-8i7>Yl=RaBau zHmP*tSs9fmh1e`ht`b;~Ql$dRq|zv zF6zY9ik+=nx_?!J9z8LDh~=rv5leuSBLdnJRSaHYTwki z&Bi-zH)00JC!P5fA*Rl*<3bbJ;K@ak*YnL%5G@$}HiDvgwnrUXHaH5R9p=8@fux;jZ<` z3Gw+9Qr!|`=OvZsXo+caTpk#YbvQVOf$4GiD8c12>UOeXPdvP@WQm_cK`2S$S(H`o zO};){&h1P_ibbUAMYklWQl>7xRGhsk6VUwT(X5;KvtV+;>-K|#I$Bp#P@^a2zLO2W zW>q23OR9`DZssXDM6X#BnDUXcW%vnQC2JM7`}_5-a_RZr_;o4AA4DupE|s}B3H^-$ z#TluXQ0W5VXjEd!frds2uzv+GFjc@YKi*vp%DU?i((N*H?fwy}5*JCU7^yYqS_ztq zKt(4HI)wJi4R9$!&aTZm6wiXQK8978^Wc>7LjWS|&UOjta$S z@{(p%O`xdOWJJ3xr7!(>rd-ByM=Kwtm*WZ0v>H?CbmSLnMT1Fa2*ht7O@nXFI%;*P zs4|vuS5a@)j-kc`&g%7IsMpu*G5gey)KJ3Jyxc{n(1mzv1h<_YE1<%ZAr=Jv0?5>?f$FGFfY(X?CO`SiKWht{XqLVwm`FXY zaKEt=&5~YAvI?*d=-J062d!o8d4`_1QsX9rlu=~ZeF;J`;|k!E+gZu1S1s4<&#&3H z#f4^(4pj9^Qb)B!VNp#(@4D)~qU3TuuEzFa7r5=>xwWGJW!{EmO=aVw>2q%}+4ueZ z^Z{xDp3S|G8nYrwVe~Y=eJ30OJacFWGqXfj6EX1#)A-Ac~wy)3Pv_ z!H5^~9qiCzUD0VEhS_K4 zDAGT;3puQ%we-_Lg_Yvan1TwHlFkDOzl;$9*DjPbbevETDE+UGbrm}@5gjv!RIUwu zOsTy-9uYWjL9t_>fY&io$q)B2uD-42rmJ?eJp&FoSlz3>+la(aL?J#0s_*z%V}JLF zDt4~uxQnbr_mIvDGmM6zk*Nif@HrnMUG_fG!(?2Ddg$-$CLt29%h@N*X}WP!E!(MU z#&Ljm4jyxLLhzD#vhUY)V{;=UpSOf{zj^%JTYot((1Lr`x_GHJ9kVYHVkYy?8{sH_ z_Tx7>#+ewV0@pq^G~;EvY%u{<&>aIVHju{%RsUg&o1%YWt=UF%D>nf*0XG3R0XG3R Uf&Y_0SeeTsT2OTl23raH3p7-$*Z=?k literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_4_f_jnl_etb_alloc_fail.gz b/tests/fsckdata/img_4_f_jnl_etb_alloc_fail.gz new file mode 100644 index 0000000000000000000000000000000000000000..cff8a4c1d4c4ab4e78c8e96b1b708074cd82a820 GIT binary patch literal 3549 zcmeIxYgCeV90zd9HR)<)DWzq0QEQPkuW9mFo~?^{$()*$XyyeYFJ*{gKDD(x%gkJv zO+z9GDnEu9XZ<$eKXx z^2;{O;T8-FQ}eUi2E?wT^(Qu5bDQNH^ZI4b+|4tl&2bT1p9>GTv$L=s#De7=?1W~id6(N3zMiRd>5YK7@k|DY^#r+Ize zn`Hd=L3_iO=TtmDpO>bFZm8q178c#=vRiuzjDsEtS2iZun8Cc0H|MtAYMq(VW5XSG z*}4v4K=4Fo&xmW%vGOWy->gG)Hh+BvkKpuvOni5yiPiJauncFqeQW!v2RjJLU1da| zD(WPL5ZVf2FDhI_OJr@hVGrv-FZYJR60yv$C%UxaETpOWZORhrO7Uad?(EXgbiRld z*;Bt247CSM!F+a}cPSW7UR{6m`U80~Yfx19z`xWIDuOdRVh8#9fd;7i?7s2DjCWdc z7#1FG5#-d=G)>ib$H^NtprfePV6s+Z1+*F->XB`FwMXF(RFvHX zD;evv-!|x)X39Wan$9YUZcvoJ8HYKGgaJH$nO>uW3F(Q%X~|nyKx!%*oRejyoyVOa z+x_#0+BWi8GHdQx229@|3UT=;bZc zap?}+!3!fQra~CLRU2WD5lh?|KtFWeb?jy0r9Hf0Vs1xEy*r;P;6KhqLV^AMgw(YB zr)`EYgCWwsFn=Mm^`k`Q<;iRaAsMd4v7_zo2zQkbLy3gr?nlh#Rkml=PH5%CucD~BJNS~kMvs!TT)~ztxAf zK~|~jx-YYkuiKQKcrbPR+1q<--7UT@WcstyK0?Z8$!Gj-+Z06U+7g;*4&tA?*;ggIKD|LSD=mEU8yR9zrB0aQKEpu~vj?u4uM<#w;iY z+hf>VYKY^pz}}i7E)DO$!h;%afxUm?{W!@^Er*eJaB>a`-9b(4Ce%>(Avw$tp>mlv zKe|<;GeT}((ur*8^lBxzEv_`7CncG;CJTY|8jOoSqHirI%?Uyy`A<((JRQ;Pjvs}| zu3gTc`p#-leb)d#eFQjyr0n45^YomJ8C37E_wviY=(BQv5vV<;D-glQN?hiCc7IWc zu&!=iF(meER%&&HxJ<7Mk!;|cREZmV9lGf7$fpuwT$n_kumW zz1PZPvR|Lp9*qBc&u#qwGH3hlNTrr$^}^9&8Rjq=w{PU-P=!zl+U z>)agDz;J>|{Eg$ju~i`X(he^8ME-GGv(}Wgr%Eh2GGRh7n&LXwTiN)ar4~p|r~9rt zyo9G9;q!B$}o37q&nV#tSsI2D(uUURzfN=qvrB1MXS=9xy zdW|o?c)duKBb7oLL24-q9aA+lE*QAYb;wpXjdsVWK))TYIzVkMytlB^N5Mv8&y?cM zv&^(Qdum+edTlCKax^bjYo8HlR-<;|dh?_~{`XIn-AW|cLtdIh|1m=lmL_Cv7DX&C*~W?(!H0)GAUVLN`K<2jHI zxLs9R@Gt_eh9ik(@xwsvc)nm{0#@A0Jr)8NgF+pB10e$@S5f#7^23;3sYVFgP?7=Z p(iQuCa{QtelW|gDQeaYGQeaYGQsDm-IL^?UyV!2F#$c9V{sa&+Z$SV6 literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_4_f_zero_super.gz b/tests/fsckdata/img_4_f_zero_super.gz new file mode 100644 index 0000000000000000000000000000000000000000..569d76c5091c5d909aaec65905002d87d1a71261 GIT binary patch literal 13336 zcmeIyeKga190%}q^-PVFutjx=>mhnz9@4Izt9w||DR+6ewp1HJ2pcAI)V)qw-13w# zm4=1TVrrNerII{^jcqY353?jp#_!(y|NW!Cx~Fr#pFck5e9rlt^Ld}wA0NY%_3MXz zcn}0r3kvlQAQDJ~IO4@HLS%F}@gi($fO`?qrw4nVX-ZmK%5OV*qV^-*<8Bj!j)TP* z>TEFHzuRO#Yt@IC;mvDtyIPGJSN$xQ$TM-33Kgei36!q;?dtcj70ATxGmo~6_Zz9l z7ktJxG7M$NJP`!*-RTNTj5wer#nIbz1-XP{;zJ^XGt_^Eu!ea>n>oWE{3?Uj!;kjZ zRC}(YfUuykLbH-^6I1NUm@Jx2X>vrSjjDioE=uiF0&>B6-gnPU>B8;;!sz|PDr+I_ zrOT3}ePr%zEP=0#rc3zBP`ac=d5MnZE6>u=Ey_qbkFT6Or3-Zv_Ozu9LvU$ra+Tzw zl)tjTsaWYv9gJ}qFo{rg+`PBEFruXK_%rF*bjaO4Of`UdLxTwTE~P6HIwdhPfO@MQ z)A&jmkq(D_r$uOP*OeT*U?k|2=DO+>45p^h{<_~r=Ae~B>C^aPn-J_xg>kwoS0Z^O zl`4=UQTmRt=1X@5b{Mq<7oO$7Q$s1a*X1MJnSMmuLYm>gH4&*^6*Oh#dN{{!st`4y zR;%CafW|{met!k+^wGhm(;HxYN0ytFCm`#F9yHx*4lc$1=a|*yBi>kg-8rUbdg>A~ zZjmhhAoDjf4>&ng{#;HflqeeVR}LzoKgo}`o6UrCwDs>g9YE(6^*LR7AMB+;!Ob2q zSBPA@aolXwI+2(iZe@6|J8GW>Lq4|ad^AO0e$0+35l!06TB|H2^(Jz;Dr#xn^|53s z*S|l*7SUc_w-tjk%fzdCtwi!_ihe|+rrEnEWuhkofz@%|g`5G?Z7QF)WHm`ii`Qti zDo8vjqfldc+~tGtG+P4;Cm&HsSW!yQ7ipI;j@3}*>ELj?o?VkuZo$j_L)h9{6%fX( zs=;Pf>Q2N~i24nj@^4#aW1Dv|_+k#mcv^>AznpdXPiG4#r&61jpz&#?gz4btU6S>x zsxUK+B4uoSFnjl14Ee2>Krxd~F0N;Dv`N1@O>^pZK-bxR72A*6x|`29y2tjf#ISK0 z;iBRn9uAhQNKGQVeBsM=sKNDT-oeq#?VFLFJtu6-=DBm3!VcW|xsXf51K19dhZD%@kT#6-rKBBEXSJ$m+ve+y~(34gyrddtD`6x@h-}zGNU-XbXI_QU7jK9 zN-}opj<(6Y{j!MdW@G6kOlpvw?GRw>Exm@58cIi5WezrZ?mm^mHCDH-(uQ#bI%RRr zWagG)G@_4rdQpAsFv29g{U~!y@!dc_68mLTJU4O%tMGR&>m}ecOXoV74K?1-Vv0u^ zdxPS)SCez6jC)YQScQmoBjVBX^zjJBtl;t3gj>X+74gpajOUpzC{1?|4zc2ma%UZOM?wR#1U)6uoqD{kxx%E`UgjT_9Uk>GbxgY{ zXL)?YmFhCaVlP~K-Cexz;lx!Gb=u+$?HQ{r4@y!e_$&oXsIZf!ihtW&3v*k-4LGK7 zcIy2vp)H8!gt&mhntAIz0pt1AbL-N?et10*MeInH${h8+(>DWR0|6ia1b_e#00KY& z2mk>f00e*l5C8%|00;m9AOHk_01yBIKmZ5;0U!VbfWWsCXoQ3;YR{DQ)>9iW zjXsu1`8sYj{{H$@)Rrz1;=;40H^%)}621C!H7R$}53SZRTCLXc!v5p7Y`t61s&AL{ zt1s1(*>3y?+WRbAG=y5|o)RQEaLk(+E1)8xe>^{<%vo4^xMYY`t%>ImYQS4Ar vTzLLp9SST00U!VbfB+Bx0zd!=00AHX1b_e#_`d}9c5gn8+kXg_$c6n4zG@rr literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_8_f_crashdisk.gz b/tests/fsckdata/img_8_f_crashdisk.gz new file mode 100644 index 0000000000000000000000000000000000000000..973335a217a2884b8d202838c9f66b962e1869a9 GIT binary patch literal 1062 zcmV+>1lju^iwFqe0;*{M18HqxXJubzUt@A%b7*8~b87(Yl>ciRbr{E=&t2{=xy$9- zCD+z%?I(_PuB96_uC=tKm#(8LqpP#bDMNQ*jcx6h%p|22KVcU{-5O>mA}S)gSe!T~ zV{E#OYIn8Pp;DX^zaiqqubDx%S|n)QYN9{7KfoXQg6H#jKM#D~&jSy9a(5i*Xzj!} zurUA>LO_|I2-j@j0Saz{r+6UvGhjS*>1`k~H}weA&DEFRBX?h@Cg%nC>qMp23~{=0 zoH;)J-ggUZ#i4fUB3Dh8=A~$_?jq0UD`i5ns|(>ElPzSjo}9ywuXK}Cxj^XIZTUsS z){`k<(k&!=6wV19&=xq`@cK)RGvv5iEzlWig~T5lqEWp}{6 z(f4mFNKk;>qB=zAFSBf^{byZ|unicsSk>Q%|u~_YOy+k*LVu9o|0}+84&5;T{}|NBakgatsfI$M9%hco;{+p`J032a#_pzG{s` z;`qT(|KLb8T&NaS+S<{D@s_U6f^6&R{zv|Iox6@-TVAFWU{v5Vu<0NeAe!I=%wro)>TUATDX6eu^+YL*nY3TxHCuX`8W|zwB)|mTrW{<(#Z!&v<4Pkbl!iH70 zS7ZBicEDhVOf~}C!gRLjrmE1e?{eA)%e$R{&jV4uPES;ao5+AnJM4|MxShJD_&e+=R$So~BGKU2joH1R84{Mrz| zF~x7ek-?7d6~~XN<7drrMR(*3$FHX2DmWLgb5U{rt~&FY^AFv*WH_&z&Sh{Rzl#pK zn2?K0x@>8eFyj(4E+=xk{cb7f_J!OfNq1S=y=un2CgZL^9>3qSHt1Ow@~lsKHl#h( zGoG4^XA_cY{Zb$(Z4OEGNvR<%HO@%Q8L0(%gMROppm%G?+n)4xq`li`yqy_u7xL}& z`?`a^-KY;8@#%9u*amyiNsCL~Q!7JI4u|C4q}-pD2N51xkg=%_*;p+C gFLYl;fR>#wU8vpcqgY{u75@MH4FTh$LjVW>05y>i-v9sr literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_8_f_extent_too_deep.gz b/tests/fsckdata/img_8_f_extent_too_deep.gz new file mode 100644 index 0000000000000000000000000000000000000000..202df17636371fe6e2cdb810cd9994372fea9997 GIT binary patch literal 2644 zcmb2|=HPh9v?`Nj zD=OQX90SV`%o3kQQl)->NpGtk?$i|(_0s?gs%L7-u8mfdX?MH%1K{e~@3$@P~sR?9y7 zoRyYY^yu!VCoVi+e?8SVTypnt$*%mj82oTh*_VQs-@$yRRME~$)3I3@n_Sb&dSf#4`?SB16 zfBDk&m&KVG8hB4E+uwev$R_3U|0t>HvyW#ze3B4Y$;iOKFgN)BMd5u@%?j;+oDFYN z|Nmd#GNY&MPh5kS$E1rF|H$1tywHAU=-ycC(#2Kj@2bjlTKJs}<6fUo=Lc#y5O(s~ z&$X}ruXq0WOCHF-k=oYn2c#KZSiP)M=FAGK*d%NmnM zy<32C4R5ozMcIaz@IjO@9C-HR`~Ua($C_E6F#&~2-1q&vf4zraWCk~ocR}{&ul;v= z+PTiK0(md`YW~J&9XpIr=|Ah^L8fAmjk}cN{~u4g6P}<48mw+x7XTsdn@I%@6#prpTz324REbl%&yQY}n{7U6 z+V}Kh^V3zkzu$VzFOpuTcvq)yz45=}$9{bO&vxt44!`*mx83-`9-VGD`+v`R^Z(w5 zHeR)oXT9~@{;Au|HH+^5KQH#FF!Nde&-gQO(?0zEvwnGHoa&mdr*22i-Ll17_wB1s zcQ@{j-Cz1|`qVeSvWp$b_0p)m(GVC7fzc2c4S~@R7!3hJA@J|6|HZV-ISdR63;@xg BSgim6 literal 0 HcmV?d00001 diff --git a/tests/fsckdata/img_8_f_illitable.gz b/tests/fsckdata/img_8_f_illitable.gz new file mode 100644 index 0000000000000000000000000000000000000000..1a8a0cdc9043752aa252e73f8ff6d3f62ac0049c GIT binary patch literal 427 zcmb2|=HPh9v?`NtDAGDDWX#|q(D;dfbNZq_fjz}t-c#JZs@*maim_rMd;yNW^?04_Rm3gPS#8o zny~umiMkYvP12ceTJyzqrnevZdgiX1S8?Y1g`YOP%d*<{zN~JKU-G}A=M`HEtLwtn z&o>Jf`~QZWVSiXRkLxzQu7Bpir5B``el5ElTV-4SiYZc3^Bto@U)BG=M`iAN+t0VR zx@|uDD8r|(zqaykK9ON>&YH4tv#k8<`*-VCWj?&SYvYNE6eT0cnO#?uFHdw!Q8Sv| zWxn{SbqE8)kNz&nYNgPi*I)O{idy?A^7}rsX?(Yjt7o&ny}bDE!FR$xs-OD&zV`ok z)XjPYVdv(13p&y{qN` literal 0 HcmV?d00001