BEJSON Library - Bash Validator

BEJSON Library - Bash Validator

code



#!/bin/bash
#===============================================================================
# Library: lib-bejson_validator.sh
# Description: BEJSON validation library for Bash/Termux environments
# Author: BEJSON Core Team
# Version: 1.0.0
# Compatibility: Bash 4.0+, Termux/Android
#===============================================================================

#-------------------------------------------------------------------------------
# SAFETY & ERROR HANDLING
#-------------------------------------------------------------------------------

set -o pipefail
set -o nounset

# Error codes
readonly E_INVALID_JSON=1
readonly E_MISSING_MANDATORY_KEY=2
readonly E_INVALID_FORMAT=3
readonly E_INVALID_VERSION=4
readonly E_INVALID_RECORDS_TYPE=5
readonly E_INVALID_FIELDS=6
readonly E_INVALID_VALUES=7
readonly E_TYPE_MISMATCH=8
readonly E_RECORD_LENGTH_MISMATCH=9
readonly E_RESERVED_KEY_COLLISION=10
readonly E_INVALID_RECORD_TYPE_PARENT=11
readonly E_NULL_VIOLATION=12
readonly E_FILE_NOT_FOUND=13
readonly E_PERMISSION_DENIED=14
readonly E_ATOMIC_WRITE_FAILED=15

# Global validation state
__BEJSON_VALIDATION_ERRORS=()
__BEJSON_VALIDATION_WARNINGS=()
__BEJSON_CURRENT_FILE=""

#-------------------------------------------------------------------------------
# UTILITY FUNCTIONS
#-------------------------------------------------------------------------------

# Check if jq is available
bejson_validator_check_dependencies() {
    if ! command -v jq &> /dev/null; then
        echo "ERROR: 'jq' is required but not installed." >&2
        echo "Please install jq: pkg install jq (Termux) or apt-get install jq" >&2
        return 1
    fi
    return 0
}

# Reset validation state
bejson_validator_reset_state() {
    __BEJSON_VALIDATION_ERRORS=()
    __BEJSON_VALIDATION_WARNINGS=()
    __BEJSON_CURRENT_FILE=""
}

# Add error to validation state
__bejson_validator_add_error() {
    local message="$1"
    local location="${2:-}"
    local context="${3:-}"
    
    local error_entry="ERROR"
    [[ -n "$location" ]] && error_entry+=" | Location: $location"
    error_entry+=" | Message: $message"
    [[ -n "$context" ]] && error_entry+=" | Context: $context"
    
    __BEJSON_VALIDATION_ERRORS+=("$error_entry")
}

# Add warning to validation state
__bejson_validator_add_warning() {
    local message="$1"
    local location="${2:-}"
    
    local warning_entry="WARNING"
    [[ -n "$location" ]] && warning_entry+=" | Location: $location"
    warning_entry+=" | Message: $message"
    
    __BEJSON_VALIDATION_WARNINGS+=("$warning_entry")
}

# Get validation errors
bejson_validator_get_errors() {
    printf '%s\n' "${__BEJSON_VALIDATION_ERRORS[@]}"
}

# Get validation warnings
bejson_validator_get_warnings() {
    printf '%s\n' "${__BEJSON_VALIDATION_WARNINGS[@]}"
}

