Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: stable
go-version-file: 'go.mod'
- name: Install task
uses: jaxxstorm/action-install-gh-release@v2.1.0
with:
Expand Down
7 changes: 4 additions & 3 deletions cmd/logwrap/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/sgaunet/logwrap/pkg/processor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)

// testBinaryPath holds the path to the compiled logwrap binary for subprocess tests.
Expand Down Expand Up @@ -49,9 +50,9 @@ func TestMain(m *testing.M) {
os.Exit(1)
}

exitCode := m.Run()
os.RemoveAll(tmpDir)
os.Exit(exitCode)
goleak.VerifyTestMain(m, goleak.Cleanup(func(exitCode int) {
os.RemoveAll(tmpDir)
}))
}

// runPipeline constructs the full logwrap pipeline with a thread-safe captured
Expand Down
47 changes: 37 additions & 10 deletions cmd/logwrap/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ var (
)

const (
exitCodeSIGINT = 130 // 128 + 2 (SIGINT)
exitCodeSIGTERM = 143 // 128 + 15 (SIGTERM)
signalExitCodeBase = 128 // UNIX convention: 128 + signal number
exitCodeSIGINT = signalExitCodeBase + 2 // SIGINT
exitCodeSIGTERM = signalExitCodeBase + 15 // SIGTERM
gracefulShutdownTimeout = 5 * time.Second
processorWaitTimeout = 3 * time.Second
killTimeout = 2 * time.Second
usage = `LogWrap - Command execution wrapper with configurable log prefixes

Usage:
Expand Down Expand Up @@ -292,6 +294,16 @@ func run(cfg *config.Config, command []string) int {
procOpts = append(procOpts, processor.WithFilter(f))
}

// Set up signal handling before starting the child process to avoid
// a race where a signal arrives after Start() but before Notify(),
// which would use Go's default handler (os.Exit) and orphan the child.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

ctx, ctxCancel := context.WithCancel(context.Background())
defer ctxCancel()

procOpts = append(procOpts, processor.WithContext(ctx))
proc := processor.New(form, os.Stdout, procOpts...)

if err := exec.Start(); err != nil {
Expand All @@ -301,12 +313,7 @@ func run(cfg *config.Config, command []string) int {

stdout, stderr := exec.GetStreams()

// Set up signal handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

// Start stream processing in background
ctx := context.Background()
processingDone := make(chan error, 1)
go func() {
processingDone <- proc.ProcessStreams(ctx, stdout, stderr)
Expand Down Expand Up @@ -372,7 +379,16 @@ func handleSignalShutdown(exec *executor.Executor, proc *processor.Processor, si
fmt.Fprintf(os.Stderr, "Warning: failed to kill process: %v\n", err)
}
proc.Stop()
return <-cmdDone // Wait for process to actually die
// Wait for process to die, with a hard timeout to avoid hanging
// indefinitely if the process is in an unkillable state (e.g., D state on Linux).
killTimer := time.NewTimer(killTimeout)
defer killTimer.Stop()
select {
case cmdErr := <-cmdDone:
return cmdErr
case <-killTimer.C:
return nil
}
}
}

Expand All @@ -391,17 +407,28 @@ func waitForProcessing(proc *processor.Processor, processingDone chan error) {
}
}

func determineExitCode(exec *executor.Executor, receivedSignal os.Signal, _ error) int {
func determineExitCode(exec *executor.Executor, receivedSignal os.Signal, cmdErr error) int {
// If we received a signal, use signal-based exit code
if receivedSignal != nil {
switch receivedSignal {
case syscall.SIGINT:
return exitCodeSIGINT
case syscall.SIGTERM:
return exitCodeSIGTERM
default:
if sig, ok := receivedSignal.(syscall.Signal); ok {
return signalExitCodeBase + int(sig)
}

return 1
}
}

// Otherwise use command's exit code
// If the command failed with a non-exit error (e.g., I/O error, context error),
// the executor's exit code stays at 0. Use 1 to avoid masking the failure.
if cmdErr != nil && exec.GetExitCode() == 0 {
return 1
}

return exec.GetExitCode()
}
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ go 1.25.0

require gopkg.in/yaml.v3 v3.0.1

require github.com/itchyny/timefmt-go v0.1.8
require (
github.com/itchyny/timefmt-go v0.1.8
go.uber.org/goleak v1.3.0
)

require github.com/kr/text v0.2.0 // indirect

require (
github.com/davecgh/go-spew v1.1.1 // indirect
Expand Down
10 changes: 9 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI=
github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
11 changes: 11 additions & 0 deletions pkg/apperrors/testmain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package apperrors

import (
"testing"

"go.uber.org/goleak"
)

func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
10 changes: 6 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import (
"io"
"os"
"path/filepath"
"slices"
"strings"

"github.com/sgaunet/logwrap/pkg/apperrors"
Expand Down Expand Up @@ -321,7 +322,7 @@ func parseCLIFlags(args []string) (*CLIFlags, error) {
}

func applyCLIOverrides(config *Config, flags *CLIFlags) {
if flags.Template != nil && *flags.Template != "" {
if flags.setFlags["template"] {
config.Prefix.Template = *flags.Template
}
if flags.setFlags["utc"] {
Expand All @@ -330,7 +331,7 @@ func applyCLIOverrides(config *Config, flags *CLIFlags) {
if flags.setFlags["colors"] {
config.Prefix.Colors.Enabled = *flags.ColorsEnabled
}
if flags.OutputFormat != nil && *flags.OutputFormat != "" {
if flags.setFlags["format"] {
config.Output.Format = *flags.OutputFormat
}
}
Expand Down Expand Up @@ -369,9 +370,10 @@ func FindConfigFile() string {
// - Path traversal: rejects paths containing ".." after filepath.Clean
// - File type: only .yaml and .yml extensions are accepted (case-insensitive)
func validateConfigPath(configFile string) error {
// Prevent path traversal attacks
// Prevent path traversal attacks by checking for ".." as a path component,
// not a substring — filenames like "backup..yaml" are valid.
cleaned := filepath.Clean(configFile)
if strings.Contains(cleaned, "..") {
if slices.Contains(strings.Split(cleaned, string(filepath.Separator)), "..") {
return apperrors.ErrPathTraversal
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ func TestApplyCLIOverrides(t *testing.T) {
TimestampUTC: &utc,
ColorsEnabled: &colors,
OutputFormat: &format,
setFlags: map[string]bool{"utc": true, "colors": true},
setFlags: map[string]bool{"utc": true, "colors": true, "template": true, "format": true},
}

// Apply overrides
Expand Down
11 changes: 11 additions & 0 deletions pkg/config/testmain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package config

import (
"testing"

"go.uber.org/goleak"
)

func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
24 changes: 14 additions & 10 deletions pkg/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,10 @@ func isValidLogLevel(level string, validLevels []string) bool {
// that assigns levels to lines. Without detection, all lines have
// an empty detected level and level filters silently drop everything.
func (c *Config) validateFilter() error {
if !c.Filter.Enabled {
return nil
}

validLevels := []string{"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"}

if !c.LogLevel.Detection.Enabled {
Expand All @@ -387,19 +391,19 @@ func (c *Config) validateFilter() error {
if err := validateFilterLevelNames(c.Filter.ExcludeLevels, "exclude_levels", validLevels); err != nil {
return err
}
if slices.Contains(c.Filter.ExcludePatterns, "") {
return fmt.Errorf("%w in exclude_patterns", apperrors.ErrEmptyFilterPattern)
}
if slices.Contains(c.Filter.IncludePatterns, "") {
return fmt.Errorf("%w in include_patterns", apperrors.ErrEmptyFilterPattern)
}
if err := validateRegexPatterns(c.Filter.ExcludePatterns, "exclude_patterns"); err != nil {
if err := validateFilterPatterns(c.Filter.ExcludePatterns, "exclude_patterns"); err != nil {
return err
}
if err := validateRegexPatterns(c.Filter.IncludePatterns, "include_patterns"); err != nil {
return err
return validateFilterPatterns(c.Filter.IncludePatterns, "include_patterns")
}

// validateFilterPatterns checks that a pattern list contains no empty strings
// and that all entries are valid regular expressions.
func validateFilterPatterns(patterns []string, field string) error {
if slices.Contains(patterns, "") {
return fmt.Errorf("%w in %s", apperrors.ErrEmptyFilterPattern, field)
}
return nil
return validateRegexPatterns(patterns, field)
}

// validateFilterLevelNames checks that all level names in the list are valid
Expand Down
4 changes: 4 additions & 0 deletions pkg/config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,7 @@ func TestConfig_ValidateFilter_EmptyPatterns(t *testing.T) {
t.Parallel()

cfg := getDefaultConfig()
cfg.Filter.Enabled = true
cfg.Filter.ExcludePatterns = tt.exclude
cfg.Filter.IncludePatterns = tt.include

Expand Down Expand Up @@ -1094,6 +1095,7 @@ func TestConfig_ValidateFilter_LevelsRequireDetection(t *testing.T) {
t.Parallel()

cfg := getDefaultConfig()
cfg.Filter.Enabled = true
cfg.LogLevel.Detection.Enabled = tt.detection
if !tt.detection {
cfg.LogLevel.Detection.Keywords = nil
Expand Down Expand Up @@ -1131,6 +1133,7 @@ func TestConfig_ValidateFilter_InvalidRegex(t *testing.T) {
t.Parallel()

cfg := getDefaultConfig()
cfg.Filter.Enabled = true
cfg.Filter.ExcludePatterns = tt.exclude
cfg.Filter.IncludePatterns = tt.include

Expand Down Expand Up @@ -1170,6 +1173,7 @@ func TestConfig_ValidateFilter_InvalidLevelNames(t *testing.T) {
t.Parallel()

cfg := getDefaultConfig()
cfg.Filter.Enabled = true
cfg.Filter.IncludeLevels = tt.include
cfg.Filter.ExcludeLevels = tt.exclude

Expand Down
Loading
Loading