Files
silo-server/internal/diagnostics/contract/contract.go
Quick104andClaude Fable 5 2d5d4980de fix(diagnostics): address round-2 review findings on PR #445
- settings.go: cap the parsed cleanup interval at 7 days before converting to
  time.Duration so a huge configured value can't overflow int64 nanoseconds and
  wrap into a tiny/negative interval; add boundary tests.
- settings.go: propagate genuine settings read failures from LoadSettings
  (missing/empty -> default, error -> fail) so a transient DB error surfaces
  retryably instead of silently reporting uploads disabled or wrong quotas.
- bundle.go: validate non-manifest bundle entries while streaming with bounded
  memory -- device.json and crash/*.json must be a single JSON object,
  logs.jsonl/breadcrumbs.jsonl must be newline-delimited JSON objects with a
  per-line byte cap (new contract.MaxLogLineBytes); binary members stay opaque.
- diagnostics upload handler: extend the read deadline per-route via
  http.ResponseController.SetReadDeadline (10m) so slow mobile uploads of large
  bundles aren't cut off by the shared 30s server ReadTimeout.
- web admin download: request the ?proxy=1 streaming path directly so downloads
  work when S3Private is only server-reachable and errors can surface in-page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
2026-07-21 11:29:02 -04:00

1070 lines
30 KiB
Go

package contract
import (
"bytes"
"encoding/json"
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
"time"
)
const (
SchemaVersion = 1
MaxStackExcerptBytes = 8192
// MaxLogLineBytes bounds a single newline-delimited entry line
// (logs.jsonl / breadcrumbs.jsonl) during streaming bundle validation. It
// is well above the per-field caps a log line can legitimately reach (msg
// 2048, tag/run 128, plus registered attrs) so it never rejects a valid
// line, while keeping per-line memory bounded when validating.
MaxLogLineBytes = 64 * 1024
)
var (
manifestRequiredFields = []string{
"schema_version",
"report",
"destination",
"consent",
"device_summary",
"playback_session_ids",
"log_summary",
"archive",
}
manifestReportRequiredFields = []string{
"type",
"captured_at",
"capture_session_id",
"app_version",
"app_build",
"platform",
"os_version",
}
manifestDestinationRequiredFields = []string{"server_instance_id"}
manifestConsentRequiredFields = []string{"mode", "notice_version"}
manifestCrashRequiredFields = []string{"summary", "source", "provenance", "occurred_at"}
manifestDeviceSummaryFields = []string{"manufacturer", "model", "os", "form_factor"}
manifestLogSummaryRequiredFields = []string{"lines", "bytes_gz", "dropped_lines", "categories", "debug_logging"}
manifestArchiveRequiredFields = []string{"entries", "bytes", "uncompressed_bytes", "sha256"}
reportTypes = []string{"crash", "anr", "native_crash", "hang", "abnormal_exit", "manual"}
platforms = []string{"android", "android-tv", "ios", "tvos"}
crashSources = []string{"ueh", "exit_info", "metrickit", "exit_sentinel"}
crashProvenances = []string{"pre_failure", "post_restart", "metric_reporting_period"}
consentModes = []string{"prompt", "always", "manual"}
logLevels = []string{"V", "D", "I", "W", "E"}
logCategories = []string{"playback", "focus", "network", "lifecycle", "browse", "cast", "download", "crash", "other"}
deviceProvenances = []string{
"pre_failure",
"post_restart",
}
deviceRequiredFields = []string{"captured_at", "provenance", "identity", "display", "audio", "video_codecs", "network"}
ArchiveEntryAllowlist = []string{
"manifest.json",
"device.json",
"logs.jsonl",
"crash/summary.json",
"crash/stack.txt",
"crash/tombstone.pb",
"crash/metrickit.json",
"breadcrumbs.jsonl",
}
sha256Pattern = regexp.MustCompile(`^[A-Fa-f0-9]{64}$`)
)
type Manifest struct {
SchemaVersion int
Report Report
Destination Destination
Consent Consent
Crash *Crash
DeviceSummary DeviceSummary
PlaybackSessionIDs []string
LogSummary LogSummary
Archive Archive
}
type Report struct {
Type string
CapturedAt time.Time
CaptureSessionID string
AppVersion string
AppBuild string
Platform string
OSVersion string
ProfileID string
}
type Destination struct {
ServerInstanceID string
}
type Consent struct {
Mode string
NoticeVersion int
}
type Crash struct {
Summary string
StackExcerpt string
Thread string
Foreground *bool
Source string
Provenance string
OccurredAt time.Time
}
type DeviceSummary struct {
Manufacturer string
Model string
OS string
FormFactor string
}
type LogSummary struct {
Lines int64
BytesGz int64
DroppedLines int64
Categories []string
DebugLogging bool
}
type Archive struct {
Entries []string
// Bytes is the size of the bundle part as transmitted (the gzip stream).
Bytes int64
// UncompressedBytes is the total size of the decompressed tar stream,
// including headers, end-of-archive blocks, and record padding — the
// byte count a client observes between its tar writer and gzip writer.
UncompressedBytes int64
// SHA256 is the hex digest of the bundle part as transmitted.
SHA256 string
}
type DeviceSnapshot struct {
CapturedAt time.Time
Provenance string
Identity json.RawMessage
Display json.RawMessage
Audio json.RawMessage
VideoCodecs json.RawMessage
Network json.RawMessage
OriginalJSON json.RawMessage
}
type LogLine struct {
Timestamp time.Time
Run string
Level string
Category string
Tag string
Message string
Attrs map[string]any
}
type manifestWire struct {
SchemaVersion *int `json:"schema_version"`
Report *reportWire `json:"report"`
Destination *destinationWire `json:"destination"`
Consent *consentWire `json:"consent"`
Crash *crashWire `json:"crash"`
DeviceSummary *deviceSummaryWire `json:"device_summary"`
PlaybackSessionIDs *[]string `json:"playback_session_ids"`
LogSummary *logSummaryWire `json:"log_summary"`
Archive *archiveWire `json:"archive"`
}
type reportWire struct {
Type *string `json:"type"`
CapturedAt *string `json:"captured_at"`
CaptureSessionID *string `json:"capture_session_id"`
AppVersion *string `json:"app_version"`
AppBuild *string `json:"app_build"`
Platform *string `json:"platform"`
OSVersion *string `json:"os_version"`
ProfileID *string `json:"profile_id"`
}
type destinationWire struct {
ServerInstanceID *string `json:"server_instance_id"`
}
type consentWire struct {
Mode *string `json:"mode"`
NoticeVersion *int `json:"notice_version"`
}
type crashWire struct {
Summary *string `json:"summary"`
StackExcerpt *string `json:"stack_excerpt"`
Thread *string `json:"thread"`
Foreground *bool `json:"foreground"`
Source *string `json:"source"`
Provenance *string `json:"provenance"`
OccurredAt *string `json:"occurred_at"`
}
type deviceSummaryWire struct {
Manufacturer *string `json:"manufacturer"`
Model *string `json:"model"`
OS *string `json:"os"`
FormFactor *string `json:"form_factor"`
}
type logSummaryWire struct {
Lines *int64 `json:"lines"`
BytesGz *int64 `json:"bytes_gz"`
DroppedLines *int64 `json:"dropped_lines"`
Categories *[]string `json:"categories"`
DebugLogging *bool `json:"debug_logging"`
}
type archiveWire struct {
Entries *[]string `json:"entries"`
Bytes *int64 `json:"bytes"`
UncompressedBytes *int64 `json:"uncompressed_bytes"`
SHA256 *string `json:"sha256"`
}
type logLineWire struct {
Timestamp *string `json:"ts"`
Run *string `json:"run"`
Level *string `json:"lvl"`
Category *string `json:"cat"`
Tag *string `json:"tag"`
Message *string `json:"msg"`
Attrs json.RawMessage `json:"attrs"`
}
type attrValueType string
const (
attrString attrValueType = "string"
attrInteger attrValueType = "integer"
)
var attrRegistry = map[string]map[string]attrValueType{
"playback": {
"sink": attrString,
"fmt": attrString,
"decoder": attrString,
"width": attrInteger,
"height": attrInteger,
"hdr_mode": attrString,
"bitrate_kbps": attrInteger,
"dropped_frames": attrInteger,
"audio_underruns": attrInteger,
},
"focus": {
"target": attrString,
"action": attrString,
},
"network": {
"method": attrString,
"path": attrString,
"status": attrInteger,
"duration_ms": attrInteger,
},
"lifecycle": {
"state": attrString,
},
"crash": {
"fingerprint": attrString,
"source": attrString,
},
}
func ValidateManifest(data []byte) (Manifest, error) {
var wire manifestWire
if err := decodeJSON(data, &wire); err != nil {
return Manifest{}, err
}
if wire.SchemaVersion == nil {
return Manifest{}, requiredError("manifest.schema_version")
}
if *wire.SchemaVersion != SchemaVersion {
return Manifest{}, fieldError("manifest.schema_version", "unsupported value %d", *wire.SchemaVersion)
}
if wire.Report == nil {
return Manifest{}, requiredError("manifest.report")
}
report, err := validateReport(*wire.Report)
if err != nil {
return Manifest{}, err
}
if wire.Destination == nil {
return Manifest{}, requiredError("manifest.destination")
}
destination, err := validateDestination(*wire.Destination)
if err != nil {
return Manifest{}, err
}
if wire.Consent == nil {
return Manifest{}, requiredError("manifest.consent")
}
consent, err := validateConsent(*wire.Consent)
if err != nil {
return Manifest{}, err
}
// Per the spec the crash object is present for every event report type and
// absent for manual captures. Enforce both directions so an event report
// can't be accepted with no crash summary/source/occurred_at to diagnose.
var crash *Crash
switch {
case report.Type == "manual":
if wire.Crash != nil {
return Manifest{}, fieldError("manifest.crash", "must be absent for manual reports")
}
case wire.Crash == nil:
return Manifest{}, requiredError("manifest.crash")
default:
validated, err := validateCrash(*wire.Crash)
if err != nil {
return Manifest{}, err
}
crash = &validated
}
if wire.DeviceSummary == nil {
return Manifest{}, requiredError("manifest.device_summary")
}
deviceSummary, err := validateDeviceSummary(*wire.DeviceSummary)
if err != nil {
return Manifest{}, err
}
if wire.PlaybackSessionIDs == nil {
return Manifest{}, requiredError("manifest.playback_session_ids")
}
playbackSessionIDs, err := validateStringSlice("manifest.playback_session_ids", *wire.PlaybackSessionIDs, 20, 1, 128, false)
if err != nil {
return Manifest{}, err
}
if wire.LogSummary == nil {
return Manifest{}, requiredError("manifest.log_summary")
}
logSummary, err := validateLogSummary(*wire.LogSummary)
if err != nil {
return Manifest{}, err
}
if wire.Archive == nil {
return Manifest{}, requiredError("manifest.archive")
}
archive, err := validateArchive(*wire.Archive)
if err != nil {
return Manifest{}, err
}
return Manifest{
SchemaVersion: *wire.SchemaVersion,
Report: report,
Destination: destination,
Consent: consent,
Crash: crash,
DeviceSummary: deviceSummary,
PlaybackSessionIDs: playbackSessionIDs,
LogSummary: logSummary,
Archive: archive,
}, nil
}
func ValidateDevice(data []byte) (DeviceSnapshot, error) {
var raw map[string]json.RawMessage
if err := decodeJSON(data, &raw); err != nil {
return DeviceSnapshot{}, err
}
capturedAt, err := requiredRawDateTime(raw, "captured_at", "device.captured_at")
if err != nil {
return DeviceSnapshot{}, err
}
provenance, err := requiredRawString(raw, "provenance", "device.provenance", 1, 32)
if err != nil {
return DeviceSnapshot{}, err
}
if err := validateEnum("device.provenance", provenance, deviceProvenances); err != nil {
return DeviceSnapshot{}, err
}
identity, err := requiredRawSection(raw, "identity", "device.identity", sectionObject)
if err != nil {
return DeviceSnapshot{}, err
}
display, err := requiredRawSection(raw, "display", "device.display", sectionObject)
if err != nil {
return DeviceSnapshot{}, err
}
audio, err := requiredRawSection(raw, "audio", "device.audio", sectionObject)
if err != nil {
return DeviceSnapshot{}, err
}
videoCodecs, err := requiredRawSection(raw, "video_codecs", "device.video_codecs", sectionArray)
if err != nil {
return DeviceSnapshot{}, err
}
network, err := requiredRawSection(raw, "network", "device.network", sectionObject)
if err != nil {
return DeviceSnapshot{}, err
}
return DeviceSnapshot{
CapturedAt: capturedAt,
Provenance: provenance,
Identity: append(json.RawMessage(nil), identity...),
Display: append(json.RawMessage(nil), display...),
Audio: append(json.RawMessage(nil), audio...),
VideoCodecs: append(json.RawMessage(nil), videoCodecs...),
Network: append(json.RawMessage(nil), network...),
OriginalJSON: append(json.RawMessage(nil), bytes.TrimSpace(data)...),
}, nil
}
func ValidateLogLine(data []byte) (LogLine, error) {
var wire logLineWire
if err := decodeJSON(data, &wire); err != nil {
return LogLine{}, err
}
timestamp, err := validateDateTime("log.ts", wire.Timestamp)
if err != nil {
return LogLine{}, err
}
run, err := validateRequiredString("log.run", wire.Run, 1, 128)
if err != nil {
return LogLine{}, err
}
level, err := validateRequiredString("log.lvl", wire.Level, 1, 1)
if err != nil {
return LogLine{}, err
}
if err := validateEnum("log.lvl", level, logLevels); err != nil {
return LogLine{}, err
}
category, err := validateRequiredString("log.cat", wire.Category, 1, 32)
if err != nil {
return LogLine{}, err
}
if err := validateEnum("log.cat", category, logCategories); err != nil {
return LogLine{}, err
}
tag, err := validateRequiredString("log.tag", wire.Tag, 1, 128)
if err != nil {
return LogLine{}, err
}
msg, err := validateRequiredString("log.msg", wire.Message, 1, 2048)
if err != nil {
return LogLine{}, err
}
attrs, err := validateAttrs(category, wire.Attrs)
if err != nil {
return LogLine{}, err
}
return LogLine{
Timestamp: timestamp,
Run: run,
Level: level,
Category: category,
Tag: tag,
Message: msg,
Attrs: attrs,
}, nil
}
func ArchiveEntryAllowed(name string) bool {
return slices.Contains(ArchiveEntryAllowlist, name)
}
func validateReport(w reportWire) (Report, error) {
reportType, err := validateRequiredString("manifest.report.type", w.Type, 1, 32)
if err != nil {
return Report{}, err
}
if err := validateEnum("manifest.report.type", reportType, reportTypes); err != nil {
return Report{}, err
}
capturedAt, err := validateDateTime("manifest.report.captured_at", w.CapturedAt)
if err != nil {
return Report{}, err
}
captureSessionID, err := validateRequiredString("manifest.report.capture_session_id", w.CaptureSessionID, 1, 128)
if err != nil {
return Report{}, err
}
appVersion, err := validateRequiredString("manifest.report.app_version", w.AppVersion, 1, 64)
if err != nil {
return Report{}, err
}
appBuild, err := validateRequiredString("manifest.report.app_build", w.AppBuild, 1, 64)
if err != nil {
return Report{}, err
}
platform, err := validateRequiredString("manifest.report.platform", w.Platform, 1, 32)
if err != nil {
return Report{}, err
}
if err := validateEnum("manifest.report.platform", platform, platforms); err != nil {
return Report{}, err
}
osVersion, err := validateRequiredString("manifest.report.os_version", w.OSVersion, 1, 128)
if err != nil {
return Report{}, err
}
profileID, err := validateOptionalString("manifest.report.profile_id", w.ProfileID, 128)
if err != nil {
return Report{}, err
}
return Report{
Type: reportType,
CapturedAt: capturedAt,
CaptureSessionID: captureSessionID,
AppVersion: appVersion,
AppBuild: appBuild,
Platform: platform,
OSVersion: osVersion,
ProfileID: profileID,
}, nil
}
func validateDestination(w destinationWire) (Destination, error) {
serverInstanceID, err := validateRequiredString("manifest.destination.server_instance_id", w.ServerInstanceID, 1, 128)
if err != nil {
return Destination{}, err
}
return Destination{ServerInstanceID: serverInstanceID}, nil
}
func validateConsent(w consentWire) (Consent, error) {
mode, err := validateRequiredString("manifest.consent.mode", w.Mode, 1, 32)
if err != nil {
return Consent{}, err
}
if err := validateEnum("manifest.consent.mode", mode, consentModes); err != nil {
return Consent{}, err
}
if w.NoticeVersion == nil {
return Consent{}, requiredError("manifest.consent.notice_version")
}
if *w.NoticeVersion < 1 {
return Consent{}, fieldError("manifest.consent.notice_version", "must be >= 1")
}
return Consent{Mode: mode, NoticeVersion: *w.NoticeVersion}, nil
}
func validateCrash(w crashWire) (Crash, error) {
summary, err := validateRequiredString("manifest.crash.summary", w.Summary, 1, MaxStackExcerptBytes)
if err != nil {
return Crash{}, err
}
stackExcerpt, err := validateOptionalString("manifest.crash.stack_excerpt", w.StackExcerpt, MaxStackExcerptBytes)
if err != nil {
return Crash{}, err
}
thread, err := validateOptionalString("manifest.crash.thread", w.Thread, 128)
if err != nil {
return Crash{}, err
}
source, err := validateRequiredString("manifest.crash.source", w.Source, 1, 32)
if err != nil {
return Crash{}, err
}
if err := validateEnum("manifest.crash.source", source, crashSources); err != nil {
return Crash{}, err
}
provenance, err := validateRequiredString("manifest.crash.provenance", w.Provenance, 1, 32)
if err != nil {
return Crash{}, err
}
if err := validateEnum("manifest.crash.provenance", provenance, crashProvenances); err != nil {
return Crash{}, err
}
occurredAt, err := validateDateTime("manifest.crash.occurred_at", w.OccurredAt)
if err != nil {
return Crash{}, err
}
return Crash{
Summary: summary,
StackExcerpt: stackExcerpt,
Thread: thread,
Foreground: w.Foreground,
Source: source,
Provenance: provenance,
OccurredAt: occurredAt,
}, nil
}
func validateDeviceSummary(w deviceSummaryWire) (DeviceSummary, error) {
manufacturer, err := validateRequiredString("manifest.device_summary.manufacturer", w.Manufacturer, 1, 128)
if err != nil {
return DeviceSummary{}, err
}
model, err := validateRequiredString("manifest.device_summary.model", w.Model, 1, 128)
if err != nil {
return DeviceSummary{}, err
}
osVersion, err := validateRequiredString("manifest.device_summary.os", w.OS, 1, 128)
if err != nil {
return DeviceSummary{}, err
}
formFactor, err := validateRequiredString("manifest.device_summary.form_factor", w.FormFactor, 1, 64)
if err != nil {
return DeviceSummary{}, err
}
return DeviceSummary{
Manufacturer: manufacturer,
Model: model,
OS: osVersion,
FormFactor: formFactor,
}, nil
}
func validateLogSummary(w logSummaryWire) (LogSummary, error) {
lines, err := validateNonNegativeInt64("manifest.log_summary.lines", w.Lines)
if err != nil {
return LogSummary{}, err
}
bytesGz, err := validateNonNegativeInt64("manifest.log_summary.bytes_gz", w.BytesGz)
if err != nil {
return LogSummary{}, err
}
droppedLines, err := validateNonNegativeInt64("manifest.log_summary.dropped_lines", w.DroppedLines)
if err != nil {
return LogSummary{}, err
}
if w.Categories == nil {
return LogSummary{}, requiredError("manifest.log_summary.categories")
}
categories, err := validateStringSlice("manifest.log_summary.categories", *w.Categories, 9, 1, 32, true)
if err != nil {
return LogSummary{}, err
}
for i, category := range categories {
if err := validateEnum(fmt.Sprintf("manifest.log_summary.categories[%d]", i), category, logCategories); err != nil {
return LogSummary{}, err
}
}
if w.DebugLogging == nil {
return LogSummary{}, requiredError("manifest.log_summary.debug_logging")
}
return LogSummary{
Lines: lines,
BytesGz: bytesGz,
DroppedLines: droppedLines,
Categories: categories,
DebugLogging: *w.DebugLogging,
}, nil
}
func validateArchive(w archiveWire) (Archive, error) {
if w.Entries == nil {
return Archive{}, requiredError("manifest.archive.entries")
}
entries, err := validateStringSlice("manifest.archive.entries", *w.Entries, len(ArchiveEntryAllowlist), 1, 64, true)
if err != nil {
return Archive{}, err
}
hasManifest := false
for i, entry := range entries {
if !ArchiveEntryAllowed(entry) {
return Archive{}, fieldError(fmt.Sprintf("manifest.archive.entries[%d]", i), "unsupported value %q", entry)
}
if entry == "manifest.json" {
hasManifest = true
}
}
if !hasManifest {
return Archive{}, fieldError("manifest.archive.entries", "must include manifest.json")
}
bytesCount, err := validateNonNegativeInt64("manifest.archive.bytes", w.Bytes)
if err != nil {
return Archive{}, err
}
uncompressedBytes, err := validateNonNegativeInt64("manifest.archive.uncompressed_bytes", w.UncompressedBytes)
if err != nil {
return Archive{}, err
}
sha256Value, err := validateRequiredString("manifest.archive.sha256", w.SHA256, 64, 64)
if err != nil {
return Archive{}, err
}
if !sha256Pattern.MatchString(sha256Value) {
return Archive{}, fieldError("manifest.archive.sha256", "must be 64 hexadecimal characters")
}
return Archive{
Entries: entries,
Bytes: bytesCount,
UncompressedBytes: uncompressedBytes,
SHA256: strings.ToLower(sha256Value),
}, nil
}
type sectionKind int
const (
sectionObject sectionKind = iota
sectionArray
)
func requiredRawSection(raw map[string]json.RawMessage, key, path string, kind sectionKind) (json.RawMessage, error) {
value, ok := raw[key]
if !ok {
return nil, requiredError(path)
}
if isRawNull(value) {
return nil, fieldError(path, "must not be null")
}
if sentinel, ok, err := rawSentinel(value); err != nil {
return nil, err
} else if ok {
if sentinel != "unknown" && sentinel != "not_collected" {
return nil, fieldError(path, "unsupported sentinel %q", sentinel)
}
return append(json.RawMessage(nil), value...), nil
}
switch kind {
case sectionObject:
if err := validateDeviceObject(path, value, 0); err != nil {
return nil, err
}
case sectionArray:
if err := validateVideoCodecs(path, value); err != nil {
return nil, err
}
default:
return nil, fieldError(path, "unsupported section kind")
}
return append(json.RawMessage(nil), value...), nil
}
func validateVideoCodecs(path string, raw json.RawMessage) error {
var items []json.RawMessage
if err := decodeJSON(raw, &items); err != nil {
return fieldError(path, "must be an array or unknown/not_collected")
}
if len(items) > 128 {
return fieldError(path, "must contain <= 128 items")
}
for i, item := range items {
if err := validateDeviceObject(fmt.Sprintf("%s[%d]", path, i), item, 0); err != nil {
return err
}
}
return nil
}
func validateDeviceObject(path string, raw json.RawMessage, depth int) error {
if depth > 8 {
return fieldError(path, "is nested too deeply")
}
var obj map[string]json.RawMessage
if err := decodeJSON(raw, &obj); err != nil {
return fieldError(path, "must be an object or unknown/not_collected")
}
if len(obj) > 64 {
return fieldError(path, "must contain <= 64 properties")
}
for key, value := range obj {
if err := validateStringBytes(path+" key", key, 1, 128); err != nil {
return err
}
if err := validateDeviceValue(path+"."+key, value, depth+1); err != nil {
return err
}
}
return nil
}
func validateDeviceValue(path string, raw json.RawMessage, depth int) error {
if depth > 8 {
return fieldError(path, "is nested too deeply")
}
if isRawNull(raw) {
return nil
}
if _, ok, err := rawSentinel(raw); err != nil {
return err
} else if ok {
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return fieldError(path, "must be a valid string")
}
return validateStringBytes(path, s, 0, 512)
}
switch firstRawByte(raw) {
case '{':
return validateDeviceObject(path, raw, depth+1)
case '[':
var items []json.RawMessage
if err := decodeJSON(raw, &items); err != nil {
return fieldError(path, "must be an array")
}
if len(items) > 128 {
return fieldError(path, "must contain <= 128 items")
}
for i, item := range items {
if err := validateDeviceValue(fmt.Sprintf("%s[%d]", path, i), item, depth+1); err != nil {
return err
}
}
return nil
case '"':
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return fieldError(path, "must be a valid string")
}
return validateStringBytes(path, s, 0, 512)
case 't', 'f':
var b bool
if err := json.Unmarshal(raw, &b); err != nil {
return fieldError(path, "must be a boolean")
}
return nil
default:
var n json.Number
if err := decodeJSON(raw, &n); err != nil {
return fieldError(path, "must be a string, number, boolean, null, array, or object")
}
if _, err := strconv.ParseFloat(n.String(), 64); err != nil {
return fieldError(path, "must be a valid number")
}
return nil
}
}
func validateAttrs(category string, raw json.RawMessage) (map[string]any, error) {
if len(bytes.TrimSpace(raw)) == 0 {
return nil, nil
}
if isRawNull(raw) {
return nil, fieldError("log.attrs", "must be an object")
}
var attrs map[string]json.RawMessage
if err := decodeJSON(raw, &attrs); err != nil {
return nil, fieldError("log.attrs", "must be an object")
}
registered := attrRegistry[category]
if len(registered) == 0 {
return nil, nil
}
filtered := make(map[string]any)
for key, value := range attrs {
valueType, ok := registered[key]
if !ok {
continue
}
decoded, err := validateAttrValue("log.attrs."+key, value, valueType)
if err != nil {
return nil, err
}
filtered[key] = decoded
}
if len(filtered) == 0 {
return nil, nil
}
return filtered, nil
}
func validateAttrValue(path string, raw json.RawMessage, valueType attrValueType) (any, error) {
switch valueType {
case attrString:
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return nil, fieldError(path, "must be a string")
}
if err := validateStringBytes(path, s, 0, 512); err != nil {
return nil, err
}
return s, nil
case attrInteger:
if b := firstRawByte(raw); b != '-' && (b < '0' || b > '9') {
return nil, fieldError(path, "must be an integer")
}
var n json.Number
if err := decodeJSON(raw, &n); err != nil {
return nil, fieldError(path, "must be an integer")
}
i, err := strconv.ParseInt(n.String(), 10, 64)
if err != nil {
return nil, fieldError(path, "must be an integer")
}
return i, nil
default:
return nil, fieldError(path, "has unsupported registered type %q", valueType)
}
}
func validateDateTime(path string, value *string) (time.Time, error) {
s, err := validateRequiredString(path, value, 1, 64)
if err != nil {
return time.Time{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}, fieldError(path, "must be RFC3339 date-time")
}
return parsed, nil
}
func requiredRawDateTime(raw map[string]json.RawMessage, key, path string) (time.Time, error) {
s, err := requiredRawString(raw, key, path, 1, 64)
if err != nil {
return time.Time{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}, fieldError(path, "must be RFC3339 date-time")
}
return parsed, nil
}
func requiredRawString(raw map[string]json.RawMessage, key, path string, minBytes, maxBytes int) (string, error) {
value, ok := raw[key]
if !ok {
return "", requiredError(path)
}
var s string
if err := json.Unmarshal(value, &s); err != nil {
return "", fieldError(path, "must be a string")
}
if err := validateStringBytes(path, s, minBytes, maxBytes); err != nil {
return "", err
}
return s, nil
}
func validateRequiredString(path string, value *string, minBytes, maxBytes int) (string, error) {
if value == nil {
return "", requiredError(path)
}
if err := validateStringBytes(path, *value, minBytes, maxBytes); err != nil {
return "", err
}
return *value, nil
}
func validateOptionalString(path string, value *string, maxBytes int) (string, error) {
if value == nil {
return "", nil
}
if err := validateStringBytes(path, *value, 0, maxBytes); err != nil {
return "", err
}
return *value, nil
}
func validateStringBytes(path, value string, minBytes, maxBytes int) error {
if len(value) < minBytes {
return fieldError(path, "must be at least %d bytes", minBytes)
}
if maxBytes > 0 && len(value) > maxBytes {
return fieldError(path, "exceeds %d bytes", maxBytes)
}
return nil
}
func validateNonNegativeInt64(path string, value *int64) (int64, error) {
if value == nil {
return 0, requiredError(path)
}
if *value < 0 {
return 0, fieldError(path, "must be >= 0")
}
return *value, nil
}
func validateStringSlice(path string, values []string, maxItems, minStringBytes, maxStringBytes int, unique bool) ([]string, error) {
if maxItems > 0 && len(values) > maxItems {
return nil, fieldError(path, "must contain <= %d items", maxItems)
}
if unique {
seen := make(map[string]struct{}, len(values))
for i, value := range values {
if _, ok := seen[value]; ok {
return nil, fieldError(fmt.Sprintf("%s[%d]", path, i), "must be unique")
}
seen[value] = struct{}{}
}
}
for i, value := range values {
if err := validateStringBytes(fmt.Sprintf("%s[%d]", path, i), value, minStringBytes, maxStringBytes); err != nil {
return nil, err
}
}
return append([]string(nil), values...), nil
}
func validateEnum(path, value string, allowed []string) error {
if !slices.Contains(allowed, value) {
return fieldError(path, "unsupported value %q", value)
}
return nil
}
func decodeJSON(data []byte, out any) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(out); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return fmt.Errorf("invalid JSON: trailing data")
}
return nil
}
func rawSentinel(raw json.RawMessage) (string, bool, error) {
if firstRawByte(raw) != '"' {
return "", false, nil
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", false, fieldError("device", "contains invalid string")
}
if s == "unknown" || s == "not_collected" {
return s, true, nil
}
return s, true, nil
}
func firstRawByte(raw json.RawMessage) byte {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return 0
}
return trimmed[0]
}
func isRawNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
func requiredError(path string) error {
return fieldError(path, "required")
}
func fieldError(path, format string, args ...any) error {
return fmt.Errorf("%s: %s", path, fmt.Sprintf(format, args...))
}