# Check if validation has errors
bejson_validator_has_errors() {
    [[ ${#__BEJSON_VALIDATION_ERRORS[@]} -gt 0 ]]
}

# Check if validation has warnings
bejson_validator_has_warnings() {
    [[ ${#__BEJSON_VALIDATION_WARNINGS[@]} -gt 0 ]]
}

# Get error count
bejson_validator_error_count() {
    echo ${#__BEJSON_VALIDATION_ERRORS[@]}
}

# Get warning count
bejson_validator_warning_count() {
    echo ${#__BEJSON_VALIDATION_WARNINGS[@]}
}

#-------------------------------------------------------------------------------
# JSON SYNTAX VALIDATION
#-------------------------------------------------------------------------------

# Validate JSON syntax using jq
bejson_validator_check_json_syntax() {
    local input="$1"
    local is_file="${2:-false}"
    
    if [[ "$is_file" == "true" ]]; then
        if [[ ! -f "$input" ]]; then
            __bejson_validator_add_error "File not found: $input" "File System"
            return $E_FILE_NOT_FOUND
        fi
        
        if [[ ! -r "$input" ]]; then
            __bejson_validator_add_error "Permission denied: $input" "File System"
            return $E_PERMISSION_DENIED
        fi
        
        __BEJSON_CURRENT_FILE="$input"
        
        if ! jq empty "$input" 2>/dev/null; then
            local jq_error=$(jq empty "$input" 2>&1)
            __bejson_validator_add_error "Invalid JSON syntax: $jq_error" "JSON Parse"
            return $E_INVALID_JSON
        fi
    else
        if ! echo "$input" | jq empty 2>/dev/null; then
            local jq_error=$(echo "$input" | jq empty 2>&1)
            __bejson_validator_add_error "Invalid JSON syntax: $jq_error" "JSON Parse"
            return $E_INVALID_JSON
        fi
    fi
    
    return 0
}

#-------------------------------------------------------------------------------
# MANDATORY KEY VALIDATION
#-------------------------------------------------------------------------------

# Check mandatory top-level keys
bejson_validator_check_mandatory_keys() {
    local json_doc="$1"
    
    local mandatory_keys=("Format" "Format_Version" "Format_Creator" "Records_Type" "Fields" "Values")
    
    for key in "${mandatory_keys[@]}"; do
        if ! echo "$json_doc" | jq -e "has(\"$key\")" &>/dev/null; then
            __bejson_validator_add_error "Missing mandatory top-level key: '$key'" "Top-Level Keys"
            return $E_MISSING_MANDATORY_KEY
        fi
    done
    
    # Check Format value
    local format_value=$(echo "$json_doc" | jq -r '.Format // empty')
    if [[ "$format_value" != "BEJSON" ]]; then
        __bejson_validator_add_error "Invalid 'Format' value: Expected 'BEJSON', got '${format_value:-}'" "Top-Level Keys/Format"
        return $E_INVALID_FORMAT
    fi
    
    # Check Format_Version
    local version_value=$(echo "$json_doc" | jq -r '.Format_Version // empty')
    if [[ ! "$version_value" =~ ^(104|104a|104db)$ ]]; then
        __bejson_validator_add_error "Invalid 'Format_Version': Expected '104', '104a', or '104db', got '${version_value:-}'" "Top-Level Keys/Format_Version"
        return $E_INVALID_VERSION
    fi
    
    # Check Format_Creator is string
    if ! echo "$json_doc" | jq -e '.Format_Creator | type == "string"' &>/dev/null; then
        __bejson_validator_add_error "Invalid 'Format_Creator': Must be a string" "Top-Level Keys/Format_Creator"
        return $E_INVALID_FORMAT
    fi
    
    # Check Records_Type is array
    if ! echo "$json_doc" | jq -e '.Records_Type | type == "array"' &>/dev/null; then
        __bejson_validator_add_error "Invalid 'Records_Type': Must be an array" "Top-Level Keys/Records_Type"
        return $E_INVALID_RECORDS_TYPE
    fi
    
    # Check Fields is array
    if ! echo "$json_doc" | jq -e '.Fields | type == "array"' &>/dev/null; then
        __bejson_validator_add_error "Invalid 'Fields': Must be an array" "Top-Level Keys/Fields"
        return $E_INVALID_FIELDS
    fi
    
    # Check Values is array
    if ! echo "$json_doc" | jq -e '.Values | type == "array"' &>/dev/null; then
        __bejson_validator_add_error "Invalid 'Values': Must be an array" "Top-Level Keys/Values"
        return $E_INVALID_VALUES
    fi
    
    echo "$version_value"
    return 0
}

#-------------------------------------------------------------------------------
# FIELDS ARRAY VALIDATION
#-------------------------------------------------------------------------------

# Validate Fields array structure
bejson_validator_check_fields_structure() {
    local json_doc="$1"
    local version="$2"
    
    local fields_count=$(echo "$json_doc" | jq '.Fields | length')
    
    if [[ $fields_count -eq 0 ]]; then
        __bejson_validator_add_error "'Fields' array cannot be empty" "Fields Array"
        return $E_INVALID_FIELDS
    fi
    
    local field_names=()
    local i=0
    
    while [[ $i -lt $fields_count ]]; do
        local field_def=$(echo "$json_doc" | jq ".Fields[$i]")
        
        # Check field is object
        if ! echo "$field_def" | jq -e 'type == "object"' &>/dev/null; then
            __bejson_validator_add_error "Field at index $i must be an object" "Fields[$i]"
            return $E_INVALID_FIELDS
        fi
        
        # Check name exists and is string
        if ! echo "$field_def" | jq -e 'has("name") and (.name | type == "string")' &>/dev/null; then
            __bejson_validator_add_error "Field at index $i: Missing or invalid 'name' (must be string)" "Fields[$i]"
            return $E_INVALID_FIELDS
        fi
        
        local field_name=$(echo "$field_def" | jq -r '.name')
        
        # Check for duplicate names
        if [[ " ${field_names[@]} " =~ " ${field_name} " ]]; then
            __bejson_validator_add_error "Duplicate field name '$field_name' found in 'Fields' array" "Fields[$i]"
            return $E_INVALID_FIELDS
        fi
        field_names+=("$field_name")
        
        # Check type exists and is valid
        if ! echo "$field_def" | jq -e 'has("type") and (.type | type == "string")' &>/dev/null; then
            __bejson_validator_add_error "Field '$field_name' (index $i): Missing or invalid 'type' (must be string)" "Fields[$i]"
            return $E_INVALID_FIELDS
        fi
        
        local field_type=$(echo "$field_def" | jq -r '.type')
        local valid_types=("string" "integer" "number" "boolean" "array" "object")
        local is_valid_type=false
        
        for valid_type in "${valid_types[@]}"; do
            if [[ "$field_type" == "$valid_type" ]]; then
                is_valid_type=true
                break
            fi
        done
        
        if [[ "$is_valid_type" == "false" ]]; then
            __bejson_validator_add_error "Field '$field_name' (index $i): Invalid type '$field_type'. Valid types: ${valid_types[*]}" "Fields[$i]"
            return $E_INVALID_FIELDS
        fi
        
        # Version 104a: Only primitive types allowed
        if [[ "$version" == "104a" ]]; then
            if [[ "$field_type" == "array" || "$field_type" == "object" ]]; then
                __bejson_validator_add_error "Field '$field_name' (index $i): Type '$field_type' not allowed in 104a. Only primitive types (string, integer, number, boolean) permitted." "Fields[$i]"
                return $E_INVALID_FIELDS
            fi
        fi
        
        ((i++))
    done
    
    echo "$fields_count"
    return 0
}

#-------------------------------------------------------------------------------
# RECORDS_TYPE VALIDATION
#-------------------------------------------------------------------------------

# Validate Records_Type based on version
bejson_validator_check_records_type() {
    local json_doc="$1"
    local version="$2"
    
    local records_type_count=$(echo "$json_doc" | jq '.Records_Type | length')
    
    case "$version" in
        "104"|"104a")
            if [[ $records_type_count -ne 1 ]]; then
                __bejson_validator_add_error "For BEJSON $version, 'Records_Type' must contain exactly one string. Found $records_type_count entries." "Records_Type"
                return $E_INVALID_RECORDS_TYPE
            fi
            
            if ! echo "$json_doc" | jq -e '.Records_Type[0] | type == "string"' &>/dev/null; then
                __bejson_validator_add_error "For BEJSON $version, 'Records_Type[0]' must be a string" "Records_Type[0]"
                return $E_INVALID_RECORDS_TYPE
            fi
            ;;
        "104db")
            if [[ $records_type_count -lt 2 ]]; then
                __bejson_validator_add_error "For BEJSON 104db, 'Records_Type' must contain two or more unique strings. Found $records_type_count entries." "Records_Type"
                return $E_INVALID_RECORDS_TYPE
            fi
            
            # Check all are strings and unique
            local i=0
            local seen_types=()
            while [[ $i -lt $records_type_count ]]; do
                if ! echo "$json_doc" | jq -e ".Records_Type[$i] | type == \"string\"" &>/dev/null; then
                    __bejson_validator_add_error "Records_Type[$i] must be a string" "Records_Type[$i]"
                    return $E_INVALID_RECORDS_TYPE
                fi
                
                local type_val=$(echo "$json_doc" | jq -r ".Records_Type[$i]")
                if [[ " ${seen_types[@]} " =~ " ${type_val} " ]]; then
                    __bejson_validator_add_error "Duplicate type '$type_val' found in 'Records_Type'" "Records_Type"
                    return $E_INVALID_RECORDS_TYPE
                fi
                seen_types+=("$type_val")
                ((i++))
            done
            ;;
    esac
    
    return 0
}

#-------------------------------------------------------------------------------
# 104DB SPECIFIC VALIDATION
#-------------------------------------------------------------------------------

# Validate Record_Type_Parent for 104db
bejson_validator_check_record_type_parent() {
    local json_doc="$1"
    
    # Check first field is Record_Type_Parent
    local first_field=$(echo "$json_doc" | jq '.Fields[0]')
    local first_field_name=$(echo "$first_field" | jq -r '.name // empty')
    local first_field_type=$(echo "$first_field" | jq -r '.type // empty')
    
    if [[ "$first_field_name" != "Record_Type_Parent" || "$first_field_type" != "string" ]]; then
        __bejson_validator_add_error "For BEJSON 104db, the first field in 'Fields' (index 0) must be '{\"name\": \"Record_Type_Parent\", \"type\": \"string\"}'. Found: '{\"name\": \"$first_field_name\", \"type\": \"$first_field_type\"}'" "Fields[0]"
        return $E_INVALID_RECORD_TYPE_PARENT
    fi
    
    # Validate each record's Record_Type_Parent value
    local values_count=$(echo "$json_doc" | jq '.Values | length')
    local records_types=$(echo "$json_doc" | jq -r '.Records_Type[]')
    local i=0
    
    while [[ $i -lt $values_count ]]; do
        local record=$(echo "$json_doc" | jq ".Values[$i]")
        
        if ! echo "$record" | jq -e 'type == "array"' &>/dev/null; then
            __bejson_validator_add_error "Values[$i] must be an array (record)" "Values[$i]"
            return $E_INVALID_VALUES
        fi
        
        local record_type_parent=$(echo "$record" | jq -r '.[0] // empty')
        
        if [[ -z "$record_type_parent" ]]; then
            __bejson_validator_add_error "Record at 'Values' index $i: 'Record_Type_Parent' (first element) is missing or null" "Values[$i][0]"
            return $E_INVALID_RECORD_TYPE_PARENT
        fi
        
        # Check if it matches any declared type
        local is_valid_type=false
        while IFS= read -r valid_type; do
            if [[ "$record_type_parent" == "$valid_type" ]]; then
                is_valid_type=true
                break
            fi
        done <<< "$records_types"
        
        if [[ "$is_valid_type" == "false" ]]; then
            __bejson_validator_add_error "Record at 'Values' index $i: 'Record_Type_Parent' value '$record_type_parent' does not match any declared type in 'Records_Type'" "Values[$i][0]"
            return $E_INVALID_RECORD_TYPE_PARENT
        fi
        
        ((i++))
    done
    
    return 0
}

#-------------------------------------------------------------------------------
# VALUES VALIDATION
#-------------------------------------------------------------------------------

# Validate Values array structure and content
bejson_validator_check_values() {
    local json_doc="$1"
    local version="$2"
    local fields_count="$3"
    
    local values_count=$(echo "$json_doc" | jq '.Values | length')
    
    if [[ $values_count -eq 0 ]]; then
        return 0  # Empty Values is valid
    fi
    
    local i=0
    while [[ $i -lt $values_count ]]; do
        local record=$(echo "$json_doc" | jq ".Values[$i]")
        
        # Check record is array
        if ! echo "$record" | jq -e 'type == "array"' &>/dev/null; then
            __bejson_validator_add_error "Values[$i] must be an array (record)" "Values[$i]"
            return $E_INVALID_VALUES
        fi
        
        local record_length=$(echo "$record" | jq 'length')
        
        # Check record length matches fields count
        if [[ $record_length -ne $fields_count ]]; then
            __bejson_validator_add_error "Record at 'Values' index $i has $record_length elements, but 'Fields' defines $fields_count fields. All records must have the same length." "Values[$i]"
            return $E_RECORD_LENGTH_MISMATCH
        fi
        
        # Validate each field value
        local j=0
        while [[ $j -lt $fields_count ]]; do
            local field_def=$(echo "$json_doc" | jq ".Fields[$j]")
            local field_name=$(echo "$field_def" | jq -r '.name')
            local field_type=$(echo "$field_def" | jq -r '.type')
            local field_parent=$(echo "$field_def" | jq -r '.Record_Type_Parent // empty')
            
            local value=$(echo "$record" | jq ".[$j]")
            local value_type=$(echo "$value" | jq -r 'type')
            
            # Special handling for 104db Record_Type_Parent property
            if [[ "$version" == "104db" && -n "$field_parent" && "$j" -gt 0 ]]; then
                local record_type=$(echo "$record" | jq -r '.[0]')
                
                if [[ "$field_parent" != "$record_type" ]]; then
                    # Field not applicable to this record type - must be null
                    if [[ "$value" != "null" ]]; then
                        __bejson_validator_add_error "Record at 'Values' index $i (type '$record_type'), field '$field_name' (index $j): This field is defined for type '$field_parent' but not for '$record_type'. Its value must be 'null', but found '$value'." "Values[$i][$j]"
                        return $E_NULL_VIOLATION
                    fi
                    ((j++))
                    continue
                fi
            fi
            
            # Type validation (null is always valid)
            if [[ "$value" == "null" ]]; then
                ((j++))
                continue
            fi
            
            local type_valid=false
            
            case "$field_type" in
                "string")
                    [[ "$value_type" == "string" ]] && type_valid=true
                    ;;
                "integer")
                    if [[ "$value_type" == "number" ]]; then
                        # Check if it's an integer (no decimal part)
                        local num_val=$(echo "$value" | jq -r '.')
                        if [[ "$num_val" =~ ^-?[0-9]+$ ]]; then
                            type_valid=true
                        fi
                    fi
                    ;;
                "number")
                    [[ "$value_type" == "number" ]] && type_valid=true
                    ;;
                "boolean")
                    [[ "$value_type" == "boolean" ]] && type_valid=true
                    ;;
                "array")
                    [[ "$value_type" == "array" ]] && type_valid=true
                    ;;
                "object")
                    [[ "$value_type" == "object" ]] && type_valid=true
                    ;;
            esac
            
            if [[ "$type_valid" == "false" ]]; then
                local actual_value=$(echo "$value" | jq -r '.')
                __bejson_validator_add_error "Record at 'Values' index $i, field '$field_name' (index $j): Value '$actual_value' is of type '$value_type', but 'Fields' defines type '$field_type'." "Values[$i][$j]"
                return $E_TYPE_MISMATCH
            fi
            
            ((j++))
        done
        
        ((i++))
    done
    
    return 0
}

