Files
shater/shater/cmd/shaterd/logsetup_test.go
T
omarandClaude Opus 5 0b32a6d58b fix(log): no ANSI colour outside a TTY — syslog and the log file stay grep-clean (B5)
Every log line the daemon produced carried aurora escapes, and under procd
stderr is not a screen, it is syslog:

  daemon.err shaterd[27540]: ...Z ESC[31mERRORESC[0m[0026]
  [ESC[38;5;193m1728741629ESC[0m 70ms] dns: exchange failed ...

`logread | grep ERROR` misses that line — the level word has invisible
bytes inside it — external collectors store the escapes forever, and a
captured log reads as mojibake.

Both producers defaulted to colour, and both are fixed at the producer,
because colour is a property of the DESTINATION and should never be
generated for a destination that cannot render it:

  * control plane (cmd/shaterd): log.Formatter{BaseTime: ...} left
    DisableColors at its false zero value. It now comes from
    controlLogFormatter(), gated on logsink.IsTTY(os.Stderr). The helper
    lives in an untagged file (same split as profilewatch.go) so it is
    unit-testable off the linux target.
  * engine (shater/generate): the generated option.LogOptions never set
    DisableColor, so box.New built a colouring formatter over the shared
    sink. logOptions() now sets it from the same TTY gate (seam:
    logColorAllowed).

logsink.IsTTY is the single source of the decision: a character-device
check, so no cgo, no termios and no new dependency on a CGO_ENABLED=0
musl-static binary. Under procd stderr is a pipe => no colour; an
interactive `shaterd run` from a shell keeps it.

The file half already stripped ANSI on the way out (emitLocked ->
stripANSI); that stays as the belt to this new braces, and the leak it
never covered — the syslog half — is now closed at the source.

Tests: the syslog half of the sink carries no 0x1b for any level with a
context ID set (the connection id is coloured by a separate branch of
log/format.go, so a level-only fix would still leak); the same for the
control-plane formatter and for a factory built from the REAL generated
log block. Each has a teeth check that a colouring formatter does emit
0x1b, so the guards cannot rot into passing for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:31:01 +03:00

38 lines
1.3 KiB
Go

package main
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/sagernet/sing-box/log"
)
// TestControlLogFormatterNoANSIOffTTY pins the control-plane half of the syslog
// colour leak: when the daemon's stderr is not a terminal — which is ALWAYS the
// case under procd, where stderr is the pipe procd relays to syslog — no line
// the daemon formats may contain an ESC (0x1b).
func TestControlLogFormatterNoANSIOffTTY(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
t.Cleanup(func() { _ = r.Close(); _ = w.Close() })
f := controlLogFormatter(time.Now(), w)
if !f.DisableColors {
t.Fatalf("colours enabled for a non-terminal stderr")
}
// A context ID exercises the second colouring branch of log/format.go (the
// 256-colour connection id), which is what produced ESC[38;5;193m on the router.
ctx := log.ContextWithNewID(context.Background())
for _, level := range []log.Level{log.LevelError, log.LevelWarn, log.LevelInfo, log.LevelDebug, log.LevelTrace} {
line := f.Format(ctx, level, "dns", "exchange failed for example.com. IN AAAA: unexpected EOF", time.Now())
if i := strings.IndexByte(line, 0x1b); i >= 0 {
t.Errorf("level %v: formatted line carries an ANSI escape at byte %d: %q", level, i, line)
}
}
}