feat(output): add routable Sink for per-scan output capture (#331)

* feat(output): add routable Sink for per-scan output capture

introduce a Sink (writer plus an interactive flag) that the package-level
chrome funcs delegate to via DefaultSink, and give ModuleLogger a sink it
writes through. a caller can now hand a scan its own Sink so that scan's
chrome lands on a dedicated writer instead of the process-wide default.
default routing is unchanged: Info/Warn/Module and friends still write to
the same stdout/stderr sink SetSilent configures.

* fix(output): trim redundant sink doc comments

drop comments on thin package-level wrappers that just forward to
the Sink methods, and tighten the Sink methods' own comments to
note the api-mode no-op instead of restating the func name
This commit is contained in:
Tigah
2026-07-22 12:53:23 -07:00
committed by GitHub
parent 1f6d0f7cf0
commit 281e59cc9e
2 changed files with 152 additions and 32 deletions
+69 -32
View File
@@ -196,42 +196,73 @@ func Writer() io.Writer {
return sink
}
// Info prints an informational message with [*] prefix
func Info(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixInfo.Render("[*]"), msg)
// 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
}
// Success prints a success message with [+] prefix
func Success(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixSuccess.Render("[+]"), msg)
// 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}
}
// Warn prints a warning message with [!] prefix
func Warn(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixWarning.Render("[!]"), msg)
// 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}
}
// Error prints an error message with [-] prefix
func Error(format string, args ...interface{}) {
// 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", prefixError.Render("[-]"), msg)
fmt.Fprintf(s.w, "%s %s\n", prefixInfo.Render("[*]"), fmt.Sprintf(format, args...))
}
// Success logs a [+]-prefixed message; a no-op in API mode.
func (s *Sink) Success(format string, args ...interface{}) {
if apiMode {
return
}
fmt.Fprintf(s.w, "%s %s\n", prefixSuccess.Render("[+]"), fmt.Sprintf(format, args...))
}
// Warn logs a [!]-prefixed message; a no-op in API mode.
func (s *Sink) Warn(format string, args ...interface{}) {
if apiMode {
return
}
fmt.Fprintf(s.w, "%s %s\n", prefixWarning.Render("[!]"), fmt.Sprintf(format, args...))
}
// Error logs a [-]-prefixed message; a no-op in API mode.
func (s *Sink) Error(format string, args ...interface{}) {
if apiMode {
return
}
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 {
@@ -248,11 +279,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,
}
}
@@ -260,6 +296,7 @@ func Module(name string) *ModuleLogger {
type ModuleLogger struct {
name string
style lipgloss.Style
sink *Sink
}
func (m *ModuleLogger) prefix() string {
@@ -272,7 +309,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
@@ -281,7 +318,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
@@ -290,7 +327,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
@@ -299,7 +336,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)
@@ -307,7 +344,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
@@ -315,7 +352,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
+83
View File
@@ -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()")
}
}