v0.1.11 harden logging lifecycle, sanitization, and formatter edge cases
Test / test (push) Canceled after 0s
Test / fuzz (./compat, FuzzParseFormat) (push) Canceled after 0s
Test / fuzz (./formatter, FuzzFormatJSON) (push) Canceled after 0s
Test / fuzz (./formatter, FuzzFormatTextPolicy) (push) Canceled after 0s
Test / fuzz (./sanitizer, FuzzSanitize) (push) Canceled after 0s
Test / fuzz (./sanitizer, FuzzSerializerJSON) (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-26 14:25:14 -04:00
1 parent 79a81db3ca
commit 2417e51da1
46 files changed
+1846 -1289

No files matched your search

+336
View File
@@ -0,0 +1,336 @@
package log
import (
"bytes"
"errors"
"math"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestConfigOwnershipAndRollback(t *testing.T) {
l, dir := newTestLogger(t)
cfg := l.GetConfig()
cfg.Format = "json"
mustNoErr(t, l.ApplyConfig(cfg), "apply snapshot")
cfg.Format = "raw"
equal(t, l.GetConfig().Format, "json", "caller config is not retained")
old := *l.GetConfig()
blocker := filepath.Join(dir, "blocker")
mustNoErr(t, os.WriteFile(blocker, nil, 0600), "create blocker")
mustNoErr(t, os.Mkdir(filepath.Join(dir, "directory"), 0700), "create directory blocker")
for _, path := range []string{filepath.Join(blocker, "child"), dir} {
bad := l.GetConfig()
bad.Directory = path
bad.Name = "directory"
bad.Extension = ""
bad.Format = "txt"
bad.Level = LevelError
mustErr(t, l.ApplyConfig(bad), "failed reconfiguration")
equal(t, *l.GetConfig(), old, "configuration rolled back")
isTrue(t, l.Enabled(LevelInfo), "failed config did not close emit gate")
}
l.Info("survives")
mustNoErr(t, l.Flush(time.Second), "flush old configuration")
contains(t, readLog(t, dir), `"fields":["survives"]`, "formatter rolled back")
}
func TestConfigBounds(t *testing.T) {
for name, mutate := range map[string]func(*Config){
"name traversal": func(c *Config) { c.Name = "../outside" },
"name dot": func(c *Config) { c.Name = ".." },
"extension traversal": func(c *Config) { c.Extension = "../log" },
"NUL": func(c *Config) { c.Name = "bad\x00name" },
"buffer": func(c *Config) { c.BufferSize = math.MaxInt64 },
"size": func(c *Config) { c.MaxSizeKB = math.MaxInt64 },
"flush": func(c *Config) { c.FlushIntervalMs = math.MaxInt64 },
"heartbeat": func(c *Config) { c.HeartbeatIntervalS = math.MaxInt64 },
"retention nan": func(c *Config) { c.RetentionPeriodHrs = math.NaN() },
"retention infinity": func(c *Config) { c.RetentionCheckMins = math.Inf(1) },
"retention overflow": func(c *Config) { c.RetentionPeriodHrs = 1e30 },
"retention underflow": func(c *Config) { c.RetentionCheckMins = 1e-30 },
} {
t.Run(name, func(t *testing.T) { c := DefaultConfig(); mutate(c); mustErr(t, c.Validate(), "validate") })
}
var nilConfig *Config
mustErr(t, nilConfig.Validate(), "nil config")
for _, b := range []*Builder{NewBuilder().MaxSizeMB(math.MaxInt64), NewBuilder().MaxTotalSizeMB(math.MaxInt64), NewBuilder().MinDiskFreeMB(math.MaxInt64)} {
_, err := b.Build()
mustErr(t, err, "MB overflow")
}
l := NewLogger()
isFalse(t, l.Enabled(math.MaxInt64), "closed level gate")
}
func TestConcurrentOverridesMerge(t *testing.T) {
l, _ := newTestLogger(t)
overrides := []string{"trace_depth=2", "level=debug", "format=json", "show_level=false", "show_timestamp=false", "sanitization=txt"}
var wg sync.WaitGroup
start := make(chan struct{})
for _, override := range overrides {
wg.Go(func() { <-start; noErr(t, l.ApplyConfigString(override), override) })
}
close(start)
wg.Wait()
c := l.GetConfig()
equal(t, c.TraceDepth, int64(2), "depth")
equal(t, c.Level, LevelDebug, "level")
equal(t, c.Format, "json", "format")
isFalse(t, c.ShowLevel, "show level")
isFalse(t, c.ShowTimestamp, "show timestamp")
equal(t, c.Sanitization, PolicyTxt, "sanitization")
}
func TestStartDoesNotReplaceProcessor(t *testing.T) {
l := NewLogger()
c := DefaultConfig()
c.EnableConsole = false
mustNoErr(t, l.ApplyConfig(c), "apply")
var launches atomic.Int64
l.SetSpawn(func(fn func()) { launches.Add(1); go fn() })
var wg sync.WaitGroup
for range 64 {
wg.Go(func() { noErr(t, l.Start(), "concurrent start") })
}
wg.Wait()
equal(t, launches.Load(), int64(1), "only one processor")
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
mustNoErr(t, l.ApplyConfig(c), "reset after shutdown")
mustNoErr(t, l.Start(), "start reset logger")
isTrue(t, l.Enabled(LevelInfo), "reset reopens gate")
mustNoErr(t, l.Shutdown(time.Second), "shutdown reset logger")
}
type blockedWriter struct {
entered, release chan struct{}
once sync.Once
}
func (w *blockedWriter) Write(p []byte) (int, error) {
w.once.Do(func() { close(w.entered) })
<-w.release
return len(p), nil
}
func TestTimedOutShutdownKeepsProcessorResources(t *testing.T) {
l := NewLogger()
c := DefaultConfig()
c.Directory = t.TempDir()
c.EnableFile = true
c.EnableConsole = true
mustNoErr(t, l.ApplyConfig(c), "apply")
w := &blockedWriter{entered: make(chan struct{}), release: make(chan struct{})}
l.state.StdoutWriter.Store(&sink{w: w})
mustNoErr(t, l.Start(), "start")
l.Info("accepted before timeout")
select {
case <-w.entered:
case <-time.After(time.Second):
t.Fatal("processor never reached writer")
}
// Always release the writer even when an assertion fails.
defer func() { close(w.release); noErr(t, l.Shutdown(time.Second), "retry shutdown") }()
errContains(t, l.Shutdown(time.Millisecond), "timeout", "shutdown timeout")
f := l.state.CurrentFile.Load().(*os.File)
_, err := f.Stat()
mustNoErr(t, err, "file still owned by draining processor")
mustErr(t, l.Start(), "restart blocked")
mustErr(t, l.ApplyConfig(c), "resource replacement blocked")
}
func TestAsyncArgumentSnapshot(t *testing.T) {
l := NewLogger()
c := DefaultConfig()
c.EnableConsole = false
c.EnableFile = true
c.Directory = t.TempDir()
c.Format = "json"
mustNoErr(t, l.ApplyConfig(c), "apply")
var run func()
l.SetSpawn(func(fn func()) { run = fn })
mustNoErr(t, l.Start(), "start deferred processor")
args := []any{"original"}
l.Info(args...)
args[0] = "mutated"
fields := map[string]any{"value": "original"}
l.LogStructured(LevelInfo, "message", fields)
fields["value"] = "mutated"
go run()
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
data := readLog(t, c.Directory)
notContains(t, data, "mutated", "snapshotted arguments and map")
equal(t, strings.Count(data, "original"), 2, "original values")
}
func TestFlushReportsSyncError(t *testing.T) {
l, _ := newTestLogger(t)
mustNoErr(t, l.Flush(time.Second), "initial flush")
file := l.state.CurrentFile.Load().(*os.File)
mustNoErr(t, file.Close(), "inject closed file")
err := l.Flush(time.Second)
if !errors.Is(err, os.ErrClosed) {
t.Fatalf("expected sync error, got %v", err)
}
}
func TestConsoleOnlyRetentionIsInert(t *testing.T) {
c := DefaultConfig()
c.EnableConsole = false
c.Directory = t.TempDir()
c.RetentionPeriodHrs = 1
name := filepath.Join(c.Directory, "old.log")
mustNoErr(t, os.WriteFile(name, []byte("keep"), 0600), "write")
old := time.Now().Add(-2 * time.Hour)
mustNoErr(t, os.Chtimes(name, old, old), "set age")
l := NewLogger()
mustNoErr(t, l.ApplyConfig(c), "apply")
l.state.EarliestFileTime.Store(old)
l.handleRetentionCheck()
mustNoErr(t, l.cleanExpiredLogs(old), "explicit cleanup")
data, err := os.ReadFile(name)
mustNoErr(t, err, "file survived")
equal(t, string(data), "keep", "contents")
timers := l.setupProcessingTimers()
defer l.stopProcessingTimers(timers)
if timers.retentionChan != nil {
t.Fatal("console-only retention timer enabled")
}
}
func TestArchiveCollisionBeyondOneThousand(t *testing.T) {
l, dir := newTestLogger(t)
stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
base := "log_" + stamp.Format("060102_150405")
mustNoErr(t, os.Mkdir(filepath.Join(dir, base+".log"), 0700), "directory collision")
for i := 1; i <= 1000; i++ {
mustNoErr(t, os.WriteFile(filepath.Join(dir, base+"_"+itoa(i)+".log"), nil, 0600), "archive")
}
equal(t, l.generateArchiveLogFileName(stamp), base+"_1001.log", "no capped collision reuse")
}
func TestExtensionlessAccountingAndCleanup(t *testing.T) {
l := NewLogger()
c := DefaultConfig()
c.EnableConsole = false
c.EnableFile = true
c.Directory = t.TempDir()
c.Extension = ""
mustNoErr(t, l.ApplyConfig(c), "apply")
defer l.Shutdown()
for _, name := range []string{"old", "other.txt"} {
mustNoErr(t, os.WriteFile(filepath.Join(c.Directory, name), []byte("123"), 0600), "write")
}
size, err := l.getLogDirSize(c.Directory, "")
mustNoErr(t, err, "size")
equal(t, size, int64(3), "extensionless size")
count, err := l.getLogFileCount(c.Directory, "")
mustNoErr(t, err, "count")
equal(t, count, 2, "extensionless count")
mustNoErr(t, l.cleanOldLogs(0), "zero demand does not remove archives")
mustNoErr(t, l.cleanOldLogs(3), "cleanup")
_, err = os.Stat(filepath.Join(c.Directory, "other.txt"))
mustNoErr(t, err, "other extension preserved")
}
func TestTextSanitizesMetadata(t *testing.T) {
var out bytes.Buffer
l := NewLogger()
c := DefaultConfig()
c.Format = "txt"
c.Sanitization = PolicyTxt
c.TimestampFormat = "bad\n\x1b"
c.EnableConsole = true
mustNoErr(t, l.ApplyConfig(c), "apply")
l.SetContextKeys("key\n\x1b")
l.state.StdoutWriter.Store(&sink{w: &out})
mustNoErr(t, l.Start(), "start")
l.LogContext(Context{Tag: "value"}, FlagKV, LevelInfo, 0, "key\n\x1b", "value")
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
equal(t, bytes.Count(out.Bytes(), []byte{'\n'}), 1, "one record line")
notContains(t, out.String(), "\x1b", "no terminal escape")
}
type shortWriter struct{}
func (shortWriter) Write(p []byte) (int, error) { return len(p) - 1, nil }
func TestConsoleWriteFailureAccounting(t *testing.T) {
l := NewLogger()
c := DefaultConfig()
mustNoErr(t, l.ApplyConfig(c), "apply")
l.state.StdoutWriter.Store(&sink{w: shortWriter{}})
mustNoErr(t, l.Start(), "start")
l.Info("short write")
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
equal(t, l.state.TotalDroppedLogs.Load(), uint64(1), "console drop")
equal(t, l.state.DroppedLogs.Load(), uint64(1), "interval drop")
equal(t, l.state.TotalLogsProcessed.Load(), uint64(0), "not processed successfully")
}
func TestDrainFlushIsBounded(t *testing.T) {
// Refill after every write, reproducing a queue which never becomes empty.
// A snapshot drain must still complete after the original pending count.
l := NewLogger()
c := DefaultConfig()
c.Format = "raw"
mustNoErr(t, l.ApplyConfig(c), "apply")
ch := make(chan logRecord, 4)
for range cap(ch) {
ch <- logRecord{Args: []any{"x"}}
}
l.state.StdoutWriter.Store(&sink{w: writerFunc(func(p []byte) (int, error) { ch <- logRecord{Args: []any{"later"}}; return len(p), nil })})
done := make(chan struct{})
go func() { l.drain(ch); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("drain starved on refilled queue")
}
equal(t, l.state.TotalLogsProcessed.Load(), uint64(4), "bounded work")
equal(t, len(ch), 4, "later records left for next iteration")
}
type writerFunc func([]byte) (int, error)
func (fn writerFunc) Write(p []byte) (int, error) { return fn(p) }
func TestReconfigureDrainsOldFileAndFormat(t *testing.T) {
l := NewLogger()
c := DefaultConfig()
c.Directory = t.TempDir()
c.EnableFile = true
c.EnableConsole = true
c.Format = "raw"
c.MaxSizeKB = 1
c.FlushIntervalMs = 1000
mustNoErr(t, l.ApplyConfig(c), "apply")
w := &blockedWriter{entered: make(chan struct{}), release: make(chan struct{})}
l.state.StdoutWriter.Store(&sink{w: w})
var diagnostics []string
l.SetErrorHandler(func(msg string) { diagnostics = append(diagnostics, msg) })
mustNoErr(t, l.Start(), "start")
payload := strings.Repeat("x", 1500)
l.Info(payload)
<-w.entered
l.Info("old-format-tail")
updated := l.GetConfig()
updated.EnableFile = false
updated.EnableConsole = false
updated.Format = "json"
done := make(chan error, 1)
go func() { done <- l.ApplyConfig(updated) }()
mustEventually(t, time.Second, "stop began", func() bool { return !l.state.Started.Load() })
close(w.release)
mustNoErr(t, <-done, "reconfigure")
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
data := readAllLogs(t, c.Directory)
contains(t, data, payload, "original payload")
contains(t, data, "old-format-tail", "queued record used old format")
equal(t, len(data), len(payload)+len("old-format-tail"), "exact drained bytes")
equal(t, len(diagnostics), 0, "closed current handle after draining rotation")
}