package logging import ( "errors" "io" neturl "net/url" "os" "path/filepath" "strings" "sync" "time" "github.com/rs/zerolog" ) const ( logFormatConsole = "console" logFormatJSON = "json" logFormatText = "text" ConsoleTimeFormat = "15:04:05" ) // Config holds logging configuration type Config struct { Level zerolog.Level Format string // "json" or "console" TimeFormat string } // FileConfig holds file logging configuration type FileConfig struct { Enabled bool LogDir string SessionID string WriteToStderr bool // if false, logs go to file only (not stderr) } // DefaultConfig returns sensible defaults func DefaultConfig() Config { return Config{ Level: zerolog.InfoLevel, Format: logFormatConsole, TimeFormat: time.RFC3339, } } // New creates a new zerolog logger with the given configuration func New(cfg Config) zerolog.Logger { var output io.Writer = os.Stderr switch cfg.Format { case logFormatConsole: output = zerolog.ConsoleWriter{ Out: os.Stderr, TimeFormat: cfg.TimeFormat, } case logFormatJSON: // JSON is the default zerolog format output = os.Stderr } return zerolog.New(output). Level(cfg.Level). With(). Timestamp(). Logger() } // NewWithFile creates a logger that writes to stderr and/or a session log file. // The session file uses JSON format for easy parsing by the CLI logs command. // LogDir must exist before calling this function (handled by config.EnsureDirectories). // Returns the logger and a cleanup function to close the file. func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) { const ( logDirPerm = 0o700 logFilePerm = 0o600 ) var writers []io.Writer cleanup := func() {} // Stderr output (only if enabled) if fileCfg.WriteToStderr { switch cfg.Format { case logFormatConsole, logFormatText, "": writers = append(writers, zerolog.ConsoleWriter{ Out: os.Stderr, TimeFormat: cfg.TimeFormat, }) case logFormatJSON: writers = append(writers, os.Stderr) default: writers = append(writers, zerolog.ConsoleWriter{ Out: os.Stderr, TimeFormat: cfg.TimeFormat, }) } } // Session file output (JSON format for parsing) if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" { // Ensure log directory exists (LogDir is from config, may not have logs subdir). // Session logs may contain browsing metadata, so keep them user-private. if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil { return zerolog.Logger{}, nil, err } if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil { return zerolog.Logger{}, nil, err } filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID)) file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm) if err != nil { return zerolog.Logger{}, nil, err } if err := file.Chmod(logFilePerm); err != nil { _ = file.Close() return zerolog.Logger{}, nil, err } sessionWriter := newSessionFileWriter(file) writers = append(writers, sessionWriter) cleanup = func() { _ = sessionWriter.Close() } } // Fallback to discard if no writers configured if len(writers) == 0 { writers = append(writers, io.Discard) } multi := zerolog.MultiLevelWriter(writers...) ctx := zerolog.New(multi). Level(cfg.Level). With(). Timestamp() return ctx.Logger(), cleanup, nil } type sessionFileWriter struct { mu sync.Mutex file *os.File closed bool } func newSessionFileWriter(file *os.File) *sessionFileWriter { return &sessionFileWriter{file: file} } func (w *sessionFileWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() if w.closed || w.file == nil { return len(p), nil } n, err := w.file.Write(p) if errors.Is(err, os.ErrClosed) { w.closed = true w.file = nil return len(p), nil } return n, err } func (w *sessionFileWriter) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.closed || w.file == nil { return nil } err := w.file.Close() w.closed = true w.file = nil if errors.Is(err, os.ErrClosed) { return nil } return err } // NewFromEnv creates a logger based on environment variables // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info) // DUMBER_LOG_FORMAT: json, console (default: console) func NewFromEnv() zerolog.Logger { cfg := DefaultConfig() if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" { cfg.Level = ParseLevel(level) } if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" { switch format { case logFormatJSON, logFormatConsole, logFormatText: cfg.Format = format } } return New(cfg) } // NewFromConfigValues creates a logger from level and format strings. // This is used by main.go to create a logger from the config package's LoggingConfig // without creating an import cycle. func NewFromConfigValues(level, format string) zerolog.Logger { return NewFromConfigValuesWithTimeFormat(level, format, "") } // NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format. func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger { cfg := DefaultConfig() cfg.Level = ParseLevel(level) if timeFormat != "" { cfg.TimeFormat = timeFormat } switch format { case logFormatJSON: cfg.Format = logFormatJSON case logFormatConsole, logFormatText, "": cfg.Format = logFormatConsole default: cfg.Format = logFormatConsole } return New(cfg) } // ParseLevel converts a level string to zerolog.Level func ParseLevel(level string) zerolog.Level { switch level { case "trace": return zerolog.TraceLevel case "debug": return zerolog.DebugLevel case "info", "": return zerolog.InfoLevel case "warn": return zerolog.WarnLevel case "error": return zerolog.ErrorLevel case "fatal": return zerolog.FatalLevel default: return zerolog.InfoLevel } } // PermissionLogURLMaxLen is the standard max length for permission-related URL logging. const PermissionLogURLMaxLen = 96 // RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host. const RedactedURLPlaceholder = "[redacted-url]" // RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a // host are reduced to host-only; query strings, fragments, paths, credentials, // and local file paths are omitted. func RedactURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } parsed, err := neturl.Parse(raw) if err != nil { return RedactedURLPlaceholder } if parsed.Host != "" { return parsed.Hostname() } if parsed.Scheme == "about" && parsed.Opaque != "" { // Use Opaque directly instead of parsed.String() to avoid leaking // RawQuery and Fragment (Go's URL parser separates them from Opaque, // but String() reconstructs the full URL including query/fragment). return parsed.Scheme + ":" + parsed.Opaque } if parsed.Scheme != "" { return parsed.Scheme + ":" + RedactedURLPlaceholder } return RedactedURLPlaceholder } // TruncateURL redacts and then truncates a URL for logging. func TruncateURL(url string, maxLen int) string { redacted := RedactURL(url) if len(redacted) <= maxLen { return redacted } return redacted[:maxLen] + "..." } // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics. func SafeURLHost(raw string) string { parsed, err := neturl.Parse(raw) if err != nil { return "" } return parsed.Hostname() }