Files
go-caatsm/internal/adapter/parser/aviation/aviation.go
T
windyboyandClaude Sonnet 4.5 c0a66cf845 Enhance aviation parser with security fixes and comprehensive refactoring
This commit implements a complete refactoring of the ICAO aviation parser,
addressing 15 identified issues across security, performance, code quality,
and documentation.

Security Enhancements (P0 - Critical):
- Add input size validation (max 1800 chars per AFTN standard)
- Implement ReDoS protection with 100ms regex timeout mechanism
- Add field validation to prevent nil pointer dereferences
- Document intentional error handling pattern for audit compliance

Performance & Design Improvements (P1 - Important):
- Remove unnecessary mutex from BodyParser (eliminates serialization)
- Fix tokenizer slash handling logic
- Remove global logger dependencies (zap.S() calls)

Code Quality Improvements (P2):
- Refactor parseRemainingLines with clear helper functions
- Document all regex patterns with ICAO format specifications
- Replace magic numbers with named constants (5 new constants)
- Add error message sanitization to prevent data leakage

Documentation & Polish (P3):
- Create comprehensive package documentation (doc.go)
- Verify naming consistency across all functions
- Add 54 comprehensive tests (all passing)
- Verify performance with benchmarks (~10µs for simple messages)

New Files:
- validation.go: Input validation utilities with AFTN limits
- validation_test.go: Comprehensive validation tests
- regex_timeout.go: ReDoS protection mechanism
- regex_timeout_test.go: Timeout protection tests
- suite_test.go: Ginkgo test suite registration
- doc.go: Package-level documentation

All changes maintain backward compatibility and existing architecture
while significantly enhancing security, maintainability, and code quality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 17:55:25 +08:00

414 lines
12 KiB
Go