#-------------------------------------------------------------------------------
# CUSTOM HEADERS VALIDATION (104a)
#-------------------------------------------------------------------------------

# Check for custom top-level keys (104a only)
bejson_validator_check_custom_headers() {
    local json_doc="$1"
    local version="$2"
    
    local mandatory_keys=("Format" "Format_Version" "Format_Creator" "Records_Type" "Fields" "Values")
    local all_keys=$(echo "$json_doc" | jq -r 'keys[]')
    
    local has_custom=false
    
    while IFS= read -r key; do
        local is_mandatory=false
        for mandatory in "${mandatory_keys[@]}"; do
            if [[ "$key" == "$mandatory" ]]; then
                is_mandatory=true
                break
            fi
        done
        
        if [[ "$is_mandatory" == "false" ]]; then
            has_custom=true
            
            if [[ "$version" == "104" || "$version" == "104db" ]]; then
                __bejson_validator_add_error "For BEJSON $version, custom top-level key '$key' is not permitted." "Top-Level Keys/$key"
                return $E_RESERVED_KEY_COLLISION
            fi
            
            # 104a: Check for reserved key collision
            for reserved in "${mandatory_keys[@]}"; do
                if [[ "$key" == "$reserved" ]]; then
                    __bejson_validator_add_error "Custom top-level key '$key' collides with reserved BEJSON system key." "Top-Level Keys/$key"
                    return $E_RESERVED_KEY_COLLISION
                fi
            done
            
            # Warning for non-PascalCase in 104a (recommendation, not error)
            if [[ ! "$key" =~ ^[A-Z][a-zA-Z0-9_]*$ ]]; then
                __bejson_validator_add_warning "Custom top-level key '$key' does not follow recommended PascalCase naming convention." "Top-Level Keys/$key"
            fi
        fi
    done <<< "$all_keys"
    
    return 0
}

