diff --git a/internal/output/output.go b/internal/output/output.go index 251756bc..dcaf556c 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -161,42 +161,73 @@ func Writer() io.Writer { return sink } -// Info prints an informational message with [*] prefix -func Info(format string, args ...interface{}) { +// Sink is a routable output destination: the writer chrome lands on, plus +// whether interactive widgets (spinners, live progress) may animate on it. A +// scan can be handed its own Sink so its chrome routes to a chosen writer. +type Sink struct { + w io.Writer + interactive bool +} + +// NewSink returns a Sink writing to w. interactive reports whether live widgets +// are allowed; a captured buffer sink passes false so spinners/progress no-op. +func NewSink(w io.Writer, interactive bool) *Sink { + return &Sink{w: w, interactive: interactive} +} + +// DefaultSink is the ambient sink the package-level chrome funcs write to: the +// process output routing configured by SetSilent. +func DefaultSink() *Sink { + return &Sink{w: sink, interactive: IsTTY && !silent} +} + +// Writer exposes the underlying writer, for callers that render their own chrome. +func (s *Sink) Writer() io.Writer { return s.w } + +// Interactive reports whether live widgets may animate on this sink; a captured +// buffer sink reports false so spinners and progress bars stay silent. +func (s *Sink) Interactive() bool { return s.interactive } + +// Info logs a [*]-prefixed message; a no-op in API mode. +func (s *Sink) Info(format string, args ...interface{}) { if apiMode { return } - msg := fmt.Sprintf(format, args...) - fmt.Fprintf(sink, "%s %s\n", prefixInfo.Render("[*]"), msg) + fmt.Fprintf(s.w, "%s %s\n", prefixInfo.Render("[*]"), fmt.Sprintf(format, args...)) } -// Success prints a success message with [+] prefix -func Success(format string, args ...interface{}) { +// Success logs a [+]-prefixed message; a no-op in API mode. +func (s *Sink) Success(format string, args ...interface{}) { if apiMode { return } - msg := fmt.Sprintf(format, args...) - fmt.Fprintf(sink, "%s %s\n", prefixSuccess.Render("[+]"), msg) + fmt.Fprintf(s.w, "%s %s\n", prefixSuccess.Render("[+]"), fmt.Sprintf(format, args...)) } -// Warn prints a warning message with [!] prefix -func Warn(format string, args ...interface{}) { +// Warn logs a [!]-prefixed message; a no-op in API mode. +func (s *Sink) Warn(format string, args ...interface{}) { if apiMode { return } - msg := fmt.Sprintf(format, args...) - fmt.Fprintf(sink, "%s %s\n", prefixWarning.Render("[!]"), msg) + fmt.Fprintf(s.w, "%s %s\n", prefixWarning.Render("[!]"), fmt.Sprintf(format, args...)) } -// Error prints an error message with [-] prefix -func Error(format string, args ...interface{}) { +// Error logs a [-]-prefixed message; a no-op in API mode. +func (s *Sink) Error(format string, args ...interface{}) { if apiMode { return } - msg := fmt.Sprintf(format, args...) - fmt.Fprintf(sink, "%s %s\n", prefixError.Render("[-]"), msg) + fmt.Fprintf(s.w, "%s %s\n", prefixError.Render("[-]"), fmt.Sprintf(format, args...)) } +func Info(format string, args ...interface{}) { DefaultSink().Info(format, args...) } + +func Success(format string, args ...interface{}) { DefaultSink().Success(format, args...) } + +func Warn(format string, args ...interface{}) { DefaultSink().Warn(format, args...) } + +func Error(format string, args ...interface{}) { DefaultSink().Error(format, args...) } + // ScanStart prints a styled scan start message func ScanStart(scanName string) { if apiMode { @@ -213,11 +244,16 @@ func ScanComplete(scanName string, resultCount int, resultType string) { fmt.Fprintf(sink, "%s %s complete (%d %s)\n", prefixInfo.Render("[*]"), scanName, resultCount, resultType) } -// Module creates a prefixed logger for a specific module/tool func Module(name string) *ModuleLogger { + return DefaultSink().Module(name) +} + +// Module creates a prefixed logger bound to this sink. +func (s *Sink) Module(name string) *ModuleLogger { return &ModuleLogger{ name: name, style: moduleStyleFor(name), + sink: s, } } @@ -225,6 +261,7 @@ func Module(name string) *ModuleLogger { type ModuleLogger struct { name string style lipgloss.Style + sink *Sink } func (m *ModuleLogger) prefix() string { @@ -237,7 +274,7 @@ func (m *ModuleLogger) Info(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Fprintf(sink, "%s %s\n", m.prefix(), msg) + fmt.Fprintf(m.sink.w, "%s %s\n", m.prefix(), msg) } // Success prints a success message with module prefix @@ -246,7 +283,7 @@ func (m *ModuleLogger) Success(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg) + fmt.Fprintf(m.sink.w, "%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg) } // Warn prints a warning message with module prefix @@ -255,7 +292,7 @@ func (m *ModuleLogger) Warn(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg) + fmt.Fprintf(m.sink.w, "%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg) } // Error prints an error message with module prefix @@ -264,7 +301,7 @@ func (m *ModuleLogger) Error(format string, args ...interface{}) { return } msg := fmt.Sprintf(format, args...) - fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg) + fmt.Fprintf(m.sink.w, "%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg) } // Start prints a scan start message with module prefix (adds newline before for separation) @@ -272,7 +309,7 @@ func (m *ModuleLogger) Start() { if apiMode { return } - fmt.Fprintf(sink, "\n%s starting scan\n", m.prefix()) + fmt.Fprintf(m.sink.w, "\n%s starting scan\n", m.prefix()) } // Complete prints a scan complete message with module prefix @@ -280,7 +317,7 @@ func (m *ModuleLogger) Complete(resultCount int, resultType string) { if apiMode { return } - fmt.Fprintf(sink, "%s complete (%d %s)\n", m.prefix(), resultCount, resultType) + fmt.Fprintf(m.sink.w, "%s complete (%d %s)\n", m.prefix(), resultCount, resultType) } // ClearLine clears the current line (for progress bar updates). silent mode is diff --git a/internal/output/sink_test.go b/internal/output/sink_test.go new file mode 100644 index 00000000..49482753 --- /dev/null +++ b/internal/output/sink_test.go @@ -0,0 +1,83 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package output + +import ( + "bytes" + "strings" + "testing" +) + +// TestSinkRoutesToOwnWriter is the seam a per-target buffer depends on: chrome +// written through a Sink must land on that sink's writer, not the process-wide +// default. Both the top-level funcs and a sink-bound module logger must honour +// it so a routed scan's output can be captured whole. +func TestSinkRoutesToOwnWriter(t *testing.T) { + var buf bytes.Buffer + s := NewSink(&buf, false) + + s.Info("info-marker") + s.Success("success-marker") + s.Warn("warn-marker") + s.Error("error-marker") + s.Module("MOD").Warn("module-marker") + + got := buf.String() + for _, want := range []string{"info-marker", "success-marker", "warn-marker", "error-marker", "module-marker"} { + if !strings.Contains(got, want) { + t.Errorf("sink writer missing %q; got:\n%s", want, got) + } + } + + if s.Interactive() { + t.Error("NewSink(w, false).Interactive() = true, want false") + } +} + +// TestSinkDoesNotLeakToDefault confirms a routed sink is isolated: writing to it +// must leave the process default sink untouched, which is what lets concurrent +// targets each own their buffer without stepping on each other. +func TestSinkDoesNotLeakToDefault(t *testing.T) { + var target, other bytes.Buffer + + // point the default sink at `other` for the duration of this test. + prev := sink + sink = &other + defer func() { sink = prev }() + + NewSink(&target, false).Info("only-in-target") + + if other.Len() != 0 { + t.Errorf("routed write leaked to default sink: %q", other.String()) + } + if !strings.Contains(target.String(), "only-in-target") { + t.Errorf("routed write missing from target sink: %q", target.String()) + } +} + +// TestDefaultSinkTracksGlobal pins that the package-level funcs still follow the +// SetSilent routing, so existing single-target behaviour is unchanged. +func TestDefaultSinkTracksGlobal(t *testing.T) { + var buf bytes.Buffer + prev := sink + sink = &buf + defer func() { sink = prev }() + + Info("default-path") + if !strings.Contains(buf.String(), "default-path") { + t.Errorf("package Info did not reach the default sink: %q", buf.String()) + } + if DefaultSink().Writer() != Writer() { + t.Error("DefaultSink().Writer() should equal the package Writer()") + } +}