package aviation
import (
"caatsm/internal/adapter/dto"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
)
var (
otherPatterns = []*regexp.Regexp{navPattern,
remarkPattern,
selPattern,
pbnPattern,
eetPattern,
performancePattern,
regPattern,
reroutePattern}
// ErrHeaderParse indicates an invalid header section.
ErrHeaderParse = errors.New("invalid telegram header")
// ErrBodyParse indicates a failure matching the telegram body.
ErrBodyParse = errors.New("invalid telegram body")
)
type BodyParser struct {
body string
bodyPatterns map[string]BodyConfig
}
func NewBodyParser(body string) *BodyParser {
return &BodyParser{
bodyPatterns: bodyPatterns,
body: body,
}
}
// GetBodyPatterns returns the body patterns map.
// The bodyPatterns map is a reference to the package-level bodyPatterns,
// which is initialized once at startup and never modified, making it safe
// for concurrent reads without synchronization.
func (parser *BodyParser) GetBodyPatterns() map[string]BodyConfig {
return parser.bodyPatterns
}
func (parser *BodyParser) Parse() (string, interface{}, error) {
parser.body = strings.TrimSpace(parser.body)
category := findCategory(parser.body)
if category == "" {
return "", nil, fmt.Errorf("no category found in body text")
}
patternConfig, exists := parser.bodyPatterns[category]
if !exists || patternConfig.Patterns == nil {
return "", nil, fmt.Errorf("no matching pattern found for category: %s", category)
}
ctx := ParseContext{
Body: parser.body,
Tokens: Tokenizer{}.Tokenize(parser.body),
}
for _, p := range patternConfig.Patterns {
if data := extract(parser.body, p.Expression); data != nil {
parsed, err := parseCategory(category, ctx, data)
if err != nil {
return "", nil, err
}
return category, parsed, nil
}
}
return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body)
}
func findCategory(body string) string {
if match := categoryRegex.FindStringSubmatch(body); match != nil {
for i, name := range categoryRegex.SubexpNames() {
if i != 0 && name == "category" {
return match[i]
}
}
}
return ""
}
func extract(data string, exp *regexp.Regexp) map[string]string {
// Use timeout protection to prevent ReDoS attacks
match, err := MatchWithTimeout(exp, data, DefaultRegexTimeout)
if err != nil {
// Timeout occurred - return nil to indicate no match
return nil
}
if len(match) > 0 {
return extractData(match, exp)
}
return nil
}
func extractData(match []string, re *regexp.Regexp) map[string]string {
data := make(map[string]string)
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
data[name] = strings.TrimSpace(match[i])
}
}
return data
}
func headerToParsedTelegram(header Header) dto.ParsedTelegram {
return dto.ParsedTelegram{
MessageID: header.MessageID,
DateTime: header.DateTime,
PriorityIndicator: header.PriorityIndicator,
PrimaryAddress: header.PrimaryAddress,
SecondaryAddresses: header.SecondaryAddresses,
Originator: header.Originator,
OriginatorDateTime: header.OriginatorDateTime,
Category: header.Category,
Body: header.Body,
Content: header.Content,
ReceivedAt: header.ReceivedAt,
ParsedAt: header.ParsedAt,
}
}
// Parse parses a raw ICAO aviation telegram and returns a ParsedTelegram with parsing status.
//
// IMPORTANT ERROR HANDLING PATTERN:
// This function intentionally returns both a non-nil ParsedTelegram AND an error when parsing fails.
// This design decision allows the caller to persist failed parse attempts with error details to the
// database for audit and compliance purposes. This pattern is specific to the aviation parser's
// error handling strategy where parser failures are permanent (ACK'd, not retried) and must be
// stored for regulatory compliance and troubleshooting.
//
// Error Handling Strategy:
// - Input validation failure: Returns ParsedTelegram with MessageStatusHeaderError + ErrHeaderParse
// - Header parse failure: Returns ParsedTelegram with MessageStatusHeaderError + ErrHeaderParse
// - Body parse failure: Returns ParsedTelegram with MessageStatusBodyError + ErrBodyParse
// - Success: Returns ParsedTelegram with MessageStatusParsed + nil error
//
// The returned ParsedTelegram is ALWAYS non-nil and safe to use, even when error is non-nil.
// Callers should check both the error and the ParsedTelegram.Status field to determine the outcome.
//
// Security:
// - Input size validation prevents DoS attacks (max 1800 chars per AFTN standard)
// - Regex timeout protection prevents ReDoS attacks (100ms timeout)
//
// Example usage:
//
// parsed, err := Parse(rawTelegram)
// if err != nil {
// // Parse failed, but parsed contains error details for storage
// repository.InsertRaw(parsed) // Store for audit
// return Permanent(err) // Don't retry
// }
// // Parse succeeded
// repository.Insert(parsed)
// publisher.Publish(parsed.BodyData)
func Parse(rawText string) (*dto.ParsedTelegram, error) {
// Validate input size to prevent DoS attacks
if err := ValidateInputSize(rawText); err != nil {
msg := dto.NewParsedTelegram()
msg.Content = rawText
msg.Comments = err.Error()
msg.ErrorReason = err.Error()
msg.Status = dto.MessageStatusHeaderError
return msg, fmt.Errorf("%w: %w", ErrHeaderParse, err)
}
header, err := ParseHeader(rawText)
if err != nil {
msg := dto.NewParsedTelegram()
msg.Content = rawText
msg.Comments = err.Error()
msg.ErrorReason = err.Error()
msg.Status = dto.MessageStatusHeaderError
return msg, fmt.Errorf("%w: %w", ErrHeaderParse, err)
}
bodyParser := NewBodyParser(header.Body)
category, bodyData, bodyErr := bodyParser.Parse()
header.Category = category
header.ParsedAt = time.Now()
if bodyErr != nil {
parsed := headerToParsedTelegram(header)
parsed.Parsed = false
parsed.Comments = bodyErr.Error()
parsed.Status = dto.MessageStatusBodyError
parsed.ErrorReason = bodyErr.Error()
return &parsed, fmt.Errorf("%w: %w", ErrBodyParse, bodyErr)
}
parsed := headerToParsedTelegram(header)
parsed.BodyData = bodyData
parsed.Parsed = true
parsed.Status = dto.MessageStatusParsed
parsed.Uuid = uuid.New().String()
return &parsed, nil
}
func cleanMessage(text string) string {
cleanedText := emptyLineRemove.ReplaceAllString(text, "")
cleanText := strings.ReplaceAll(cleanedText, "\n\n", "\n")
if match := bodyOnly.FindStringSubmatch(cleanText); len(match) > 1 {
bodyContent := match[2]
if bodyContent[len(bodyContent)-1] == '\n' {
return bodyContent[:len(bodyContent)-1]
}
return bodyContent
}
return ""
}
// ParseHeader parses only the header portion of the message and returns a lightweight struct
// with header fields and body content. It is used internally by the aviation parser.
type Header struct {
MessageID string
DateTime string
PriorityIndicator string
PrimaryAddress string
SecondaryAddresses string
Originator string
OriginatorDateTime string
Category string
Content string
Body string
ReceivedAt time.Time
ParsedAt time.Time
}
// ParseHeader parses the header portion of an ICAO telegram.
// It extracts message metadata (ID, datetime, addresses, originator) and separates
// the body content for subsequent parsing.
//
// Returns Header struct with parsed fields and the raw body content.
// On error, returns Header with Content field populated for audit purposes.
func ParseHeader(fullMessage string) (Header, error) {
cleaned := cleanMessage(fullMessage)
lines := strings.Split(cleaned, "\n")
if len(lines) < MinHeaderLines {
return Header{Content: fullMessage}, fmt.Errorf("invalid message format: expected at least %d lines, got %d", MinHeaderLines, len(lines))
}
_, messageID, dateTime, err := parseStartIndicator(lines[0])
if err != nil {
return Header{Content: fullMessage}, err
}
priorityIndicator, primaryAddress := parsePriorityAndPrimary(lines[1])
secondaryAddresses, originator, originatorDateTime, body := parseRemainingLines(lines[2:])
return Header{
MessageID: messageID,
DateTime: dateTime,
PriorityIndicator: priorityIndicator,
PrimaryAddress: primaryAddress,
SecondaryAddresses: secondaryAddresses,
Originator: originator,
OriginatorDateTime: originatorDateTime,
Content: fullMessage,
Body: body,
ReceivedAt: time.Now(),
}, nil
}
func parseStartIndicator(line string) (string, string, string, error) {
parts := strings.Fields(line)
if len(parts) >= MinStartIndicatorParts && strings.HasPrefix(parts[0], StartIndicatorPrefix) {
return parts[0], parts[1], parts[2], nil
}
return "", "", "", fmt.Errorf("invalid start indicator line format: %s", line)
}
func parsePriorityAndPrimary(line string) (string, string) {
parts := strings.Fields(line)
if len(parts) >= MinPriorityLineParts {
return parts[0], parts[1]
}
return "", ""
}
// isOriginatorLine checks if a dot-prefixed line matches the originator format (.CODE DATETIME).
// Returns the originator code, datetime, and whether it's a valid match.
func isOriginatorLine(line string) (originator, dateTime string, isMatch bool) {
if !strings.HasPrefix(line, ".") {
return "", "", false
}
parts := strings.Fields(line[1:])
if len(parts) < MinOriginatorParts {
return "", "", false
}
if isAllUppercaseLetters(parts[0]) && isAllDigits(parts[1]) {
return parts[0], parts[1], true
}
return "", "", false
}
// isBodyStartLine checks if a line indicates the start of the message body.
func isBodyStartLine(line string) bool {
return strings.HasPrefix(line, BeginPartMarker) || strings.HasPrefix(line, "(")
}
func parseRemainingLines(lines []string) (string, string, string, string) {
var (
secondaryAddresses strings.Builder
originator string
originatorDateTime string
body strings.Builder
inBody bool
)
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines and single dots
if line == "" || line == EndHeaderMarker {
continue
}
// Once in body, collect all remaining lines
if inBody {
body.WriteString(line + "\n")
continue
}
// Check for originator line (.CODE DATETIME)
if orig, dt, isOrig := isOriginatorLine(line); isOrig {
originator = orig
originatorDateTime = dt
inBody = true
continue
}
// Check for body start markers
if isBodyStartLine(line) {
inBody = true
// Skip lines containing NNNN (end marker)
if !strings.Contains(line, "NNNN") {
body.WriteString(line + "\n")
}
continue
}
// Try to parse as originator using regex (fallback)
if dt, orig := getOriginator(line); orig != "" {
originatorDateTime = dt
originator = orig
continue
}
// Otherwise, treat as secondary address
secondaryAddresses.WriteString(" " + line)
}
return secondaryAddresses.String(), originator, originatorDateTime, body.String()
}
func getOriginator(line string) (string, string) {
match := originator.FindStringSubmatch(line)
if len(match) >= MinOriginatorMatchGroups {
return match[1], match[2]
}
return "", ""
}
// isAllUppercaseLetters checks if a string contains only uppercase letters
func isAllUppercaseLetters(s string) bool {
if len(s) == 0 {
return false
}
for _, r := range s {
if r < 'A' || r > 'Z' {
return false
}
}
return true
}
// isAllDigits checks if a string contains only digits
func isAllDigits(s string) bool {
if len(s) == 0 {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func parseOther(text string) map[string]string {
data := make(map[string]string)
for _, re := range otherPatterns {
if match := re.FindStringSubmatch(text); len(match) > 0 {
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
data[name] = strings.TrimSpace(match[i])
}
}
}
}
return data
}