#-------------------------------------------------------------------------------
# MAIN VALIDATION FUNCTIONS
#-------------------------------------------------------------------------------

# Validate a BEJSON string
bejson_validator_validate_string() {
    local json_string="$1"
    
    bejson_validator_reset_state
    bejson_validator_check_dependencies || return $?
    
    # Step 1: JSON Syntax
    bejson_validator_check_json_syntax "$json_string" "false" || return $?
    
    # Step 2: Mandatory Keys (returns version)
    local version
    version=$(bejson_validator_check_mandatory_keys "$json_string")
    local result=$?
    [[ $result -ne 0 ]] && return $result
    
    # Step 3: Custom Headers
    bejson_validator_check_custom_headers "$json_string" "$version" || return $?
    
    # Step 4: Records_Type
    bejson_validator_check_records_type "$json_string" "$version" || return $?
    
    # Step 5: Fields Structure (returns fields_count)
    local fields_count
    fields_count=$(bejson_validator_check_fields_structure "$json_string" "$version")
    local result=$?
    [[ $result -ne 0 ]] && return $result
    
    # Step 6: 104db specific - Record_Type_Parent
    if [[ "$version" == "104db" ]]; then
        bejson_validator_check_record_type_parent "$json_string" || return $?
    fi
    
    # Step 7: Values
    bejson_validator_check_values "$json_string" "$version" "$fields_count" || return $?
    
    return 0
}

