mirror of
https://github.com/ivuorinen/gibidify.git
synced 2026-02-23 06:52:54 +00:00
feat: update go to 1.25, add permissions and envs (#49)
* chore(ci): update go to 1.25, add permissions and envs * fix(ci): update pr-lint.yml * chore: update go, fix linting * fix: tests and linting * fix(lint): lint fixes, renovate should now pass * fix: updates, security upgrades * chore: workflow updates, lint * fix: more lint, checkmake, and other fixes * fix: more lint, convert scripts to POSIX compliant * fix: simplify codeql workflow * tests: increase test coverage, fix found issues * fix(lint): editorconfig checking, add to linters * fix(lint): shellcheck, add to linters * fix(lint): apply cr comment suggestions * fix(ci): remove step-security/harden-runner * fix(lint): remove duplication, apply cr fixes * fix(ci): tests in CI/CD pipeline * chore(lint): deduplication of strings * fix(lint): apply cr comment suggestions * fix(ci): actionlint * fix(lint): apply cr comment suggestions * chore: lint, add deps management
This commit is contained in:
283
gibidiutils/errors.go
Normal file
283
gibidiutils/errors.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// Package gibidiutils provides common utility functions for gibidify.
|
||||
package gibidiutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ErrorType represents the category of error.
|
||||
type ErrorType int
|
||||
|
||||
const (
|
||||
// ErrorTypeUnknown represents an unknown error type.
|
||||
ErrorTypeUnknown ErrorType = iota
|
||||
// ErrorTypeCLI represents command-line interface errors.
|
||||
ErrorTypeCLI
|
||||
// ErrorTypeFileSystem represents file system operation errors.
|
||||
ErrorTypeFileSystem
|
||||
// ErrorTypeProcessing represents file processing errors.
|
||||
ErrorTypeProcessing
|
||||
// ErrorTypeConfiguration represents configuration errors.
|
||||
ErrorTypeConfiguration
|
||||
// ErrorTypeIO represents input/output errors.
|
||||
ErrorTypeIO
|
||||
// ErrorTypeValidation represents validation errors.
|
||||
ErrorTypeValidation
|
||||
)
|
||||
|
||||
// String returns the string representation of the error type.
|
||||
func (e ErrorType) String() string {
|
||||
switch e {
|
||||
case ErrorTypeCLI:
|
||||
return "CLI"
|
||||
case ErrorTypeFileSystem:
|
||||
return "FileSystem"
|
||||
case ErrorTypeProcessing:
|
||||
return "Processing"
|
||||
case ErrorTypeConfiguration:
|
||||
return "Configuration"
|
||||
case ErrorTypeIO:
|
||||
return "IO"
|
||||
case ErrorTypeValidation:
|
||||
return "Validation"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Error formatting templates.
|
||||
const (
|
||||
errorFormatWithCause = "%s: %v"
|
||||
)
|
||||
|
||||
// StructuredError represents a structured error with type, code, and context.
|
||||
type StructuredError struct {
|
||||
Type ErrorType
|
||||
Code string
|
||||
Message string
|
||||
Cause error
|
||||
Context map[string]any
|
||||
FilePath string
|
||||
Line int
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *StructuredError) Error() string {
|
||||
base := fmt.Sprintf("%s [%s]: %s", e.Type, e.Code, e.Message)
|
||||
if len(e.Context) > 0 {
|
||||
// Sort keys for deterministic output
|
||||
keys := make([]string, 0, len(e.Context))
|
||||
for k := range e.Context {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
ctxPairs := make([]string, 0, len(e.Context))
|
||||
for _, k := range keys {
|
||||
ctxPairs = append(ctxPairs, fmt.Sprintf("%s=%v", k, e.Context[k]))
|
||||
}
|
||||
base = fmt.Sprintf("%s | context: %s", base, strings.Join(ctxPairs, ", "))
|
||||
}
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf(errorFormatWithCause, base, e.Cause)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying cause error.
|
||||
func (e *StructuredError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// WithContext adds context information to the error.
|
||||
func (e *StructuredError) WithContext(key string, value any) *StructuredError {
|
||||
if e.Context == nil {
|
||||
e.Context = make(map[string]any)
|
||||
}
|
||||
e.Context[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// WithFilePath adds file path information to the error.
|
||||
func (e *StructuredError) WithFilePath(filePath string) *StructuredError {
|
||||
e.FilePath = filePath
|
||||
return e
|
||||
}
|
||||
|
||||
// WithLine adds line number information to the error.
|
||||
func (e *StructuredError) WithLine(line int) *StructuredError {
|
||||
e.Line = line
|
||||
return e
|
||||
}
|
||||
|
||||
// NewStructuredError creates a new structured error.
|
||||
func NewStructuredError(
|
||||
errorType ErrorType,
|
||||
code, message, filePath string,
|
||||
context map[string]any,
|
||||
) *StructuredError {
|
||||
return &StructuredError{
|
||||
Type: errorType,
|
||||
Code: code,
|
||||
Message: message,
|
||||
FilePath: filePath,
|
||||
Context: context,
|
||||
}
|
||||
}
|
||||
|
||||
// NewStructuredErrorf creates a new structured error with formatted message.
|
||||
func NewStructuredErrorf(errorType ErrorType, code, format string, args ...any) *StructuredError {
|
||||
return &StructuredError{
|
||||
Type: errorType,
|
||||
Code: code,
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
}
|
||||
}
|
||||
|
||||
// WrapError wraps an existing error with structured error information.
|
||||
func WrapError(err error, errorType ErrorType, code, message string) *StructuredError {
|
||||
return &StructuredError{
|
||||
Type: errorType,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
// WrapErrorf wraps an existing error with formatted message.
|
||||
func WrapErrorf(err error, errorType ErrorType, code, format string, args ...any) *StructuredError {
|
||||
return &StructuredError{
|
||||
Type: errorType,
|
||||
Code: code,
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Common error codes for each type
|
||||
const (
|
||||
// CLI Error Codes
|
||||
|
||||
CodeCLIMissingSource = "MISSING_SOURCE"
|
||||
CodeCLIInvalidArgs = "INVALID_ARGS"
|
||||
|
||||
// FileSystem Error Codes
|
||||
|
||||
CodeFSPathResolution = "PATH_RESOLUTION"
|
||||
CodeFSPermission = "PERMISSION_DENIED"
|
||||
CodeFSNotFound = "NOT_FOUND"
|
||||
CodeFSAccess = "ACCESS_DENIED"
|
||||
|
||||
// Processing Error Codes
|
||||
|
||||
CodeProcessingFileRead = "FILE_READ"
|
||||
CodeProcessingCollection = "COLLECTION"
|
||||
CodeProcessingTraversal = "TRAVERSAL"
|
||||
CodeProcessingEncode = "ENCODE"
|
||||
|
||||
// Configuration Error Codes
|
||||
|
||||
CodeConfigValidation = "VALIDATION"
|
||||
CodeConfigMissing = "MISSING"
|
||||
|
||||
// IO Error Codes
|
||||
|
||||
CodeIOFileCreate = "FILE_CREATE"
|
||||
CodeIOFileWrite = "FILE_WRITE"
|
||||
CodeIOEncoding = "ENCODING"
|
||||
CodeIOWrite = "WRITE"
|
||||
CodeIOFileRead = "FILE_READ"
|
||||
CodeIOClose = "CLOSE"
|
||||
|
||||
// Validation Error Codes
|
||||
|
||||
CodeValidationFormat = "FORMAT"
|
||||
CodeValidationFileType = "FILE_TYPE"
|
||||
CodeValidationSize = "SIZE_LIMIT"
|
||||
CodeValidationRequired = "REQUIRED"
|
||||
CodeValidationPath = "PATH_TRAVERSAL"
|
||||
|
||||
// Resource Limit Error Codes
|
||||
|
||||
CodeResourceLimitFiles = "FILE_COUNT_LIMIT"
|
||||
CodeResourceLimitTotalSize = "TOTAL_SIZE_LIMIT"
|
||||
CodeResourceLimitTimeout = "TIMEOUT"
|
||||
CodeResourceLimitMemory = "MEMORY_LIMIT"
|
||||
CodeResourceLimitConcurrency = "CONCURRENCY_LIMIT"
|
||||
CodeResourceLimitRate = "RATE_LIMIT"
|
||||
)
|
||||
|
||||
// Predefined error constructors for common error scenarios
|
||||
|
||||
// NewMissingSourceError creates a CLI error for missing source argument.
|
||||
func NewMissingSourceError() *StructuredError {
|
||||
return NewStructuredError(
|
||||
ErrorTypeCLI,
|
||||
CodeCLIMissingSource,
|
||||
"usage: gibidify -source <source_directory> "+
|
||||
"[--destination <output_file>] [--format=json|yaml|markdown]",
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// NewFileSystemError creates a file system error.
|
||||
func NewFileSystemError(code, message string) *StructuredError {
|
||||
return NewStructuredError(ErrorTypeFileSystem, code, message, "", nil)
|
||||
}
|
||||
|
||||
// NewProcessingError creates a processing error.
|
||||
func NewProcessingError(code, message string) *StructuredError {
|
||||
return NewStructuredError(ErrorTypeProcessing, code, message, "", nil)
|
||||
}
|
||||
|
||||
// NewIOError creates an IO error.
|
||||
func NewIOError(code, message string) *StructuredError {
|
||||
return NewStructuredError(ErrorTypeIO, code, message, "", nil)
|
||||
}
|
||||
|
||||
// NewValidationError creates a validation error.
|
||||
func NewValidationError(code, message string) *StructuredError {
|
||||
return NewStructuredError(ErrorTypeValidation, code, message, "", nil)
|
||||
}
|
||||
|
||||
// LogError logs an error with a consistent format if the error is not nil.
|
||||
// The operation parameter describes what was being attempted.
|
||||
// Additional context can be provided via the args parameter.
|
||||
func LogError(operation string, err error, args ...any) {
|
||||
if err != nil {
|
||||
msg := operation
|
||||
if len(args) > 0 {
|
||||
// Format the operation string with the provided arguments
|
||||
msg = fmt.Sprintf(operation, args...)
|
||||
}
|
||||
|
||||
// Check if it's a structured error and log with additional context
|
||||
var structErr *StructuredError
|
||||
if errors.As(err, &structErr) {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"error_type": structErr.Type.String(),
|
||||
"error_code": structErr.Code,
|
||||
"context": structErr.Context,
|
||||
"file_path": structErr.FilePath,
|
||||
"line": structErr.Line,
|
||||
}).Errorf(errorFormatWithCause, msg, err)
|
||||
} else {
|
||||
// Log regular errors without structured fields
|
||||
logrus.Errorf(errorFormatWithCause, msg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LogErrorf logs an error with a formatted message if the error is not nil.
|
||||
// This is a convenience wrapper around LogError for cases where formatting is needed.
|
||||
func LogErrorf(err error, format string, args ...any) {
|
||||
if err != nil {
|
||||
LogError(format, err, args...)
|
||||
}
|
||||
}
|
||||
367
gibidiutils/errors_additional_test.go
Normal file
367
gibidiutils/errors_additional_test.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package gibidiutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestErrorTypeString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errType ErrorType
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "CLI error type",
|
||||
errType: ErrorTypeCLI,
|
||||
expected: "CLI",
|
||||
},
|
||||
{
|
||||
name: "FileSystem error type",
|
||||
errType: ErrorTypeFileSystem,
|
||||
expected: "FileSystem",
|
||||
},
|
||||
{
|
||||
name: "Processing error type",
|
||||
errType: ErrorTypeProcessing,
|
||||
expected: "Processing",
|
||||
},
|
||||
{
|
||||
name: "Configuration error type",
|
||||
errType: ErrorTypeConfiguration,
|
||||
expected: "Configuration",
|
||||
},
|
||||
{
|
||||
name: "IO error type",
|
||||
errType: ErrorTypeIO,
|
||||
expected: "IO",
|
||||
},
|
||||
{
|
||||
name: "Validation error type",
|
||||
errType: ErrorTypeValidation,
|
||||
expected: "Validation",
|
||||
},
|
||||
{
|
||||
name: "Unknown error type",
|
||||
errType: ErrorTypeUnknown,
|
||||
expected: "Unknown",
|
||||
},
|
||||
{
|
||||
name: "Invalid error type",
|
||||
errType: ErrorType(999),
|
||||
expected: "Unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.errType.String()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredErrorMethods(t *testing.T) {
|
||||
t.Run("Error method", func(t *testing.T) {
|
||||
err := &StructuredError{
|
||||
Type: ErrorTypeValidation,
|
||||
Code: CodeValidationRequired,
|
||||
Message: "field is required",
|
||||
}
|
||||
expected := "Validation [REQUIRED]: field is required"
|
||||
assert.Equal(t, expected, err.Error())
|
||||
})
|
||||
|
||||
t.Run("Error method with context", func(t *testing.T) {
|
||||
err := &StructuredError{
|
||||
Type: ErrorTypeFileSystem,
|
||||
Code: CodeFSNotFound,
|
||||
Message: testErrFileNotFound,
|
||||
Context: map[string]interface{}{
|
||||
"path": "/test/file.txt",
|
||||
},
|
||||
}
|
||||
errStr := err.Error()
|
||||
assert.Contains(t, errStr, "FileSystem")
|
||||
assert.Contains(t, errStr, "NOT_FOUND")
|
||||
assert.Contains(t, errStr, testErrFileNotFound)
|
||||
assert.Contains(t, errStr, "/test/file.txt")
|
||||
assert.Contains(t, errStr, "path")
|
||||
})
|
||||
|
||||
t.Run("Unwrap method", func(t *testing.T) {
|
||||
innerErr := errors.New("inner error")
|
||||
err := &StructuredError{
|
||||
Type: ErrorTypeIO,
|
||||
Code: CodeIOFileWrite,
|
||||
Message: testErrWriteFailed,
|
||||
Cause: innerErr,
|
||||
}
|
||||
assert.Equal(t, innerErr, err.Unwrap())
|
||||
})
|
||||
|
||||
t.Run("Unwrap with nil cause", func(t *testing.T) {
|
||||
err := &StructuredError{
|
||||
Type: ErrorTypeIO,
|
||||
Code: CodeIOFileWrite,
|
||||
Message: testErrWriteFailed,
|
||||
}
|
||||
assert.Nil(t, err.Unwrap())
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithContextMethods(t *testing.T) {
|
||||
t.Run("WithContext", func(t *testing.T) {
|
||||
err := &StructuredError{
|
||||
Type: ErrorTypeValidation,
|
||||
Code: CodeValidationFormat,
|
||||
Message: testErrInvalidFormat,
|
||||
}
|
||||
|
||||
err = err.WithContext("format", "xml")
|
||||
err = err.WithContext("expected", "json")
|
||||
|
||||
assert.NotNil(t, err.Context)
|
||||
assert.Equal(t, "xml", err.Context["format"])
|
||||
assert.Equal(t, "json", err.Context["expected"])
|
||||
})
|
||||
|
||||
t.Run("WithFilePath", func(t *testing.T) {
|
||||
err := &StructuredError{
|
||||
Type: ErrorTypeFileSystem,
|
||||
Code: CodeFSPermission,
|
||||
Message: "permission denied",
|
||||
}
|
||||
|
||||
err = err.WithFilePath("/etc/passwd")
|
||||
|
||||
assert.Equal(t, "/etc/passwd", err.FilePath)
|
||||
})
|
||||
|
||||
t.Run("WithLine", func(t *testing.T) {
|
||||
err := &StructuredError{
|
||||
Type: ErrorTypeProcessing,
|
||||
Code: CodeProcessingFileRead,
|
||||
Message: "read error",
|
||||
}
|
||||
|
||||
err = err.WithLine(42)
|
||||
|
||||
assert.Equal(t, 42, err.Line)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewStructuredError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errType ErrorType
|
||||
code string
|
||||
message string
|
||||
filePath string
|
||||
context map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "basic error",
|
||||
errType: ErrorTypeValidation,
|
||||
code: CodeValidationRequired,
|
||||
message: "field is required",
|
||||
filePath: "",
|
||||
context: nil,
|
||||
},
|
||||
{
|
||||
name: "error with file path",
|
||||
errType: ErrorTypeFileSystem,
|
||||
code: CodeFSNotFound,
|
||||
message: testErrFileNotFound,
|
||||
filePath: "/test/missing.txt",
|
||||
context: nil,
|
||||
},
|
||||
{
|
||||
name: "error with context",
|
||||
errType: ErrorTypeIO,
|
||||
code: CodeIOFileWrite,
|
||||
message: testErrWriteFailed,
|
||||
context: map[string]interface{}{
|
||||
"size": 1024,
|
||||
"error": "disk full",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewStructuredError(tt.errType, tt.code, tt.message, tt.filePath, tt.context)
|
||||
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, tt.errType, err.Type)
|
||||
assert.Equal(t, tt.code, err.Code)
|
||||
assert.Equal(t, tt.message, err.Message)
|
||||
assert.Equal(t, tt.filePath, err.FilePath)
|
||||
assert.Equal(t, tt.context, err.Context)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStructuredErrorf(t *testing.T) {
|
||||
err := NewStructuredErrorf(
|
||||
ErrorTypeValidation,
|
||||
CodeValidationSize,
|
||||
"file size %d exceeds limit %d",
|
||||
2048, 1024,
|
||||
)
|
||||
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrorTypeValidation, err.Type)
|
||||
assert.Equal(t, CodeValidationSize, err.Code)
|
||||
assert.Equal(t, "file size 2048 exceeds limit 1024", err.Message)
|
||||
}
|
||||
|
||||
func TestWrapError(t *testing.T) {
|
||||
innerErr := errors.New("original error")
|
||||
wrappedErr := WrapError(
|
||||
innerErr,
|
||||
ErrorTypeProcessing,
|
||||
CodeProcessingFileRead,
|
||||
"failed to process file",
|
||||
)
|
||||
|
||||
assert.NotNil(t, wrappedErr)
|
||||
assert.Equal(t, ErrorTypeProcessing, wrappedErr.Type)
|
||||
assert.Equal(t, CodeProcessingFileRead, wrappedErr.Code)
|
||||
assert.Equal(t, "failed to process file", wrappedErr.Message)
|
||||
assert.Equal(t, innerErr, wrappedErr.Cause)
|
||||
}
|
||||
|
||||
func TestWrapErrorf(t *testing.T) {
|
||||
innerErr := errors.New("original error")
|
||||
wrappedErr := WrapErrorf(
|
||||
innerErr,
|
||||
ErrorTypeIO,
|
||||
CodeIOFileCreate,
|
||||
"failed to create %s in %s",
|
||||
"output.txt", "/tmp",
|
||||
)
|
||||
|
||||
assert.NotNil(t, wrappedErr)
|
||||
assert.Equal(t, ErrorTypeIO, wrappedErr.Type)
|
||||
assert.Equal(t, CodeIOFileCreate, wrappedErr.Code)
|
||||
assert.Equal(t, "failed to create output.txt in /tmp", wrappedErr.Message)
|
||||
assert.Equal(t, innerErr, wrappedErr.Cause)
|
||||
}
|
||||
|
||||
func TestSpecificErrorConstructors(t *testing.T) {
|
||||
t.Run("NewMissingSourceError", func(t *testing.T) {
|
||||
err := NewMissingSourceError()
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrorTypeCLI, err.Type)
|
||||
assert.Equal(t, CodeCLIMissingSource, err.Code)
|
||||
assert.Contains(t, err.Message, "source")
|
||||
})
|
||||
|
||||
t.Run("NewFileSystemError", func(t *testing.T) {
|
||||
err := NewFileSystemError(CodeFSPermission, "access denied")
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrorTypeFileSystem, err.Type)
|
||||
assert.Equal(t, CodeFSPermission, err.Code)
|
||||
assert.Equal(t, "access denied", err.Message)
|
||||
})
|
||||
|
||||
t.Run("NewProcessingError", func(t *testing.T) {
|
||||
err := NewProcessingError(CodeProcessingCollection, "collection failed")
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrorTypeProcessing, err.Type)
|
||||
assert.Equal(t, CodeProcessingCollection, err.Code)
|
||||
assert.Equal(t, "collection failed", err.Message)
|
||||
})
|
||||
|
||||
t.Run("NewIOError", func(t *testing.T) {
|
||||
err := NewIOError(CodeIOFileWrite, testErrWriteFailed)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrorTypeIO, err.Type)
|
||||
assert.Equal(t, CodeIOFileWrite, err.Code)
|
||||
assert.Equal(t, testErrWriteFailed, err.Message)
|
||||
})
|
||||
|
||||
t.Run("NewValidationError", func(t *testing.T) {
|
||||
err := NewValidationError(CodeValidationFormat, testErrInvalidFormat)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrorTypeValidation, err.Type)
|
||||
assert.Equal(t, CodeValidationFormat, err.Code)
|
||||
assert.Equal(t, testErrInvalidFormat, err.Message)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLogErrorf is already covered in errors_test.go
|
||||
|
||||
func TestStructuredErrorChaining(t *testing.T) {
|
||||
// Test method chaining
|
||||
err := NewStructuredError(
|
||||
ErrorTypeFileSystem,
|
||||
CodeFSNotFound,
|
||||
testErrFileNotFound,
|
||||
"",
|
||||
nil,
|
||||
).WithFilePath("/test.txt").WithLine(10).WithContext("operation", "read")
|
||||
|
||||
assert.Equal(t, "/test.txt", err.FilePath)
|
||||
assert.Equal(t, 10, err.Line)
|
||||
assert.Equal(t, "read", err.Context["operation"])
|
||||
}
|
||||
|
||||
func TestErrorCodes(t *testing.T) {
|
||||
// Test that all error codes are defined
|
||||
codes := []string{
|
||||
CodeCLIMissingSource,
|
||||
CodeCLIInvalidArgs,
|
||||
CodeFSPathResolution,
|
||||
CodeFSPermission,
|
||||
CodeFSNotFound,
|
||||
CodeFSAccess,
|
||||
CodeProcessingFileRead,
|
||||
CodeProcessingCollection,
|
||||
CodeProcessingTraversal,
|
||||
CodeProcessingEncode,
|
||||
CodeConfigValidation,
|
||||
CodeConfigMissing,
|
||||
CodeIOFileCreate,
|
||||
CodeIOFileWrite,
|
||||
CodeIOEncoding,
|
||||
CodeIOWrite,
|
||||
CodeIOFileRead,
|
||||
CodeIOClose,
|
||||
CodeValidationRequired,
|
||||
CodeValidationFormat,
|
||||
CodeValidationSize,
|
||||
CodeValidationPath,
|
||||
CodeResourceLimitFiles,
|
||||
CodeResourceLimitTotalSize,
|
||||
CodeResourceLimitMemory,
|
||||
CodeResourceLimitTimeout,
|
||||
}
|
||||
|
||||
// All codes should be non-empty strings
|
||||
for _, code := range codes {
|
||||
assert.NotEmpty(t, code, "Error code should not be empty")
|
||||
assert.NotEqual(t, "", code, "Error code should be defined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorUnwrapChain(t *testing.T) {
|
||||
// Test unwrapping through multiple levels
|
||||
innermost := errors.New("innermost error")
|
||||
middle := WrapError(innermost, ErrorTypeIO, CodeIOFileRead, "read failed")
|
||||
outer := WrapError(middle, ErrorTypeProcessing, CodeProcessingFileRead, "processing failed")
|
||||
|
||||
// Test unwrapping
|
||||
assert.Equal(t, middle, outer.Unwrap())
|
||||
assert.Equal(t, innermost, middle.Unwrap())
|
||||
|
||||
// innermost is a plain error, doesn't have Unwrap() method
|
||||
// No need to test it
|
||||
|
||||
// Test error chain messages
|
||||
assert.Contains(t, outer.Error(), "Processing")
|
||||
assert.Contains(t, middle.Error(), "IO")
|
||||
}
|
||||
243
gibidiutils/errors_test.go
Normal file
243
gibidiutils/errors_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Package gibidiutils provides common utility functions for gibidify.
|
||||
package gibidiutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// captureLogOutput captures logrus output for testing
|
||||
func captureLogOutput(f func()) string {
|
||||
var buf bytes.Buffer
|
||||
logrus.SetOutput(&buf)
|
||||
defer logrus.SetOutput(logrus.StandardLogger().Out)
|
||||
f()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestLogError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
err error
|
||||
args []any
|
||||
wantLog string
|
||||
wantEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "nil error should not log",
|
||||
operation: "test operation",
|
||||
err: nil,
|
||||
args: nil,
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "basic error logging",
|
||||
operation: "failed to read file",
|
||||
err: errors.New("permission denied"),
|
||||
args: nil,
|
||||
wantLog: "failed to read file: permission denied",
|
||||
},
|
||||
{
|
||||
name: "error with formatting args",
|
||||
operation: "failed to process file %s",
|
||||
err: errors.New("file too large"),
|
||||
args: []any{"test.txt"},
|
||||
wantLog: "failed to process file test.txt: file too large",
|
||||
},
|
||||
{
|
||||
name: "error with multiple formatting args",
|
||||
operation: "failed to copy from %s to %s",
|
||||
err: errors.New("disk full"),
|
||||
args: []any{"source.txt", "dest.txt"},
|
||||
wantLog: "failed to copy from source.txt to dest.txt: disk full",
|
||||
},
|
||||
{
|
||||
name: "wrapped error",
|
||||
operation: "database operation failed",
|
||||
err: fmt.Errorf("connection error: %w", errors.New("timeout")),
|
||||
args: nil,
|
||||
wantLog: "database operation failed: connection error: timeout",
|
||||
},
|
||||
{
|
||||
name: "empty operation string",
|
||||
operation: "",
|
||||
err: errors.New("some error"),
|
||||
args: nil,
|
||||
wantLog: ": some error",
|
||||
},
|
||||
{
|
||||
name: "operation with percentage sign",
|
||||
operation: "processing 50% complete",
|
||||
err: errors.New("interrupted"),
|
||||
args: nil,
|
||||
wantLog: "processing 50% complete: interrupted",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := captureLogOutput(func() {
|
||||
LogError(tt.operation, tt.err, tt.args...)
|
||||
})
|
||||
|
||||
if tt.wantEmpty {
|
||||
if output != "" {
|
||||
t.Errorf("LogError() logged output when error was nil: %q", output)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(output, tt.wantLog) {
|
||||
t.Errorf("LogError() output = %q, want to contain %q", output, tt.wantLog)
|
||||
}
|
||||
|
||||
// Verify it's logged at ERROR level
|
||||
if !strings.Contains(output, "level=error") {
|
||||
t.Errorf("LogError() should log at ERROR level, got: %q", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogErrorf(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
format string
|
||||
args []any
|
||||
wantLog string
|
||||
wantEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "nil error should not log",
|
||||
err: nil,
|
||||
format: "operation %s failed",
|
||||
args: []any{"test"},
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "basic formatted error",
|
||||
err: errors.New("not found"),
|
||||
format: "file %s not found",
|
||||
args: []any{"config.yaml"},
|
||||
wantLog: "file config.yaml not found: not found",
|
||||
},
|
||||
{
|
||||
name: "multiple format arguments",
|
||||
err: errors.New("invalid range"),
|
||||
format: "value %d is not between %d and %d",
|
||||
args: []any{150, 0, 100},
|
||||
wantLog: "value 150 is not between 0 and 100: invalid range",
|
||||
},
|
||||
{
|
||||
name: "no format arguments",
|
||||
err: errors.New("generic error"),
|
||||
format: "operation failed",
|
||||
args: nil,
|
||||
wantLog: "operation failed: generic error",
|
||||
},
|
||||
{
|
||||
name: "format with different types",
|
||||
err: errors.New("type mismatch"),
|
||||
format: "expected %s but got %d",
|
||||
args: []any{"string", 42},
|
||||
wantLog: "expected string but got 42: type mismatch",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := captureLogOutput(func() {
|
||||
LogErrorf(tt.err, tt.format, tt.args...)
|
||||
})
|
||||
|
||||
if tt.wantEmpty {
|
||||
if output != "" {
|
||||
t.Errorf("LogErrorf() logged output when error was nil: %q", output)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(output, tt.wantLog) {
|
||||
t.Errorf("LogErrorf() output = %q, want to contain %q", output, tt.wantLog)
|
||||
}
|
||||
|
||||
// Verify it's logged at ERROR level
|
||||
if !strings.Contains(output, "level=error") {
|
||||
t.Errorf("LogErrorf() should log at ERROR level, got: %q", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogErrorConcurrency(_ *testing.T) {
|
||||
// Test that LogError is safe for concurrent use
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(n int) {
|
||||
LogError("concurrent operation", fmt.Errorf("error %d", n))
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogErrorfConcurrency(_ *testing.T) {
|
||||
// Test that LogErrorf is safe for concurrent use
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(n int) {
|
||||
LogErrorf(fmt.Errorf("error %d", n), "concurrent operation %d", n)
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLogError benchmarks the LogError function
|
||||
func BenchmarkLogError(b *testing.B) {
|
||||
err := errors.New("benchmark error")
|
||||
// Disable output during benchmark
|
||||
logrus.SetOutput(bytes.NewBuffer(nil))
|
||||
defer logrus.SetOutput(logrus.StandardLogger().Out)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
LogError("benchmark operation", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLogErrorf benchmarks the LogErrorf function
|
||||
func BenchmarkLogErrorf(b *testing.B) {
|
||||
err := errors.New("benchmark error")
|
||||
// Disable output during benchmark
|
||||
logrus.SetOutput(bytes.NewBuffer(nil))
|
||||
defer logrus.SetOutput(logrus.StandardLogger().Out)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
LogErrorf(err, "benchmark operation %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLogErrorNil benchmarks LogError with nil error (no-op case)
|
||||
func BenchmarkLogErrorNil(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
LogError("benchmark operation", nil)
|
||||
}
|
||||
}
|
||||
10
gibidiutils/icons.go
Normal file
10
gibidiutils/icons.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package gibidiutils
|
||||
|
||||
// Unicode icons and symbols for CLI UI and test output.
|
||||
const (
|
||||
IconSuccess = "✓" // U+2713
|
||||
IconError = "✗" // U+2717
|
||||
IconWarning = "⚠" // U+26A0
|
||||
IconBullet = "•" // U+2022
|
||||
IconInfo = "ℹ️" // U+2139 FE0F
|
||||
)
|
||||
311
gibidiutils/paths.go
Normal file
311
gibidiutils/paths.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Package gibidiutils provides common utility functions for gibidify.
|
||||
package gibidiutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EscapeForMarkdown sanitizes a string for safe use in Markdown code-fence and header lines.
|
||||
// It replaces backticks with backslash-escaped backticks and removes/collapses newlines.
|
||||
func EscapeForMarkdown(s string) string {
|
||||
// Escape backticks
|
||||
safe := strings.ReplaceAll(s, "`", "\\`")
|
||||
// Remove newlines (collapse to space)
|
||||
safe = strings.ReplaceAll(safe, "\n", " ")
|
||||
safe = strings.ReplaceAll(safe, "\r", " ")
|
||||
return safe
|
||||
}
|
||||
|
||||
// GetAbsolutePath returns the absolute path for the given path.
|
||||
// It wraps filepath.Abs with consistent error handling.
|
||||
func GetAbsolutePath(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute path for %s: %w", path, err)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// GetBaseName returns the base name for the given path, handling special cases.
|
||||
func GetBaseName(absPath string) string {
|
||||
baseName := filepath.Base(absPath)
|
||||
if baseName == "." || baseName == "" {
|
||||
return "output"
|
||||
}
|
||||
return baseName
|
||||
}
|
||||
|
||||
// checkPathTraversal checks for path traversal patterns and returns an error if found.
|
||||
func checkPathTraversal(path, context string) error {
|
||||
// Normalize separators without cleaning (to preserve ..)
|
||||
normalized := filepath.ToSlash(path)
|
||||
|
||||
// Split into components
|
||||
components := strings.Split(normalized, "/")
|
||||
|
||||
// Check each component for exact ".." match
|
||||
for _, component := range components {
|
||||
if component == ".." {
|
||||
return NewStructuredError(
|
||||
ErrorTypeValidation,
|
||||
CodeValidationPath,
|
||||
fmt.Sprintf("path traversal attempt detected in %s", context),
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"original_path": path,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanAndResolveAbsPath cleans a path and resolves it to an absolute path.
|
||||
func cleanAndResolveAbsPath(path, context string) (string, error) {
|
||||
cleaned := filepath.Clean(path)
|
||||
abs, err := filepath.Abs(cleaned)
|
||||
if err != nil {
|
||||
return "", NewStructuredError(
|
||||
ErrorTypeFileSystem,
|
||||
CodeFSPathResolution,
|
||||
fmt.Sprintf("cannot resolve %s", context),
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// evalSymlinksOrStructuredError wraps filepath.EvalSymlinks with structured error handling.
|
||||
func evalSymlinksOrStructuredError(path, context, original string) (string, error) {
|
||||
eval, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return "", NewStructuredError(
|
||||
ErrorTypeValidation,
|
||||
CodeValidationPath,
|
||||
fmt.Sprintf("cannot resolve symlinks for %s", context),
|
||||
original,
|
||||
map[string]interface{}{
|
||||
"resolved_path": path,
|
||||
"context": context,
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
return eval, nil
|
||||
}
|
||||
|
||||
// validateWorkingDirectoryBoundary checks if the given absolute path escapes the working directory.
|
||||
func validateWorkingDirectoryBoundary(abs, path string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return NewStructuredError(
|
||||
ErrorTypeFileSystem,
|
||||
CodeFSPathResolution,
|
||||
"cannot get current working directory",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
cwdAbs, err := filepath.Abs(cwd)
|
||||
if err != nil {
|
||||
return NewStructuredError(
|
||||
ErrorTypeFileSystem,
|
||||
CodeFSPathResolution,
|
||||
"cannot resolve current working directory",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
absEval, err := evalSymlinksOrStructuredError(abs, "source path", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cwdEval, err := evalSymlinksOrStructuredError(cwdAbs, "working directory", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(cwdEval, absEval)
|
||||
if err != nil {
|
||||
return NewStructuredError(
|
||||
ErrorTypeValidation,
|
||||
CodeValidationPath,
|
||||
"cannot determine relative path",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"resolved_path": absEval,
|
||||
"working_dir": cwdEval,
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return NewStructuredError(
|
||||
ErrorTypeValidation,
|
||||
CodeValidationPath,
|
||||
"source path attempts to access directories outside current working directory",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"resolved_path": absEval,
|
||||
"working_dir": cwdEval,
|
||||
"relative_path": rel,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSourcePath validates a source directory path for security.
|
||||
// It ensures the path exists, is a directory, and doesn't contain path traversal attempts.
|
||||
//
|
||||
//revive:disable-next-line:function-length
|
||||
func ValidateSourcePath(path string) error {
|
||||
if path == "" {
|
||||
return NewValidationError(CodeValidationRequired, "source path is required")
|
||||
}
|
||||
|
||||
// Check for path traversal patterns before cleaning
|
||||
if err := checkPathTraversal(path, "source path"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean and get absolute path
|
||||
abs, err := cleanAndResolveAbsPath(path, "source path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
|
||||
// Ensure the resolved path is within or below the current working directory for relative paths
|
||||
if !filepath.IsAbs(path) {
|
||||
if err := validateWorkingDirectoryBoundary(abs, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
info, err := os.Stat(cleaned)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewFileSystemError(CodeFSNotFound, "source directory does not exist").WithFilePath(path)
|
||||
}
|
||||
return NewStructuredError(
|
||||
ErrorTypeFileSystem,
|
||||
CodeFSAccess,
|
||||
"cannot access source directory",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return NewStructuredError(
|
||||
ErrorTypeValidation,
|
||||
CodeValidationPath,
|
||||
"source path must be a directory",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"is_file": true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDestinationPath validates a destination file path for security.
|
||||
// It ensures the path doesn't contain path traversal attempts and the parent directory exists.
|
||||
func ValidateDestinationPath(path string) error {
|
||||
if path == "" {
|
||||
return NewValidationError(CodeValidationRequired, "destination path is required")
|
||||
}
|
||||
|
||||
// Check for path traversal patterns before cleaning
|
||||
if err := checkPathTraversal(path, "destination path"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get absolute path to ensure it's not trying to escape current working directory
|
||||
abs, err := cleanAndResolveAbsPath(path, "destination path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure the destination is not a directory
|
||||
if info, err := os.Stat(abs); err == nil && info.IsDir() {
|
||||
return NewStructuredError(
|
||||
ErrorTypeValidation,
|
||||
CodeValidationPath,
|
||||
"destination cannot be a directory",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"is_directory": true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Check if parent directory exists and is writable
|
||||
parentDir := filepath.Dir(abs)
|
||||
if parentInfo, err := os.Stat(parentDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewStructuredError(
|
||||
ErrorTypeFileSystem,
|
||||
CodeFSNotFound,
|
||||
"destination parent directory does not exist",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"parent_dir": parentDir,
|
||||
},
|
||||
)
|
||||
}
|
||||
return NewStructuredError(
|
||||
ErrorTypeFileSystem,
|
||||
CodeFSAccess,
|
||||
"cannot access destination parent directory",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"parent_dir": parentDir,
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
} else if !parentInfo.IsDir() {
|
||||
return NewStructuredError(
|
||||
ErrorTypeValidation,
|
||||
CodeValidationPath,
|
||||
"destination parent is not a directory",
|
||||
path,
|
||||
map[string]interface{}{
|
||||
"parent_dir": parentDir,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfigPath validates a configuration file path for security.
|
||||
// It ensures the path doesn't contain path traversal attempts.
|
||||
func ValidateConfigPath(path string) error {
|
||||
if path == "" {
|
||||
return nil // Empty path is allowed for config
|
||||
}
|
||||
|
||||
// Check for path traversal patterns before cleaning
|
||||
return checkPathTraversal(path, "config path")
|
||||
}
|
||||
368
gibidiutils/paths_additional_test.go
Normal file
368
gibidiutils/paths_additional_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package gibidiutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetBaseName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
absPath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "normal path",
|
||||
absPath: "/home/user/project",
|
||||
expected: "project",
|
||||
},
|
||||
{
|
||||
name: "path with trailing slash",
|
||||
absPath: "/home/user/project/",
|
||||
expected: "project",
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
absPath: "/",
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "current directory",
|
||||
absPath: ".",
|
||||
expected: "output",
|
||||
},
|
||||
{
|
||||
name: testEmptyPath,
|
||||
absPath: "",
|
||||
expected: "output",
|
||||
},
|
||||
{
|
||||
name: "file path",
|
||||
absPath: "/home/user/file.txt",
|
||||
expected: "file.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := GetBaseName(tt.absPath)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSourcePath(t *testing.T) {
|
||||
// Create a temp directory for testing
|
||||
tempDir := t.TempDir()
|
||||
tempFile := filepath.Join(tempDir, "test.txt")
|
||||
require.NoError(t, os.WriteFile(tempFile, []byte("test"), 0o600))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: testEmptyPath,
|
||||
path: "",
|
||||
expectedError: "source path is required",
|
||||
},
|
||||
{
|
||||
name: testPathTraversalAttempt,
|
||||
path: "../../../etc/passwd",
|
||||
expectedError: testPathTraversalDetected,
|
||||
},
|
||||
{
|
||||
name: "path with double dots",
|
||||
path: "/home/../etc/passwd",
|
||||
expectedError: testPathTraversalDetected,
|
||||
},
|
||||
{
|
||||
name: "non-existent path",
|
||||
path: "/definitely/does/not/exist",
|
||||
expectedError: "does not exist",
|
||||
},
|
||||
{
|
||||
name: "file instead of directory",
|
||||
path: tempFile,
|
||||
expectedError: "must be a directory",
|
||||
},
|
||||
{
|
||||
name: "valid directory",
|
||||
path: tempDir,
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "valid relative path",
|
||||
path: ".",
|
||||
expectedError: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateSourcePath(tt.path)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
|
||||
// Check if it's a StructuredError
|
||||
var structErr *StructuredError
|
||||
if errors.As(err, &structErr) {
|
||||
assert.NotEmpty(t, structErr.Code)
|
||||
assert.NotEqual(t, ErrorTypeUnknown, structErr.Type)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDestinationPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: testEmptyPath,
|
||||
path: "",
|
||||
expectedError: "destination path is required",
|
||||
},
|
||||
{
|
||||
name: testPathTraversalAttempt,
|
||||
path: "../../etc/passwd",
|
||||
expectedError: testPathTraversalDetected,
|
||||
},
|
||||
{
|
||||
name: "absolute path traversal",
|
||||
path: "/home/../../../etc/passwd",
|
||||
expectedError: testPathTraversalDetected,
|
||||
},
|
||||
{
|
||||
name: "valid new file",
|
||||
path: filepath.Join(tempDir, "newfile.txt"),
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "valid relative path",
|
||||
path: "output.txt",
|
||||
expectedError: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateDestinationPath(tt.path)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
validConfig := filepath.Join(tempDir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(validConfig, []byte("key: value"), 0o600))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: testEmptyPath,
|
||||
path: "",
|
||||
expectedError: "", // Empty config path is allowed
|
||||
},
|
||||
{
|
||||
name: testPathTraversalAttempt,
|
||||
path: "../../../etc/config.yaml",
|
||||
expectedError: testPathTraversalDetected,
|
||||
},
|
||||
// ValidateConfigPath doesn't check if file exists or is regular file
|
||||
// It only checks for path traversal
|
||||
{
|
||||
name: "valid config file",
|
||||
path: validConfig,
|
||||
expectedError: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateConfigPath(tt.path)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAbsolutePath is already covered in paths_test.go
|
||||
|
||||
func TestValidationErrorTypes(t *testing.T) {
|
||||
t.Run("source path validation errors", func(t *testing.T) {
|
||||
// Test empty source
|
||||
err := ValidateSourcePath("")
|
||||
assert.Error(t, err)
|
||||
var structErrEmptyPath *StructuredError
|
||||
if errors.As(err, &structErrEmptyPath) {
|
||||
assert.Equal(t, ErrorTypeValidation, structErrEmptyPath.Type)
|
||||
assert.Equal(t, CodeValidationRequired, structErrEmptyPath.Code)
|
||||
}
|
||||
|
||||
// Test path traversal
|
||||
err = ValidateSourcePath("../../../etc")
|
||||
assert.Error(t, err)
|
||||
var structErrTraversal *StructuredError
|
||||
if errors.As(err, &structErrTraversal) {
|
||||
assert.Equal(t, ErrorTypeValidation, structErrTraversal.Type)
|
||||
assert.Equal(t, CodeValidationPath, structErrTraversal.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("destination path validation errors", func(t *testing.T) {
|
||||
// Test empty destination
|
||||
err := ValidateDestinationPath("")
|
||||
assert.Error(t, err)
|
||||
var structErrEmptyDest *StructuredError
|
||||
if errors.As(err, &structErrEmptyDest) {
|
||||
assert.Equal(t, ErrorTypeValidation, structErrEmptyDest.Type)
|
||||
assert.Equal(t, CodeValidationRequired, structErrEmptyDest.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config path validation errors", func(t *testing.T) {
|
||||
// Test path traversal in config
|
||||
err := ValidateConfigPath("../../etc/config.yaml")
|
||||
assert.Error(t, err)
|
||||
var structErrTraversalInConfig *StructuredError
|
||||
if errors.As(err, &structErrTraversalInConfig) {
|
||||
assert.Equal(t, ErrorTypeValidation, structErrTraversalInConfig.Type)
|
||||
assert.Equal(t, CodeValidationPath, structErrTraversalInConfig.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPathSecurityChecks(t *testing.T) {
|
||||
// Test various path traversal attempts
|
||||
traversalPaths := []string{
|
||||
"../etc/passwd",
|
||||
"../../root/.ssh/id_rsa",
|
||||
"/home/../../../etc/shadow",
|
||||
"./../../sensitive/data",
|
||||
"foo/../../../bar",
|
||||
}
|
||||
|
||||
for _, path := range traversalPaths {
|
||||
t.Run("source_"+path, func(t *testing.T) {
|
||||
err := ValidateSourcePath(path)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testPathTraversal)
|
||||
})
|
||||
|
||||
t.Run("dest_"+path, func(t *testing.T) {
|
||||
err := ValidateDestinationPath(path)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testPathTraversal)
|
||||
})
|
||||
|
||||
t.Run("config_"+path, func(t *testing.T) {
|
||||
err := ValidateConfigPath(path)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testPathTraversal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecialPaths(t *testing.T) {
|
||||
t.Run("GetBaseName with special paths", func(t *testing.T) {
|
||||
specialPaths := map[string]string{
|
||||
"/": "/",
|
||||
"": "output",
|
||||
".": "output",
|
||||
"..": "..",
|
||||
"/.": "output", // filepath.Base("/.") returns "." which matches the output condition
|
||||
"/..": "..",
|
||||
"//": "/",
|
||||
"///": "/",
|
||||
}
|
||||
|
||||
for path, expected := range specialPaths {
|
||||
result := GetBaseName(path)
|
||||
assert.Equal(t, expected, result, "Path: %s", path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPathNormalization(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("source path normalization", func(t *testing.T) {
|
||||
// Create nested directory
|
||||
nestedDir := filepath.Join(tempDir, "a", "b", "c")
|
||||
require.NoError(t, os.MkdirAll(nestedDir, 0o750))
|
||||
|
||||
// Test path with redundant separators
|
||||
redundantPath := tempDir + string(
|
||||
os.PathSeparator,
|
||||
) + string(
|
||||
os.PathSeparator,
|
||||
) + "a" + string(
|
||||
os.PathSeparator,
|
||||
) + "b" + string(
|
||||
os.PathSeparator,
|
||||
) + "c"
|
||||
err := ValidateSourcePath(redundantPath)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPathValidationConcurrency(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Test concurrent path validation
|
||||
paths := []string{
|
||||
tempDir,
|
||||
".",
|
||||
"/tmp",
|
||||
}
|
||||
|
||||
errChan := make(chan error, len(paths)*2)
|
||||
|
||||
for _, path := range paths {
|
||||
go func(p string) {
|
||||
errChan <- ValidateSourcePath(p)
|
||||
}(path)
|
||||
|
||||
go func(p string) {
|
||||
errChan <- ValidateDestinationPath(p + "/output.txt")
|
||||
}(path)
|
||||
}
|
||||
|
||||
// Collect results
|
||||
for i := 0; i < len(paths)*2; i++ {
|
||||
<-errChan
|
||||
}
|
||||
|
||||
// No assertions needed - test passes if no panic/race
|
||||
}
|
||||
264
gibidiutils/paths_test.go
Normal file
264
gibidiutils/paths_test.go
Normal file
@@ -0,0 +1,264 @@
|
||||
// Package gibidiutils provides common utility functions for gibidify.
|
||||
package gibidiutils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetAbsolutePath(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 := []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: "empty path",
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.skipWindows && runtime.GOOS == "windows" {
|
||||
t.Skip("Skipping test on Windows")
|
||||
}
|
||||
|
||||
got, err := GetAbsolutePath(tt.path)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("GetAbsolutePath() error = nil, wantErr %v", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErrMsg != "" && !strings.Contains(err.Error(), tt.wantErrMsg) {
|
||||
t.Errorf("GetAbsolutePath() error = %v, want error containing %v", err, tt.wantErrMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("GetAbsolutePath() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Clean the expected path for comparison
|
||||
wantClean := filepath.Clean(tt.wantPrefix)
|
||||
gotClean := filepath.Clean(got)
|
||||
|
||||
if gotClean != wantClean {
|
||||
t.Errorf("GetAbsolutePath() = %v, want %v", gotClean, wantClean)
|
||||
}
|
||||
|
||||
// Verify the result is actually absolute
|
||||
if !filepath.IsAbs(got) {
|
||||
t.Errorf("GetAbsolutePath() returned non-absolute path: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAbsolutePathSpecialCases(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Skipping Unix-specific tests on Windows")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func() (string, func())
|
||||
path string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "symlink to directory",
|
||||
setup: func() (string, func()) {
|
||||
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() {}
|
||||
},
|
||||
path: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "broken symlink",
|
||||
setup: func() (string, func()) {
|
||||
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() {}
|
||||
},
|
||||
path: "",
|
||||
wantErr: false, // filepath.Abs still works with broken symlinks
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path, cleanup := tt.setup()
|
||||
defer cleanup()
|
||||
|
||||
if tt.path == "" {
|
||||
tt.path = path
|
||||
}
|
||||
|
||||
got, err := GetAbsolutePath(tt.path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetAbsolutePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil && !filepath.IsAbs(got) {
|
||||
t.Errorf("GetAbsolutePath() returned non-absolute path: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAbsolutePathConcurrency verifies that GetAbsolutePath is safe for concurrent use.
|
||||
// The test intentionally does not use assertions - it will panic if there's a race condition.
|
||||
// Run with -race flag to detect concurrent access issues.
|
||||
func TestGetAbsolutePathConcurrency(_ *testing.T) {
|
||||
// Test that GetAbsolutePath 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) {
|
||||
_, _ = GetAbsolutePath(path)
|
||||
done <- true
|
||||
}(p)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for range paths {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAbsolutePathErrorFormatting(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 := GetAbsolutePath(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)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetAbsolutePath benchmarks the GetAbsolutePath function
|
||||
func BenchmarkGetAbsolutePath(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = GetAbsolutePath("test/path/file.go")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetAbsolutePathAbs benchmarks with already absolute path
|
||||
func BenchmarkGetAbsolutePathAbs(b *testing.B) {
|
||||
absPath := "/home/user/test/file.go"
|
||||
if runtime.GOOS == "windows" {
|
||||
absPath = "C:\\Users\\test\\file.go"
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = GetAbsolutePath(absPath)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetAbsolutePathCurrent benchmarks with current directory
|
||||
func BenchmarkGetAbsolutePathCurrent(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = GetAbsolutePath(".")
|
||||
}
|
||||
}
|
||||
18
gibidiutils/test_constants.go
Normal file
18
gibidiutils/test_constants.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package gibidiutils
|
||||
|
||||
// Test constants to avoid duplication in test files.
|
||||
// These constants are used across multiple test files in the gibidiutils package.
|
||||
const (
|
||||
// Error messages
|
||||
|
||||
testErrFileNotFound = "file not found"
|
||||
testErrWriteFailed = "write failed"
|
||||
testErrInvalidFormat = "invalid format"
|
||||
|
||||
// Path validation messages
|
||||
|
||||
testEmptyPath = "empty path"
|
||||
testPathTraversal = "path traversal"
|
||||
testPathTraversalAttempt = "path traversal attempt"
|
||||
testPathTraversalDetected = "path traversal attempt detected"
|
||||
)
|
||||
162
gibidiutils/writers.go
Normal file
162
gibidiutils/writers.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Package gibidiutils provides common utility functions for gibidify.
|
||||
package gibidiutils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SafeCloseReader safely closes a reader if it implements io.Closer.
|
||||
// This eliminates the duplicated closeReader methods across all writers.
|
||||
func SafeCloseReader(reader io.Reader, path string) {
|
||||
if closer, ok := reader.(io.Closer); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
LogError(
|
||||
"Failed to close file reader",
|
||||
WrapError(err, ErrorTypeIO, CodeIOClose, "failed to close file reader").WithFilePath(path),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WriteWithErrorWrap performs file writing with consistent error handling.
|
||||
// This centralizes the common pattern of writing strings with error wrapping.
|
||||
func WriteWithErrorWrap(writer io.Writer, content, errorMsg, filePath string) error {
|
||||
if _, err := writer.Write([]byte(content)); err != nil {
|
||||
wrappedErr := WrapError(err, ErrorTypeIO, CodeIOWrite, errorMsg)
|
||||
if filePath != "" {
|
||||
wrappedErr = wrappedErr.WithFilePath(filePath)
|
||||
}
|
||||
return wrappedErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamContent provides a common streaming implementation with chunk processing.
|
||||
// This eliminates the similar streaming patterns across JSON and Markdown writers.
|
||||
//
|
||||
//revive:disable-next-line:cognitive-complexity
|
||||
func StreamContent(
|
||||
reader io.Reader,
|
||||
writer io.Writer,
|
||||
chunkSize int,
|
||||
filePath string,
|
||||
processChunk func([]byte) []byte,
|
||||
) error {
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
processed := buf[:n]
|
||||
if processChunk != nil {
|
||||
processed = processChunk(processed)
|
||||
}
|
||||
if _, writeErr := writer.Write(processed); writeErr != nil {
|
||||
wrappedErr := WrapError(writeErr, ErrorTypeIO, CodeIOWrite, "failed to write content chunk")
|
||||
if filePath != "" {
|
||||
wrappedErr = wrappedErr.WithFilePath(filePath)
|
||||
}
|
||||
return wrappedErr
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
wrappedErr := WrapError(err, ErrorTypeIO, CodeIOFileRead, "failed to read content chunk")
|
||||
if filePath != "" {
|
||||
wrappedErr = wrappedErr.WithFilePath(filePath)
|
||||
}
|
||||
return wrappedErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EscapeForJSON escapes content for JSON output using the standard library.
|
||||
// This replaces the custom escapeJSONString function with a more robust implementation.
|
||||
func EscapeForJSON(content string) string {
|
||||
// Use the standard library's JSON marshaling for proper escaping
|
||||
jsonBytes, _ := json.Marshal(content)
|
||||
// Remove the surrounding quotes that json.Marshal adds
|
||||
jsonStr := string(jsonBytes)
|
||||
if len(jsonStr) >= 2 && jsonStr[0] == '"' && jsonStr[len(jsonStr)-1] == '"' {
|
||||
return jsonStr[1 : len(jsonStr)-1]
|
||||
}
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// EscapeForYAML quotes/escapes content for YAML output if needed.
|
||||
// This centralizes the YAML string quoting logic.
|
||||
func EscapeForYAML(content string) string {
|
||||
// Quote if contains special characters, spaces, or starts with special chars
|
||||
needsQuotes := strings.ContainsAny(content, " \t\n\r:{}[]|>-'\"\\") ||
|
||||
strings.HasPrefix(content, "-") ||
|
||||
strings.HasPrefix(content, "?") ||
|
||||
strings.HasPrefix(content, ":") ||
|
||||
content == "" ||
|
||||
content == "true" || content == "false" ||
|
||||
content == "null" || content == "~"
|
||||
|
||||
if needsQuotes {
|
||||
// Use double quotes and escape internal quotes
|
||||
escaped := strings.ReplaceAll(content, "\\", "\\\\")
|
||||
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
|
||||
return "\"" + escaped + "\""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// SafeUint64ToInt64WithDefault safely converts uint64 to int64, returning a default value if overflow would occur.
|
||||
// When defaultValue is 0 (the safe default), clamps to MaxInt64 on overflow to keep guardrails active.
|
||||
// This prevents overflow from making monitors think memory usage is zero when it's actually maxed out.
|
||||
func SafeUint64ToInt64WithDefault(value uint64, defaultValue int64) int64 {
|
||||
if value > math.MaxInt64 {
|
||||
// When caller uses 0 as "safe" default, clamp to max so overflow still trips guardrails
|
||||
if defaultValue == 0 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
return int64(value) //#nosec G115 -- Safe: value <= MaxInt64 checked above
|
||||
}
|
||||
|
||||
// StreamLines provides line-based streaming for YAML content.
|
||||
// This provides an alternative streaming approach for YAML writers.
|
||||
func StreamLines(reader io.Reader, writer io.Writer, filePath string, lineProcessor func(string) string) error {
|
||||
// Read all content first (for small files this is fine)
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
wrappedErr := WrapError(err, ErrorTypeIO, CodeIOFileRead, "failed to read content for line processing")
|
||||
if filePath != "" {
|
||||
wrappedErr = wrappedErr.WithFilePath(filePath)
|
||||
}
|
||||
return wrappedErr
|
||||
}
|
||||
|
||||
// Split into lines and process each
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for i, line := range lines {
|
||||
processedLine := line
|
||||
if lineProcessor != nil {
|
||||
processedLine = lineProcessor(line)
|
||||
}
|
||||
|
||||
// Write line with proper line ending (except for last empty line)
|
||||
lineToWrite := processedLine
|
||||
if i < len(lines)-1 || line != "" {
|
||||
lineToWrite += "\n"
|
||||
}
|
||||
|
||||
if _, writeErr := writer.Write([]byte(lineToWrite)); writeErr != nil {
|
||||
wrappedErr := WrapError(writeErr, ErrorTypeIO, CodeIOWrite, "failed to write processed line")
|
||||
if filePath != "" {
|
||||
wrappedErr = wrappedErr.WithFilePath(filePath)
|
||||
}
|
||||
return wrappedErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
111
gibidiutils/writers_test.go
Normal file
111
gibidiutils/writers_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Package gibidiutils provides common utility functions for gibidify.
|
||||
package gibidiutils
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeUint64ToInt64WithDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value uint64
|
||||
defaultValue int64
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "normal value within range",
|
||||
value: 1000,
|
||||
defaultValue: 0,
|
||||
want: 1000,
|
||||
},
|
||||
{
|
||||
name: "zero value",
|
||||
value: 0,
|
||||
defaultValue: 0,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "max int64 exactly",
|
||||
value: math.MaxInt64,
|
||||
defaultValue: 0,
|
||||
want: math.MaxInt64,
|
||||
},
|
||||
{
|
||||
name: "overflow with zero default clamps to max",
|
||||
value: math.MaxInt64 + 1,
|
||||
defaultValue: 0,
|
||||
want: math.MaxInt64,
|
||||
},
|
||||
{
|
||||
name: "large overflow with zero default clamps to max",
|
||||
value: math.MaxUint64,
|
||||
defaultValue: 0,
|
||||
want: math.MaxInt64,
|
||||
},
|
||||
{
|
||||
name: "overflow with custom default returns custom",
|
||||
value: math.MaxInt64 + 1,
|
||||
defaultValue: -1,
|
||||
want: -1,
|
||||
},
|
||||
{
|
||||
name: "overflow with custom positive default",
|
||||
value: math.MaxUint64,
|
||||
defaultValue: 12345,
|
||||
want: 12345,
|
||||
},
|
||||
{
|
||||
name: "large value within range",
|
||||
value: uint64(math.MaxInt64 - 1000),
|
||||
defaultValue: 0,
|
||||
want: math.MaxInt64 - 1000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SafeUint64ToInt64WithDefault(tt.value, tt.defaultValue)
|
||||
if got != tt.want {
|
||||
t.Errorf("SafeUint64ToInt64WithDefault(%d, %d) = %d, want %d",
|
||||
tt.value, tt.defaultValue, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUint64ToInt64WithDefaultGuardrailsBehavior(t *testing.T) {
|
||||
// Test that overflow with default=0 returns MaxInt64, not 0
|
||||
// This is critical for back-pressure and resource monitors
|
||||
result := SafeUint64ToInt64WithDefault(math.MaxUint64, 0)
|
||||
if result == 0 {
|
||||
t.Error("Overflow with default=0 returned 0, which would disable guardrails")
|
||||
}
|
||||
if result != math.MaxInt64 {
|
||||
t.Errorf("Overflow with default=0 should clamp to MaxInt64, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSafeUint64ToInt64WithDefault benchmarks the conversion function
|
||||
func BenchmarkSafeUint64ToInt64WithDefault(b *testing.B) {
|
||||
b.Run("normal_value", func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = SafeUint64ToInt64WithDefault(1000, 0)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("overflow_zero_default", func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = SafeUint64ToInt64WithDefault(math.MaxUint64, 0)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("overflow_custom_default", func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = SafeUint64ToInt64WithDefault(math.MaxUint64, -1)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user