DB_PATH, HTTP_ADDR, HTTP_PORT and LOG_LEVEL parsing with validation; slog text logger with a TRACE level and token redaction; embedded idempotent schema on CGO-free SQLite (WAL, busy timeout); token store (create, list, deactivate, authenticate) and append-only audit store with ordered, limited and windowed queries; shared unit test helpers.
112 lines
2.9 KiB
Go
112 lines
2.9 KiB
Go
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
)
|
|
|
|
// LevelTrace is the custom level below DEBUG
|
|
const LevelTrace = slog.Level(-8)
|
|
|
|
// Names of the supported levels, as printed and as accepted by ParseLogLevel
|
|
var levelNames = map[slog.Level]string{
|
|
slog.LevelError: "ERROR",
|
|
slog.LevelWarn: "WARN",
|
|
slog.LevelInfo: "INFO",
|
|
slog.LevelDebug: "DEBUG",
|
|
LevelTrace: "TRACE",
|
|
}
|
|
|
|
// ParseLogLevel maps a level name (ASCII case-insensitive, exact match) to its slog level
|
|
func ParseLogLevel(s string) (slog.Level, error) {
|
|
for level, name := range levelNames {
|
|
if asciiEqualFold(s, name) {
|
|
return level, nil
|
|
}
|
|
}
|
|
return 0, errors.New("unknown log level")
|
|
}
|
|
|
|
// LevelName renders a level using the five supported names, falling back to the slog default
|
|
func LevelName(level slog.Level) string {
|
|
if name, ok := levelNames[level]; ok {
|
|
return name
|
|
}
|
|
return level.String()
|
|
}
|
|
|
|
// NewLogger builds the text logger writing to w with the given minimum level
|
|
func NewLogger(w io.Writer, level slog.Level) *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{
|
|
Level: level,
|
|
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
|
if len(groups) != 0 {
|
|
return a
|
|
}
|
|
switch a.Key {
|
|
case slog.LevelKey:
|
|
// Render the level with the custom names (TRACE instead of DEBUG-4)
|
|
if level, ok := a.Value.Any().(slog.Level); ok {
|
|
a.Value = slog.StringValue(LevelName(level))
|
|
}
|
|
case slog.TimeKey:
|
|
// Always log timestamps in UTC
|
|
if a.Value.Kind() == slog.KindTime {
|
|
a.Value = slog.TimeValue(a.Value.Time().UTC())
|
|
}
|
|
}
|
|
return a
|
|
},
|
|
}))
|
|
}
|
|
|
|
// trace emits a TRACE record
|
|
func trace(ctx context.Context, log *slog.Logger, msg string, args ...any) {
|
|
log.Log(ctx, LevelTrace, msg, args...)
|
|
}
|
|
|
|
// traceFunc emits a TRACE function-entry record and returns the matching function-exit emitter
|
|
// Usage: defer traceFunc(ctx, log, "Name")()
|
|
func traceFunc(ctx context.Context, log *slog.Logger, fn string) func() {
|
|
trace(ctx, log, "function entry", "func", fn)
|
|
return func() {
|
|
trace(ctx, log, "function exit", "func", fn)
|
|
}
|
|
}
|
|
|
|
// tokenID returns a stable, non-reversible identifier for a token, safe to log
|
|
// It is the first 8 hex characters of the SHA-256 digest of the token
|
|
func tokenID(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:4])
|
|
}
|
|
|
|
// asciiEqualFold compares two strings case-insensitively over ASCII letters only
|
|
// Non-ASCII bytes must match exactly, so Unicode case folding never applies
|
|
func asciiEqualFold(a, b string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := 0; i < len(a); i++ {
|
|
if asciiLower(a[i]) != asciiLower(b[i]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// asciiLower lowercases a single ASCII letter
|
|
func asciiLower(c byte) byte {
|
|
if c >= 'A' && c <= 'Z' {
|
|
return c + ('a' - 'A')
|
|
}
|
|
return c
|
|
}
|