Files

232 lines
5.5 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package logging
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"runtime"
"strings"
"sync"
"time"
)
const redactedValue = "[REDACTED]"
type Options struct {
Level slog.Level
AddSource bool
SensitiveKeys []string
}
type Handler struct {
writer io.Writer
writerLock *sync.Mutex
level slog.Level
addSource bool
sensitiveKeys map[string]struct{}
group string
attrs []slog.Attr
}
func New(writer io.Writer, options Options) *slog.Logger {
return slog.New(newHandler(writer, options))
}
func newHandler(writer io.Writer, options Options) *Handler {
if writer == nil {
writer = io.Discard
}
level := options.Level
if level == 0 {
level = slog.LevelInfo
}
sensitiveKeys := map[string]struct{}{}
for _, key := range append(defaultSensitiveKeys(), options.SensitiveKeys...) {
sensitiveKeys[lowercaseKey(key)] = struct{}{}
}
return &Handler{
writer: writer,
writerLock: &sync.Mutex{},
level: level,
addSource: options.AddSource,
sensitiveKeys: sensitiveKeys,
}
}
func (handler *Handler) Enabled(_ context.Context, level slog.Level) bool {
return level >= handler.level
}
func (handler *Handler) Handle(_ context.Context, record slog.Record) error {
_, err := handler.write(handler.format(record))
return err
}
func (handler *Handler) format(record slog.Record) []byte {
var buffer bytes.Buffer
buffer.WriteByte('{')
appendField(&buffer, "message", record.Message)
appendField(&buffer, "level", record.Level.String())
if handler.addSource && record.PC != 0 {
frame, _ := runtime.CallersFrames([]uintptr{record.PC}).Next()
appendField(&buffer, "source", fmt.Sprintf("%s:%d", frame.File, frame.Line))
appendField(&buffer, "function", frame.Function)
}
for _, attr := range handler.attrs {
handler.appendAttr(&buffer, attr, "")
}
record.Attrs(func(attr slog.Attr) bool {
handler.appendAttr(&buffer, attr, handler.group)
return true
})
appendField(&buffer, "time", record.Time.Format(time.RFC3339Nano))
buffer.WriteByte('}')
buffer.WriteByte('\n')
return buffer.Bytes()
}
func (handler *Handler) write(data []byte) (int, error) {
handler.writerLock.Lock()
defer handler.writerLock.Unlock()
return handler.writer.Write(data)
}
func (handler *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
clone := *handler
clone.attrs = append([]slog.Attr(nil), handler.attrs...)
prefix := ""
if handler.group != "" {
prefix = handler.group + "."
}
for _, attr := range attrs {
if prefix == "" {
clone.attrs = append(clone.attrs, attr)
continue
}
clone.attrs = append(clone.attrs, slog.Attr{Key: prefix + attr.Key, Value: attr.Value})
}
return &clone
}
func (handler *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return handler
}
clone := *handler
if handler.group == "" {
clone.group = name
} else {
clone.group = handler.group + "." + name
}
return &clone
}
func (handler *Handler) appendAttr(buffer *bytes.Buffer, attr slog.Attr, prefix string) {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return
}
if attr.Value.Kind() == slog.KindGroup {
groupPrefix := prefix
if attr.Key != "" && prefix != "" {
groupPrefix = prefix + "." + attr.Key
} else if attr.Key != "" {
groupPrefix = attr.Key
}
for _, child := range attr.Value.Group() {
handler.appendAttr(buffer, child, groupPrefix)
}
return
}
key := attr.Key
if prefix != "" {
key = prefix + "." + attr.Key
}
var value any
if attr.Value.Kind() == slog.KindString {
value = attr.Value.String()
} else {
value = attr.Value.Any()
}
if handler.isSensitive(key) {
value = redactedValue
} else if err, ok := value.(error); ok {
value = err.Error()
}
appendField(buffer, key, value)
}
func (handler *Handler) isSensitive(key string) bool {
normalized := lowercaseKey(key)
if _, exists := handler.sensitiveKeys[normalized]; exists {
return true
}
lastDot := len(normalized) - 1
for lastDot >= 0 && normalized[lastDot] != '.' {
lastDot--
}
_, exists := handler.sensitiveKeys[normalized[lastDot+1:]]
return exists
}
func appendField(buffer *bytes.Buffer, key string, value any) {
if buffer.Len() > 1 {
buffer.WriteByte(',')
}
encodedKey, err := json.Marshal(key)
if err != nil {
encodedKey = []byte(fmt.Sprintf("%q", key))
}
encodedValue, err := json.Marshal(value)
if err != nil {
encodedValue = []byte(fmt.Sprintf("%q", fmt.Sprint(value)))
}
buffer.Write(encodedKey)
buffer.WriteByte(':')
buffer.Write(encodedValue)
}
func defaultSensitiveKeys() []string {
return []string{
"authorization",
"document_text",
"host",
"hostname",
"password",
"private_text",
"protected_text",
"secret",
"token",
"url",
"username",
}
}
func lowercaseKey(key string) string {
return strings.ToLower(key)
}