- 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
1070 lines
30 KiB
Go
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...))
|
|
}
|