# Validate a BEJSON file
bejson_validator_validate_file() {
    local file_path="$1"
    
    bejson_validator_reset_state
    bejson_validator_check_dependencies || return $?
    
    # Read file content
    local file_content
    if ! file_content=$(cat "$file_path" 2>/dev/null); then
        __bejson_validator_add_error "Cannot read file: $file_path" "File System"
        return $E_FILE_NOT_FOUND
    fi
    
    bejson_validator_validate_string "$file_content"
    return $?
}

# Get detailed validation report
bejson_validator_get_report() {
    local json_string="$1"
    local is_file="${2:-false}"
    
    if [[ "$is_file" == "true" ]]; then
        bejson_validator_validate_file "$json_string"
    else
        bejson_validator_validate_string "$json_string"
    fi
    
    local exit_code=$?
    
    echo "=== BEJSON Validation Report ==="
    echo "Status: $([[ $exit_code -eq 0 ]] && echo 'VALID' || echo 'INVALID')"
    echo "Exit Code: $exit_code"
    echo ""
    
    local error_count=$(bejson_validator_error_count)
    local warning_count=$(bejson_validator_warning_count)
    
    echo "Errors: $error_count"
    if [[ $error_count -gt 0 ]]; then
        echo "---"
        bejson_validator_get_errors
    fi
    
    echo ""
    echo "Warnings: $warning_count"
    if [[ $warning_count -gt 0 ]]; then
        echo "---"
        bejson_validator_get_warnings
    fi
    
    return $exit_code
}

#-------------------------------------------------------------------------------
# EXPORT VALIDATION FUNCTIONS
#-------------------------------------------------------------------------------

export -f bejson_validator_check_dependencies
export -f bejson_validator_reset_state
export -f bejson_validator_get_errors
export -f bejson_validator_get_warnings
export -f bejson_validator_has_errors
export -f bejson_validator_has_warnings
export -f bejson_validator_error_count
export -f bejson_validator_warning_count
export -f bejson_validator_check_json_syntax
export -f bejson_validator_check_mandatory_keys
export -f bejson_validator_check_fields_structure
export -f bejson_validator_check_records_type
export -f bejson_validator_check_record_type_parent
export -f bejson_validator_check_values
export -f bejson_validator_check_custom_headers
export -f bejson_validator_validate_string
export -f bejson_validator_validate_file
export -f bejson_validator_get_report






Related Content