Optional serialized field has no deserialization callback to initialize it

ID

vbnet.correctness.deserialization_method_for_optionalfield

Severity

high

Remediation Complexity

medium

Remediation Risk

low

Remediation Effort

low

Resource

Serialization

Language

VB.NET

Description

Reports a field marked <OptionalField> or <OptionalFieldAttribute>, with or without arguments such as VersionAdded, declared in a type that has no <OnDeserialized> callback. Both the bare declaration and the form with an initializer are reported. A single <OnDeserialized> or <OnDeserializedAttribute> method anywhere in the declaring class satisfies the check for every optional field in that class, so the rule reports the type that has no post-deserialization hook at all rather than auditing which fields the hook assigns.

Rationale

<OptionalField> exists to let a newer version of a type read a stream written by an older one, so the case it covers is precisely the case where the field is not in the payload. What is easy to miss is that nothing else runs to fill the gap: the formatter allocates the object without calling any constructor, and field initializers are compiled into the constructors, so the = 0D written next to the declaration and the assignment made in New are both skipped. The field is left at the CLR’s zeroed default, which for a reference type is Nothing and for a numeric type is a zero that reads as a legitimate value. Code downstream is then working with a default it was never designed to see: an arithmetic path silently applies a zero rate, a lookup keyed on the field misses, or a null reference is raised several calls away from the deserialization that caused it. Because both sides of a round-trip test use the current version of the type, the field is always present in test data and the defect only appears against payloads written before the field existed - persisted state, a warm cache, a queue backlog, a client on the previous release.

The following code illustrates the pattern detected by this rule:

Public Class PriceSheet

    ' FLAGGED: Optional serialized field has no deserialization callback to initialize it
    <OptionalField(VersionAdded:=2)>
    Private discountCode As String

    ' FLAGGED: Optional serialized field has no deserialization callback to initialize it
    <OptionalField>
    Private surchargeRate As Decimal = 0D

    Public SheetId As String
    Public BasePrice As Decimal

    Public Function Total() As Decimal
        Return BasePrice * (1D + surchargeRate)
    End Function

Remediation

Add a method marked <OnDeserialized> that takes a StreamingContext parameter and assigns every optional field the value the constructor would have given it, guarding on the type default so that a value which was present in the stream is not overwritten. Keep the callback cheap and free of side effects outside the object; it runs as part of deserialization.

' Before: an old payload leaves surchargeRate at zero, the initializer never runs
<OptionalField>
Private surchargeRate As Decimal = 0.05D

' After
<OptionalField>
Private surchargeRate As Decimal = 0.05D

<OnDeserialized>
Private Sub SetDefaultsAfterDeserialization(context As StreamingContext)
    If surchargeRate = 0D Then surchargeRate = 0.05D
End Sub

Configuration

This detector does not need any configuration.