package generatedtext import ( "bytes" "encoding/json" "fmt" "unicode/utf8" ) const ( // MaxGeneratedTextBytes is the largest provider response accepted for one // generated-text report before JSON decoding begins. MaxGeneratedTextBytes = 64 * 1024 maxGeneratedTextCharacters = 20_000 maxGeneratedTextStringCharacters = 4_000 maxHourlyDiscussionCharacters = 12_000 maxDayStyleDiscussionParagraphs = 12 maxDayStyleDiscussionParagraphChars = 4_000 ) // ValidateRawOutput rejects generated JSON before it is decoded or retained. func ValidateRawOutput(data []byte) error { if len(data) > MaxGeneratedTextBytes { return fmt.Errorf("generated text exceeds the %d-byte limit", MaxGeneratedTextBytes) } return nil } func validateGeneratedTextSize(data []byte, name string) error { if err := ValidateRawOutput(data); err != nil { return fmt.Errorf("%s %w", name, err) } return nil } func validateGeneratedTextField(name, field string, raw json.RawMessage, total *int) error { switch field { case "summary", "precipitation_timing": return validateGeneratedTextString(name, field, raw, maxGeneratedTextStringCharacters, total) case "forecast_discussion": if name == "hourly" { return validateGeneratedTextString(name, field, raw, maxHourlyDiscussionCharacters, total) } return validateDayStyleDiscussion(name, raw, total) default: return fmt.Errorf("decode %s generated text: unsupported field", name) } } func validateGeneratedTextString(name, field string, raw json.RawMessage, limit int, total *int) error { if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return fmt.Errorf("%s generated text %s must be a string", name, generatedTextFieldLabel(field)) } var value string if err := json.Unmarshal(raw, &value); err != nil { return fmt.Errorf("%s generated text %s must be a string", name, generatedTextFieldLabel(field)) } characters := utf8.RuneCountInString(value) if characters > limit { return fmt.Errorf("%s generated text %s exceeds the %d-character limit", name, generatedTextFieldLabel(field), limit) } return addGeneratedTextCharacters(name, characters, total) } func validateDayStyleDiscussion(name string, raw json.RawMessage, total *int) error { decoder := json.NewDecoder(bytes.NewReader(raw)) token, err := decoder.Token() if err != nil { return fmt.Errorf("%s generated text forecast discussion must be an array", name) } if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' { return fmt.Errorf("%s generated text forecast discussion must be an array", name) } paragraphs := 0 for decoder.More() { paragraphs++ if paragraphs > maxDayStyleDiscussionParagraphs { return fmt.Errorf("%s generated text forecast discussion exceeds the %d-paragraph limit", name, maxDayStyleDiscussionParagraphs) } var rawParagraph json.RawMessage if err := decoder.Decode(&rawParagraph); err != nil || bytes.Equal(bytes.TrimSpace(rawParagraph), []byte("null")) { return fmt.Errorf("%s generated text forecast discussion paragraphs must be strings", name) } var paragraph string if err := json.Unmarshal(rawParagraph, ¶graph); err != nil { return fmt.Errorf("%s generated text forecast discussion paragraphs must be strings", name) } characters := utf8.RuneCountInString(paragraph) if characters > maxDayStyleDiscussionParagraphChars { return fmt.Errorf("%s generated text forecast discussion paragraphs exceed the %d-character limit", name, maxDayStyleDiscussionParagraphChars) } if err := addGeneratedTextCharacters(name, characters, total); err != nil { return err } } if _, err := decoder.Token(); err != nil { return fmt.Errorf("%s generated text forecast discussion must be an array", name) } return nil } func addGeneratedTextCharacters(name string, characters int, total *int) error { *total += characters if *total > maxGeneratedTextCharacters { return fmt.Errorf("%s generated text exceeds the %d-character limit", name, maxGeneratedTextCharacters) } return nil } func generatedTextFieldLabel(field string) string { if field == "forecast_discussion" { return "forecast discussion" } if field == "precipitation_timing" { return "precipitation timing" } return field }