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