1package logging
2
3import (
4 "errors"
5 "io"
6 neturl "net/url"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/rs/zerolog"
14)
15
16const (
17 logFormatConsole = "console"
18 logFormatJSON = "json"
19 logFormatText = "text"
20 ConsoleTimeFormat = "15:04:05"
21)
22
23// Config holds logging configuration
24type Config struct {
25 Level zerolog.Level
26 Format string // "json" or "console"
27 TimeFormat string
28}
29
30// FileConfig holds file logging configuration
31type FileConfig struct {
32 Enabled bool
33 LogDir string
34 SessionID string
35 WriteToStderr bool // if false, logs go to file only (not stderr)
36}
37
38// DefaultConfig returns sensible defaults
39func DefaultConfig() Config {
40 return Config{
41 Level: zerolog.InfoLevel,
42 Format: logFormatConsole,
43 TimeFormat: time.RFC3339,
44 }
45}
46
47// New creates a new zerolog logger with the given configuration
48func New(cfg Config) zerolog.Logger {
49 var output io.Writer = os.Stderr
50
51 switch cfg.Format {
52 case logFormatConsole:
53 output = zerolog.ConsoleWriter{
54 Out: os.Stderr,
55 TimeFormat: cfg.TimeFormat,
56 }
57 case logFormatJSON:
58 // JSON is the default zerolog format
59 output = os.Stderr
60 }
61
62 return zerolog.New(output).
63 Level(cfg.Level).
64 With().
65 Timestamp().
66 Logger()
67}
68
69// NewWithFile creates a logger that writes to stderr and/or a session log file.
70// The session file uses JSON format for easy parsing by the CLI logs command.
71// LogDir must exist before calling this function (handled by config.EnsureDirectories).
72// Returns the logger and a cleanup function to close the file.
73func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
74 const (
75 logDirPerm = 0o700
76 logFilePerm = 0o600
77 )
78 var writers []io.Writer
79 cleanup := func() {}
80
81 // Stderr output (only if enabled)
82 if fileCfg.WriteToStderr {
83 switch cfg.Format {
84 case logFormatConsole, logFormatText, "":
85 writers = append(writers, zerolog.ConsoleWriter{
86 Out: os.Stderr,
87 TimeFormat: cfg.TimeFormat,
88 })
89 case logFormatJSON:
90 writers = append(writers, os.Stderr)
91 default:
92 writers = append(writers, zerolog.ConsoleWriter{
93 Out: os.Stderr,
94 TimeFormat: cfg.TimeFormat,
95 })
96 }
97 }
98
99 // Session file output (JSON format for parsing)
100 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
101 // Ensure log directory exists (LogDir is from config, may not have logs subdir).
102 // Session logs may contain browsing metadata, so keep them user-private.
103 if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {
104 return zerolog.Logger{}, nil, err
105 }
106 if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil {
107 return zerolog.Logger{}, nil, err
108 }
109
110 filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
111 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
112 if err != nil {
113 return zerolog.Logger{}, nil, err
114 }
115 if err := file.Chmod(logFilePerm); err != nil {
116 _ = file.Close()
117 return zerolog.Logger{}, nil, err
118 }
119
120 sessionWriter := newSessionFileWriter(file)
121 writers = append(writers, sessionWriter)
122 cleanup = func() { _ = sessionWriter.Close() }
123 }
124
125 // Fallback to discard if no writers configured
126 if len(writers) == 0 {
127 writers = append(writers, io.Discard)
128 }
129
130 multi := zerolog.MultiLevelWriter(writers...)
131 ctx := zerolog.New(multi).
132 Level(cfg.Level).
133 With().
134 Timestamp()
135
136 return ctx.Logger(), cleanup, nil
137}
138
139type sessionFileWriter struct {
140 mu sync.Mutex
141 file *os.File
142 closed bool
143}
144
145func newSessionFileWriter(file *os.File) *sessionFileWriter {
146 return &sessionFileWriter{file: file}
147}
148
149func (w *sessionFileWriter) Write(p []byte) (int, error) {
150 w.mu.Lock()
151 defer w.mu.Unlock()
152
153 if w.closed || w.file == nil {
154 return len(p), nil
155 }
156
157 n, err := w.file.Write(p)
158 if errors.Is(err, os.ErrClosed) {
159 w.closed = true
160 w.file = nil
161 return len(p), nil
162 }
163
164 return n, err
165}
166
167func (w *sessionFileWriter) Close() error {
168 w.mu.Lock()
169 defer w.mu.Unlock()
170
171 if w.closed || w.file == nil {
172 return nil
173 }
174
175 err := w.file.Close()
176 w.closed = true
177 w.file = nil
178 if errors.Is(err, os.ErrClosed) {
179 return nil
180 }
181 return err
182}
183
184// NewFromEnv creates a logger based on environment variables
185// DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)
186// DUMBER_LOG_FORMAT: json, console (default: console)
187func NewFromEnv() zerolog.Logger {
188 cfg := DefaultConfig()
189
190 if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
191 cfg.Level = ParseLevel(level)
192 }
193
194 if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" {
195 switch format {
196 case logFormatJSON, logFormatConsole, logFormatText:
197 cfg.Format = format
198 }
199 }
200
201 return New(cfg)
202}
203
204// NewFromConfigValues creates a logger from level and format strings.
205// This is used by main.go to create a logger from the config package's LoggingConfig
206// without creating an import cycle.
207func NewFromConfigValues(level, format string) zerolog.Logger {
208 return NewFromConfigValuesWithTimeFormat(level, format, "")
209}
210
211// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
212func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
213 cfg := DefaultConfig()
214 cfg.Level = ParseLevel(level)
215 if timeFormat != "" {
216 cfg.TimeFormat = timeFormat
217 }
218
219 switch format {
220 case logFormatJSON:
221 cfg.Format = logFormatJSON
222 case logFormatConsole, logFormatText, "":
223 cfg.Format = logFormatConsole
224 default:
225 cfg.Format = logFormatConsole
226 }
227
228 return New(cfg)
229}
230
231// ParseLevel converts a level string to zerolog.Level
232func ParseLevel(level string) zerolog.Level {
233 switch level {
234 case "trace":
235 return zerolog.TraceLevel
236 case "debug":
237 return zerolog.DebugLevel
238 case "info", "":
239 return zerolog.InfoLevel
240 case "warn":
241 return zerolog.WarnLevel
242 case "error":
243 return zerolog.ErrorLevel
244 case "fatal":
245 return zerolog.FatalLevel
246 default:
247 return zerolog.InfoLevel
248 }
249}
250
251// PermissionLogURLMaxLen is the standard max length for permission-related URL logging.
252const PermissionLogURLMaxLen = 96
253
254// RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host.
255const RedactedURLPlaceholder = "[redacted-url]"
256
257// RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a
258// host are reduced to host-only; query strings, fragments, paths, credentials,
259// and local file paths are omitted.
260func RedactURL(raw string) string {
261 raw = strings.TrimSpace(raw)
262 if raw == "" {
263 return ""
264 }
265
266 parsed, err := neturl.Parse(raw)
267 if err != nil {
268 return RedactedURLPlaceholder
269 }
270 if parsed.Host != "" {
271 return parsed.Hostname()
272 }
273 if parsed.Scheme == "about" && parsed.Opaque != "" {
274 // Use Opaque directly instead of parsed.String() to avoid leaking
275 // RawQuery and Fragment (Go's URL parser separates them from Opaque,
276 // but String() reconstructs the full URL including query/fragment).
277 return parsed.Scheme + ":" + parsed.Opaque
278 }
279 if parsed.Scheme != "" {
280 return parsed.Scheme + ":" + RedactedURLPlaceholder
281 }
282 return RedactedURLPlaceholder
283}
284
285// TruncateURL redacts and then truncates a URL for logging.
286func TruncateURL(url string, maxLen int) string {
287 redacted := RedactURL(url)
288 if len(redacted) <= maxLen {
289 return redacted
290 }
291 return redacted[:maxLen] + "..."
292}
293
294// SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics.
295func SafeURLHost(raw string) string {
296 parsed, err := neturl.Parse(raw)
297 if err != nil {
298 return ""
299 }
300 return parsed.Hostname()
301}