Files
gibidify/shared/paths_test.go
Ismo Vuorinen 95b7ef6dd3 chore: modernize workflows, security scanning, and linting configuration (#50)
* build: update Go 1.25, CI workflows, and build tooling

- Upgrade to Go 1.25
- Add benchmark targets to Makefile
- Implement parallel gosec execution
- Lock tool versions for reproducibility
- Add shellcheck directives to scripts
- Update CI workflows with improved caching

* refactor: migrate from golangci-lint to revive

- Replace golangci-lint with revive for linting
- Configure comprehensive revive rules
- Fix all EditorConfig violations
- Add yamllint and yamlfmt support
- Remove deprecated .golangci.yml

* refactor: rename utils to shared and deduplicate code

- Rename utils package to shared
- Add shared constants package
- Deduplicate constants across packages
- Address CodeRabbit review feedback

* fix: resolve SonarQube issues and add safety guards

- Fix all 73 SonarQube OPEN issues
- Add nil guards for resourceMonitor, backpressure, metricsCollector
- Implement io.Closer for headerFileReader
- Propagate errors from processing helpers
- Add metrics and templates packages
- Improve error handling across codebase

* test: improve test infrastructure and coverage

- Add benchmarks for cli, fileproc, metrics
- Improve test coverage for cli, fileproc, config
- Refactor tests with helper functions
- Add shared test constants
- Fix test function naming conventions
- Reduce cognitive complexity in benchmark tests

* docs: update documentation and configuration examples

- Update CLAUDE.md with current project state
- Refresh README with new features
- Add usage and configuration examples
- Add SonarQube project configuration
- Consolidate config.example.yaml

* fix: resolve shellcheck warnings in scripts

- Use ./*.go instead of *.go to prevent dash-prefixed filenames
  from being interpreted as options (SC2035)
- Remove unreachable return statement after exit (SC2317)
- Remove obsolete gibidiutils/ directory reference

* chore(deps): upgrade go dependencies

* chore(lint): megalinter fixes

* fix: improve test coverage and fix file descriptor leaks

- Add defer r.Close() to fix pipe file descriptor leaks in benchmark tests
- Refactor TestProcessorConfigureFileTypes with helper functions and assertions
- Refactor TestProcessorLogFinalStats with output capture and keyword verification
- Use shared constants instead of literal strings (TestFilePNG, FormatMarkdown, etc.)
- Reduce cognitive complexity by extracting helper functions

* fix: align test comments with function names

Remove underscores from test comments to match actual function names:
- benchmark/benchmark_test.go (2 fixes)
- fileproc/filetypes_config_test.go (4 fixes)
- fileproc/filetypes_registry_test.go (6 fixes)
- fileproc/processor_test.go (6 fixes)
- fileproc/resource_monitor_types_test.go (4 fixes)
- fileproc/writer_test.go (3 fixes)

* fix: various test improvements and bug fixes

- Remove duplicate maxCacheSize check in filetypes_registry_test.go
- Shorten long comment in processor_test.go to stay under 120 chars
- Remove flaky time.Sleep in collector_test.go, use >= 0 assertion
- Close pipe reader in benchmark_test.go to fix file descriptor leak
- Use ContinueOnError in flags_test.go to match ResetFlags behavior
- Add nil check for p.ui in processor_workers.go before UpdateProgress
- Fix resource_monitor_validation_test.go by setting hardMemoryLimitBytes directly

* chore(yaml): add missing document start markers

Add --- document start to YAML files to satisfy yamllint:
- .github/workflows/codeql.yml
- .github/workflows/build-test-publish.yml
- .github/workflows/security.yml
- .github/actions/setup/action.yml

* fix: guard nil resourceMonitor and fix test deadlock

- Guard resourceMonitor before CreateFileProcessingContext call
- Add ui.UpdateProgress on emergency stop and path error returns
- Fix potential deadlock in TestProcessFile using wg.Go with defer close
2025-12-10 19:07:11 +02:00

789 lines
20 KiB
Go

package shared
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
const (
windowsOS = "windows"
)
// validatePathTestCase represents a test case for path validation functions.
type validatePathTestCase struct {
name string
path string
wantErr bool
errType ErrorType
errCode string
errContains string
}
// validateExpectedError validates expected error structure and content.
func validateExpectedError(t *testing.T, err error, validatorName string, testCase validatePathTestCase) {
t.Helper()
if err == nil {
t.Errorf("%s() expected error, got nil", validatorName)
return
}
var structErr *StructuredError
if !errors.As(err, &structErr) {
t.Errorf("Expected StructuredError, got %T", err)
return
}
if structErr.Type != testCase.errType {
t.Errorf("Expected error type %v, got %v", testCase.errType, structErr.Type)
}
if structErr.Code != testCase.errCode {
t.Errorf("Expected error code %v, got %v", testCase.errCode, structErr.Code)
}
if testCase.errContains != "" && !strings.Contains(err.Error(), testCase.errContains) {
t.Errorf("Error should contain %q, got: %v", testCase.errContains, err.Error())
}
}
// testPathValidation is a helper function to test path validation functions without duplication.
func testPathValidation(
t *testing.T,
validatorName string,
validatorFunc func(string) error,
tests []validatePathTestCase,
) {
t.Helper()
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
err := validatorFunc(tt.path)
if tt.wantErr {
validateExpectedError(t, err, validatorName, tt)
return
}
if err != nil {
t.Errorf("%s() unexpected error: %v", validatorName, err)
}
},
)
}
}
func TestAbsolutePath(t *testing.T) {
// Get current working directory for tests
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current directory: %v", err)
}
tests := createAbsolutePathTestCases(cwd)
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
verifyAbsolutePathResult(t, tt.path, tt.wantPrefix, tt.wantErr, tt.wantErrMsg, tt.skipWindows)
},
)
}
}
// createAbsolutePathTestCases creates test cases for AbsolutePath.
func createAbsolutePathTestCases(cwd string) []struct {
name string
path string
wantPrefix string
wantErr bool
wantErrMsg string
skipWindows bool
} {
return []struct {
name string
path string
wantPrefix string
wantErr bool
wantErrMsg string
skipWindows bool
}{
{
name: "absolute path unchanged",
path: cwd,
wantPrefix: cwd,
wantErr: false,
},
{
name: "relative path current directory",
path: ".",
wantPrefix: cwd,
wantErr: false,
},
{
name: "relative path parent directory",
path: "..",
wantPrefix: filepath.Dir(cwd),
wantErr: false,
},
{
name: "relative path with file",
path: "test.txt",
wantPrefix: filepath.Join(cwd, "test.txt"),
wantErr: false,
},
{
name: "relative path with subdirectory",
path: "subdir/file.go",
wantPrefix: filepath.Join(cwd, "subdir", "file.go"),
wantErr: false,
},
{
name: TestMsgEmptyPath,
path: "",
wantPrefix: cwd,
wantErr: false,
},
{
name: "path with tilde",
path: "~/test",
wantPrefix: filepath.Join(cwd, "~", "test"),
wantErr: false,
skipWindows: false,
},
{
name: "path with multiple separators",
path: "path//to///file",
wantPrefix: filepath.Join(cwd, "path", "to", "file"),
wantErr: false,
},
{
name: "path with trailing separator",
path: "path/",
wantPrefix: filepath.Join(cwd, "path"),
wantErr: false,
},
}
}
// verifyAbsolutePathResult verifies the result of AbsolutePath.
func verifyAbsolutePathResult(
t *testing.T,
path, wantPrefix string,
wantErr bool,
wantErrMsg string,
skipWindows bool,
) {
t.Helper()
if skipWindows && runtime.GOOS == windowsOS {
t.Skip("Skipping test on Windows")
}
got, err := AbsolutePath(path)
if wantErr {
verifyExpectedError(t, err, wantErrMsg)
return
}
if err != nil {
t.Errorf("AbsolutePath() unexpected error = %v", err)
return
}
//nolint:errcheck // Test helper, error intentionally ignored for testing
verifyAbsolutePathOutput(t, got, wantPrefix)
}
// verifyExpectedError verifies that an expected error occurred.
func verifyExpectedError(t *testing.T, err error, wantErrMsg string) {
t.Helper()
if err == nil {
t.Error("AbsolutePath() error = nil, wantErr true")
return
}
if wantErrMsg != "" && !strings.Contains(err.Error(), wantErrMsg) {
t.Errorf("AbsolutePath() error = %v, want error containing %v", err, wantErrMsg)
}
}
// verifyAbsolutePathOutput verifies the output of AbsolutePath.
func verifyAbsolutePathOutput(t *testing.T, got, wantPrefix string) {
t.Helper()
// Clean the expected path for comparison
wantClean := filepath.Clean(wantPrefix)
gotClean := filepath.Clean(got)
if gotClean != wantClean {
t.Errorf("AbsolutePath() = %v, want %v", gotClean, wantClean)
}
// Verify the result is actually absolute
if !filepath.IsAbs(got) {
t.Errorf("AbsolutePath() returned non-absolute path: %v", got)
}
}
func TestAbsolutePathSpecialCases(t *testing.T) {
if runtime.GOOS == windowsOS {
t.Skip("Skipping Unix-specific tests on Windows")
}
tests := []struct {
name string
setup func(*testing.T) (string, func())
path string
wantErr bool
}{
{
name: "symlink to directory",
setup: setupSymlinkToDirectory,
path: "",
wantErr: false,
},
{
name: "broken symlink",
setup: setupBrokenSymlink,
path: "",
wantErr: false, // filepath.Abs still works with broken symlinks
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
verifySpecialCaseAbsolutePath(t, tt.setup, tt.path, tt.wantErr)
},
)
}
}
// setupSymlinkToDirectory creates a symlink pointing to a directory.
func setupSymlinkToDirectory(t *testing.T) (string, func()) {
t.Helper()
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "target")
link := filepath.Join(tmpDir, "link")
if err := os.Mkdir(target, 0o750); err != nil {
t.Fatalf("Failed to create target directory: %v", err)
}
if err := os.Symlink(target, link); err != nil {
t.Fatalf("Failed to create symlink: %v", err)
}
return link, func() {
// Cleanup handled automatically by t.TempDir()
}
}
// setupBrokenSymlink creates a broken symlink.
func setupBrokenSymlink(t *testing.T) (string, func()) {
t.Helper()
tmpDir := t.TempDir()
link := filepath.Join(tmpDir, "broken_link")
if err := os.Symlink("/nonexistent/path", link); err != nil {
t.Fatalf("Failed to create broken symlink: %v", err)
}
return link, func() {
// Cleanup handled automatically by t.TempDir()
}
}
// verifySpecialCaseAbsolutePath verifies AbsolutePath with special cases.
func verifySpecialCaseAbsolutePath(t *testing.T, setup func(*testing.T) (string, func()), path string, wantErr bool) {
t.Helper()
testPath, cleanup := setup(t)
//nolint:errcheck // Test helper, error intentionally ignored for testing
defer cleanup()
if path == "" {
path = testPath
}
got, err := AbsolutePath(path)
if (err != nil) != wantErr {
t.Errorf("AbsolutePath() error = %v, wantErr %v", err, wantErr)
return
}
if err == nil && !filepath.IsAbs(got) {
t.Errorf("AbsolutePath() returned non-absolute path: %v", got)
}
}
func TestAbsolutePathConcurrency(_ *testing.T) {
// Test that AbsolutePath is safe for concurrent use
paths := []string{".", "..", "test.go", "subdir/file.txt", "/tmp/test"}
done := make(chan bool)
for _, p := range paths {
go func(path string) {
_, _ = AbsolutePath(path) //nolint:errcheck // Testing concurrency safety only, result not needed
done <- true
}(p)
}
// Wait for all goroutines to complete
for range paths {
<-done
}
}
func TestAbsolutePathErrorFormatting(t *testing.T) {
// This test verifies error message formatting
// We need to trigger an actual error from filepath.Abs
// On Unix systems, we can't easily trigger filepath.Abs errors
// so we'll just verify the error wrapping works correctly
// Create a test that would fail if filepath.Abs returns an error
path := "test/path"
got, err := AbsolutePath(path)
if err != nil {
// If we somehow get an error, verify it's properly formatted
if !strings.Contains(err.Error(), "failed to get absolute path for") {
t.Errorf("Error message format incorrect: %v", err)
}
if !strings.Contains(err.Error(), path) {
t.Errorf("Error message should contain original path: %v", err)
}
} else if !filepath.IsAbs(got) {
// Normal case - just verify we got a valid absolute path
t.Errorf("Expected absolute path, got: %v", got)
}
}
// BenchmarkAbsolutePath benchmarks the AbsolutePath function.
func BenchmarkAbsolutePath(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = AbsolutePath("test/path/file.go") //nolint:errcheck // Benchmark test, result not needed
}
}
// BenchmarkAbsolutePathAbs benchmarks with already absolute path.
func BenchmarkAbsolutePathAbs(b *testing.B) {
absPath := "/home/user/test/file.go"
if runtime.GOOS == windowsOS {
absPath = "C:\\Users\\test\\file.go"
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = AbsolutePath(absPath) //nolint:errcheck // Benchmark test, result not needed
}
}
// BenchmarkAbsolutePathCurrent benchmarks with current directory.
func BenchmarkAbsolutePathCurrent(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = AbsolutePath(".") //nolint:errcheck // Benchmark test, result not needed
}
}
func TestValidateSourcePath(t *testing.T) {
// Create test directories for validation
tmpDir := t.TempDir()
validDir := filepath.Join(tmpDir, "validdir")
validFile := filepath.Join(tmpDir, "validfile.txt")
// Create test directory and file
if err := os.Mkdir(validDir, 0o750); err != nil {
t.Fatalf(TestMsgFailedToCreateTestDir, err)
}
if err := os.WriteFile(validFile, []byte("test"), 0o600); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
tests := []validatePathTestCase{
{
name: TestMsgEmptyPath,
path: "",
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationRequired,
errContains: "source path is required",
},
{
name: "path traversal with double dots",
path: TestPathEtcPasswdTraversal,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "path traversal in middle",
path: "valid/../../../secrets",
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "nonexistent directory",
path: "/nonexistent/directory",
wantErr: true,
errType: ErrorTypeFileSystem,
errCode: CodeFSNotFound,
errContains: "source directory does not exist",
},
{
name: "file instead of directory",
path: validFile,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: "source path must be a directory",
},
{
name: "valid directory (absolute)",
path: validDir,
wantErr: false,
},
{
name: "valid directory (relative)",
path: ".",
wantErr: false,
},
{
name: "valid directory (current)",
path: tmpDir,
wantErr: false,
},
}
// Save and restore current directory for relative path tests
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer func() {
// Need to use os.Chdir here since t.Chdir only works in the current function context
if err := os.Chdir(originalWd); err != nil { // nolint:usetesting // needed in defer function
t.Logf("Failed to restore working directory: %v", err)
}
}()
t.Chdir(tmpDir)
testPathValidation(t, "ValidateSourcePath", ValidateSourcePath, tests)
}
func TestValidateDestinationPath(t *testing.T) {
tmpDir := t.TempDir()
existingDir := filepath.Join(tmpDir, "existing")
existingFile := filepath.Join(tmpDir, "existing.txt")
validDest := filepath.Join(tmpDir, TestFileOutputTXT)
// Create test directory and file
if err := os.Mkdir(existingDir, 0o750); err != nil {
t.Fatalf(TestMsgFailedToCreateTestDir, err)
}
if err := os.WriteFile(existingFile, []byte("test"), 0o600); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
tests := []validatePathTestCase{
{
name: TestMsgEmptyPath,
path: "",
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationRequired,
errContains: "destination path is required",
},
{
name: "path traversal attack",
path: "../../../tmp/malicious.txt",
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "destination is existing directory",
path: existingDir,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: "destination cannot be a directory",
},
{
name: "parent directory doesn't exist",
path: "/nonexistent/dir/TestFileOutputTXT",
wantErr: true,
errType: ErrorTypeFileSystem,
errCode: CodeFSNotFound,
errContains: "destination parent directory does not exist",
},
{
name: "valid destination path",
path: validDest,
wantErr: false,
},
{
name: "overwrite existing file (should be valid)",
path: existingFile,
wantErr: false,
},
}
testPathValidation(t, "ValidateDestinationPath", ValidateDestinationPath, tests)
}
func TestValidateConfigPath(t *testing.T) {
tests := []validatePathTestCase{
{
name: "empty path (allowed for config)",
path: "",
wantErr: false,
},
{
name: "path traversal attack",
path: TestPathEtcPasswdTraversal,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "complex path traversal",
path: "config/../../../secrets/" + TestFileConfigYAML,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "valid config path",
path: TestFileConfigYAML,
wantErr: false,
},
{
name: "valid absolute config path",
path: "/etc/myapp/" + TestFileConfigYAML,
wantErr: false,
},
{
name: "config in subdirectory",
path: "configs/production.yaml",
wantErr: false,
},
}
testPathValidation(t, "ValidateConfigPath", ValidateConfigPath, tests)
}
func TestBaseName(t *testing.T) {
tests := []struct {
name string
path string
expected string
}{
{
name: "simple filename",
path: "/path/to/file.txt",
expected: "file.txt",
},
{
name: "directory path",
path: "/path/to/directory",
expected: "directory",
},
{
name: "root path",
path: "/",
expected: "/",
},
{
name: "current directory",
path: ".",
expected: "output", // Special case: . returns "output"
},
{
name: TestMsgEmptyPath,
path: "",
expected: "output", // Special case: empty returns "output"
},
{
name: "path with trailing separator",
path: "/path/to/dir/",
expected: "dir",
},
{
name: "relative path",
path: "subdir/file.go",
expected: "file.go",
},
{
name: "single filename",
path: "README.md",
expected: "README.md",
},
{
name: "path with spaces",
path: "/path/to/my file.txt",
expected: "my file.txt",
},
{
name: "path with special characters",
path: "/path/to/file-name_123.ext",
expected: "file-name_123.ext",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := BaseName(tt.path)
if result != tt.expected {
t.Errorf("BaseName(%q) = %q, want %q", tt.path, result, tt.expected)
}
},
)
}
}
// Security-focused integration tests.
func TestPathValidationIntegration(t *testing.T) {
tmpDir := t.TempDir()
validSourceDir := filepath.Join(tmpDir, "source")
validDestFile := filepath.Join(tmpDir, TestFileOutputTXT)
// Create source directory
if err := os.Mkdir(validSourceDir, 0o750); err != nil {
t.Fatalf(TestMsgFailedToCreateTestDir, err)
}
// Test complete validation workflow
tests := []struct {
name string
sourcePath string
destPath string
configPath string
expectSourceErr bool
expectDestErr bool
expectConfigErr bool
}{
{
name: "valid paths",
sourcePath: validSourceDir,
destPath: validDestFile,
configPath: TestFileConfigYAML,
expectSourceErr: false,
expectDestErr: false,
expectConfigErr: false,
},
{
name: "source path traversal attack",
sourcePath: "../../../etc",
destPath: validDestFile,
configPath: TestFileConfigYAML,
expectSourceErr: true,
expectDestErr: false,
expectConfigErr: false,
},
{
name: "destination path traversal attack",
sourcePath: validSourceDir,
destPath: "../../../tmp/malicious.txt",
configPath: TestFileConfigYAML,
expectSourceErr: false,
expectDestErr: true,
expectConfigErr: false,
},
{
name: "config path traversal attack",
sourcePath: validSourceDir,
destPath: validDestFile,
configPath: TestPathEtcPasswdTraversal,
expectSourceErr: false,
expectDestErr: false,
expectConfigErr: true,
},
{
name: "multiple path traversal attacks",
sourcePath: "../../../var",
destPath: "../../../tmp/bad.txt",
configPath: "../../../etc/config",
expectSourceErr: true,
expectDestErr: true,
expectConfigErr: true,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
// Test source validation
sourceErr := ValidateSourcePath(tt.sourcePath)
if (sourceErr != nil) != tt.expectSourceErr {
t.Errorf("Source validation: expected error %v, got %v", tt.expectSourceErr, sourceErr)
}
// Test destination validation
destErr := ValidateDestinationPath(tt.destPath)
if (destErr != nil) != tt.expectDestErr {
t.Errorf("Destination validation: expected error %v, got %v", tt.expectDestErr, destErr)
}
// Test config validation
configErr := ValidateConfigPath(tt.configPath)
if (configErr != nil) != tt.expectConfigErr {
t.Errorf("Config validation: expected error %v, got %v", tt.expectConfigErr, configErr)
}
},
)
}
}
// Benchmark the validation functions for performance.
func BenchmarkValidateSourcePath(b *testing.B) {
tmpDir := b.TempDir()
validDir := filepath.Join(tmpDir, "testdir")
if err := os.Mkdir(validDir, 0o750); err != nil {
b.Fatalf(TestMsgFailedToCreateTestDir, err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ValidateSourcePath(validDir) // nolint:errcheck // benchmark test
}
}
func BenchmarkValidateDestinationPath(b *testing.B) {
tmpDir := b.TempDir()
validDest := filepath.Join(tmpDir, TestFileOutputTXT)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ValidateDestinationPath(validDest) // nolint:errcheck // benchmark test
}
}
func BenchmarkBaseName(b *testing.B) {
path := "/very/long/path/to/some/deeply/nested/file.txt"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = BaseName(path)
}
}