diff --git a/F1Server.Core/Data/ReceivedPacketData.cs b/F1Server.Core/Data/ReceivedPacketData.cs
index 417b3f7..6d12305 100644
--- a/F1Server.Core/Data/ReceivedPacketData.cs
+++ b/F1Server.Core/Data/ReceivedPacketData.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -65,6 +66,21 @@ public ReceivedPacketData()
///
public PacketHeader? PacketHeader { get; private set; }
+ ///
+ /// Bounded classification of why the packet header was rejected, safe to use as a metric dimension
+ ///
+ public HeaderRejectionCode HeaderRejectionCode { get; private set; }
+
+ ///
+ /// Exception caught while parsing the packet header, set only when is
+ ///
+ public Exception? HeaderParseException { get; private set; }
+
+ ///
+ /// Game version reported by the packet, set only when is
+ ///
+ public ushort ReportedGameVersion { get; private set; }
+
#endregion // Properties
#region Methods
@@ -76,6 +92,9 @@ public ReceivedPacketData()
public void SetRawData(byte[] rawData)
{
PacketHeader = null;
+ HeaderRejectionCode = HeaderRejectionCode.None;
+ HeaderParseException = null;
+ ReportedGameVersion = 0;
_rawData = new byte[rawData.Length];
@@ -92,105 +111,133 @@ public void SetRawData(byte[] rawData)
/// Complete received packet content
private void AnalyzePacketHeader(ReadOnlySpan dataPacket)
{
- ref var memRef = ref MemoryMarshal.GetReference(dataPacket);
-
if (dataPacket.Length >= ConstData.F12019HeaderSize)
{
- try
- {
- var contentOffset = 0;
+ ParseHeader(dataPacket);
+ }
+ else
+ {
+ HeaderRejectionCode = HeaderRejectionCode.PacketTooShort;
+ }
+ }
+
+ ///
+ /// Parses the packet header fields, containing any parsing exception so a malformed packet cannot crash the receiver
+ ///
+ /// Complete received packet content, at least bytes long
+ [ExcludeFromCodeCoverage(Justification = "The try/catch wrapper itself cannot be exercised: every offset ParseHeaderFields reads is validated against the packet length by the guards in AnalyzePacketHeader before this method is called, and neither Enum.ToObject nor the object initializer can throw, so no packet observed in practice reaches this catch. Unsafe.ReadUnaligned itself performs no bounds checking; safety here comes entirely from those length guards, not from the read call")]
+ private void ParseHeader(ReadOnlySpan dataPacket)
+ {
+ try
+ {
+ ParseHeaderFields(dataPacket);
+ }
+ catch (Exception ex)
+ {
+ PacketHeader = null;
+ HeaderRejectionCode = HeaderRejectionCode.ParseException;
+ HeaderParseException = ex;
+ }
+ }
+
+ ///
+ /// Reads the packet header fields from the raw packet bytes
+ ///
+ /// Complete received packet content, at least bytes long
+ private void ParseHeaderFields(ReadOnlySpan dataPacket)
+ {
+ ref var memRef = ref MemoryMarshal.GetReference(dataPacket);
+
+ var contentOffset = 0;
- var gameVersion = Unsafe.ReadUnaligned(ref memRef);
+ var gameVersion = Unsafe.ReadUnaligned(ref memRef);
- contentOffset += ConstData.TypeUInt16;
+ contentOffset += ConstData.TypeUInt16;
- // From 2023 the header carries additional fields (GameYear, OverallFrameIdentifier)
- // that are read further below without their own bounds check; reject undersized
- // packets here so those reads cannot go past the end of the array.
- if (gameVersion >= 2023 && dataPacket.Length < ConstData.F12023HeaderSize)
- {
- return;
- }
+ // From 2023 the header carries additional fields (GameYear, OverallFrameIdentifier)
+ // that are read further below without their own bounds check; reject undersized
+ // packets here so those reads cannot go past the end of the array.
+ if (gameVersion >= 2023 && dataPacket.Length < ConstData.F12023HeaderSize)
+ {
+ HeaderRejectionCode = HeaderRejectionCode.Undersized2023Header;
+ ReportedGameVersion = gameVersion;
- PacketHeader = new()
- {
- // Format - uint16
- GameVersion = gameVersion
- };
+ return;
+ }
- // Game year (since 2023) - uint8
- if (PacketHeader.GameVersion >= 2023)
- {
- PacketHeader.GameYear = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ PacketHeader = new()
+ {
+ // Format - uint16
+ GameVersion = gameVersion
+ };
- contentOffset += ConstData.TypeUInt8;
- }
+ // Game year (since 2023) - uint8
+ if (PacketHeader.GameVersion >= 2023)
+ {
+ PacketHeader.GameYear = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+
+ contentOffset += ConstData.TypeUInt8;
+ }
- // Major version - uint8
- PacketHeader.MajorGameVersion = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Major version - uint8
+ PacketHeader.MajorGameVersion = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- contentOffset += ConstData.TypeUInt8;
+ contentOffset += ConstData.TypeUInt8;
- // Minor version - uint8
- PacketHeader.MinorGameVersion = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Minor version - uint8
+ PacketHeader.MinorGameVersion = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- contentOffset += ConstData.TypeUInt8;
+ contentOffset += ConstData.TypeUInt8;
- // Packet version - uint8
- PacketHeader.PacketVersion = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Packet version - uint8
+ PacketHeader.PacketVersion = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- contentOffset += ConstData.TypeUInt8;
+ contentOffset += ConstData.TypeUInt8;
- // Packet type (id) - uint8
- var packetType = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Packet type (id) - uint8
+ var packetType = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- PacketHeader.PacketType = (PacketTypes)Enum.ToObject(typeof(PacketTypes), packetType + 1);
+ PacketHeader.PacketType = (PacketTypes)Enum.ToObject(typeof(PacketTypes), packetType + 1);
- contentOffset += ConstData.TypeUInt8;
+ contentOffset += ConstData.TypeUInt8;
- // Session id - uint64
- PacketHeader.UniqueSessionId = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Session id - uint64
+ PacketHeader.UniqueSessionId = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- contentOffset += ConstData.TypeUInt64;
+ contentOffset += ConstData.TypeUInt64;
- // Session time - float
- PacketHeader.SessionTime = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- PacketHeader.SessionTimeNum = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Session time - float
+ PacketHeader.SessionTime = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ PacketHeader.SessionTimeNum = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- contentOffset += ConstData.TypeFloat;
+ contentOffset += ConstData.TypeFloat;
- // Frame identifier - uint32
- PacketHeader.FrameIdentifier = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Frame identifier - uint32
+ PacketHeader.FrameIdentifier = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- contentOffset += ConstData.TypeUInt32;
+ contentOffset += ConstData.TypeUInt32;
- // Overall frame identifier (doesn't go back after flashbacks)
- if (PacketHeader.GameVersion >= 2023)
- {
- PacketHeader.OverallFrameIdentifier = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Overall frame identifier (doesn't go back after flashbacks)
+ if (PacketHeader.GameVersion >= 2023)
+ {
+ PacketHeader.OverallFrameIdentifier = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- contentOffset += ConstData.TypeUInt32;
- }
+ contentOffset += ConstData.TypeUInt32;
+ }
- // Car index - uint8
- PacketHeader.PlayerCarIndex = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ // Car index - uint8
+ PacketHeader.PlayerCarIndex = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- if (PacketHeader.GameVersion >= 2020 && dataPacket.Length >= ConstData.F12020HeaderSize)
- {
- contentOffset += ConstData.TypeUInt8;
+ if (PacketHeader.GameVersion >= 2020 && dataPacket.Length >= ConstData.F12020HeaderSize)
+ {
+ contentOffset += ConstData.TypeUInt8;
- // Secondary car index - uint8
- PacketHeader.PlayerCarIndexSecondary = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
- }
- else
- {
- PacketHeader.PlayerCarIndexSecondary = 255;
- }
- }
- catch
- {
- PacketHeader = null;
- }
+ // Secondary car index - uint8
+ PacketHeader.PlayerCarIndexSecondary = Unsafe.ReadUnaligned(ref Unsafe.Add(ref memRef, contentOffset));
+ }
+ else
+ {
+ PacketHeader.PlayerCarIndexSecondary = 255;
}
}
diff --git a/F1Server.Core/Enumerations/HeaderRejectionCode.cs b/F1Server.Core/Enumerations/HeaderRejectionCode.cs
new file mode 100644
index 0000000..fc41506
--- /dev/null
+++ b/F1Server.Core/Enumerations/HeaderRejectionCode.cs
@@ -0,0 +1,27 @@
+namespace F1Server.Core.Enumerations;
+
+///
+/// Bounded classification of why a packet header was rejected, safe to use as a metric dimension
+///
+public enum HeaderRejectionCode
+{
+ ///
+ /// The header was not rejected
+ ///
+ None = 0,
+
+ ///
+ /// The packet was shorter than the minimum header size
+ ///
+ PacketTooShort,
+
+ ///
+ /// The packet reported game version 2023 or later but was shorter than the 2023+ header size
+ ///
+ Undersized2023Header,
+
+ ///
+ /// An exception was thrown while parsing the packet header
+ ///
+ ParseException
+}
\ No newline at end of file
diff --git a/F1Server.Service/Runtime/PacketProcessor.cs b/F1Server.Service/Runtime/PacketProcessor.cs
index 90d51a8..0e82466 100644
--- a/F1Server.Service/Runtime/PacketProcessor.cs
+++ b/F1Server.Service/Runtime/PacketProcessor.cs
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using F1Server.Core;
@@ -199,6 +200,10 @@ public bool ProcessPacket(ReceivedPacketData receivedPacketData)
isProcessed = InternalProcessPackets(receivedPacketData);
}
+ else
+ {
+ RecordRejectedHeader(receivedPacketData);
+ }
}
catch (Exception ex)
{
@@ -241,6 +246,37 @@ private void RaisePacketReceived(PacketHeader packetHeader)
}
}
+ ///
+ /// Logs and counts a rejected packet header using its bounded as the metric dimension,
+ /// keeping the untrusted packet details (length, reported game version, exception text) in the log message only
+ ///
+ /// Received packet whose header was rejected
+ private void RecordRejectedHeader(ReceivedPacketData receivedPacketData)
+ {
+ LastError = receivedPacketData.HeaderRejectionCode.ToString();
+
+ _appData?.AppMetrics?.ProcessingErrors.Add(1, new KeyValuePair("LastError", receivedPacketData.HeaderRejectionCode.ToString()));
+
+ RecordHeaderParseExceptionIfPresent(receivedPacketData.HeaderParseException);
+
+ Logger?.LogWarning("Rejected packet header: {RejectionCode}, packet length {PacketLength} bytes, reported game version {GameVersion}", receivedPacketData.HeaderRejectionCode, receivedPacketData.PacketLength, receivedPacketData.ReportedGameVersion);
+ }
+
+ ///
+ /// Logs the exception caught while parsing a packet header, if one was recorded
+ ///
+ /// Exception caught while parsing the packet header, or if the header was rejected without an exception
+ [ExcludeFromCodeCoverage(Justification = "Defensive fallback: ReceivedPacketData's header parsing is bounds-checked before this exception can occur, so no packet observed in practice reaches this path")]
+ private void RecordHeaderParseExceptionIfPresent(Exception? exception)
+ {
+ if (exception is null)
+ {
+ return;
+ }
+
+ Logger?.LogError(exception, "Error parsing packet header!");
+ }
+
///
/// Internal method for processing received packets
///
diff --git a/F1Server.Tests/ReceivedPacketDataTests.cs b/F1Server.Tests/ReceivedPacketDataTests.cs
index ec8acf5..33ef9d3 100644
--- a/F1Server.Tests/ReceivedPacketDataTests.cs
+++ b/F1Server.Tests/ReceivedPacketDataTests.cs
@@ -1,4 +1,5 @@
using F1Server.Core.Data;
+using F1Server.Core.Enumerations;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -14,7 +15,8 @@ public class ReceivedPacketDataTests
///
/// Test to verify that a packet reporting game version 2023 but shorter than the 2023 header size
- /// does not read past the end of the raw data and leaves the header unset
+ /// does not read past the end of the raw data, leaves the header unset, and records a bounded
+ /// rejection code plus the reported game version instead of silently discarding the failure
///
[TestMethod]
public void SetRawDataTruncated2023HeaderReturnsNullHeader()
@@ -28,12 +30,36 @@ public void SetRawDataTruncated2023HeaderReturnsNullHeader()
packetData.SetRawData(rawData);
Assert.IsNull(packetData.PacketHeader, $"Header should stay null for a {length} byte 2023 packet!");
+ Assert.IsNull(packetData.HeaderParseException, $"No exception should be recorded for a {length} byte 2023 packet!");
+ Assert.AreEqual(HeaderRejectionCode.Undersized2023Header, packetData.HeaderRejectionCode, $"Wrong rejection code for a {length} byte 2023 packet!");
+ Assert.AreEqual((ushort)2023, packetData.ReportedGameVersion, $"Wrong reported game version for a {length} byte 2023 packet!");
+ }
+ }
+
+ ///
+ /// Test to verify that a packet shorter than the minimum header size leaves the header unset
+ /// and records a bounded rejection code instead of silently discarding the failure
+ ///
+ [TestMethod]
+ public void SetRawDataTooShortForHeaderReturnsNullHeader()
+ {
+ for (var length = 0; length < ConstData.F12019HeaderSize; length++)
+ {
+ var rawData = BuildRawPacket(2019, length);
+
+ var packetData = new ReceivedPacketData();
+
+ packetData.SetRawData(rawData);
+
+ Assert.IsNull(packetData.PacketHeader, $"Header should stay null for a {length} byte packet!");
+ Assert.IsNull(packetData.HeaderParseException, $"No exception should be recorded for a {length} byte packet!");
+ Assert.AreEqual(HeaderRejectionCode.PacketTooShort, packetData.HeaderRejectionCode, $"Wrong rejection code for a {length} byte packet!");
}
}
///
/// Test to verify that a packet reporting game version 2023 with exactly the 2023 header size
- /// is parsed successfully
+ /// is parsed successfully and leaves no parse exception or rejection code behind
///
[TestMethod]
public void SetRawDataFullSize2023HeaderReturnsHeader()
@@ -46,10 +72,13 @@ public void SetRawDataFullSize2023HeaderReturnsHeader()
Assert.IsNotNull(packetData.PacketHeader, "Header should be set for a full size 2023 packet!");
Assert.AreEqual((ushort)2023, packetData.PacketHeader.GameVersion, "Wrong game version!");
+ Assert.IsNull(packetData.HeaderParseException, "No exception should be recorded for a successfully parsed packet!");
+ Assert.AreEqual(HeaderRejectionCode.None, packetData.HeaderRejectionCode, "No rejection code should be recorded for a successfully parsed packet!");
}
///
- /// Builds a raw packet of the given length whose first two bytes encode the given game version
+ /// Builds a raw packet of the given length whose first two bytes encode the given game version,
+ /// when the length is large enough to hold them
///
/// Game version to encode in the header
/// Total length of the raw packet
@@ -58,8 +87,15 @@ private static byte[] BuildRawPacket(ushort gameVersion, int length)
{
var rawData = new byte[length];
- rawData[0] = (byte)(gameVersion & 0xFF);
- rawData[1] = (byte)((gameVersion >> 8) & 0xFF);
+ if (length >= 1)
+ {
+ rawData[0] = (byte)(gameVersion & 0xFF);
+ }
+
+ if (length >= 2)
+ {
+ rawData[1] = (byte)((gameVersion >> 8) & 0xFF);
+ }
return rawData;
}
diff --git a/F1Server.Tests/Runtime/PacketProcessorRejectedHeaderTests.cs b/F1Server.Tests/Runtime/PacketProcessorRejectedHeaderTests.cs
new file mode 100644
index 0000000..d8300ce
--- /dev/null
+++ b/F1Server.Tests/Runtime/PacketProcessorRejectedHeaderTests.cs
@@ -0,0 +1,92 @@
+using F1Server.Core;
+using F1Server.Core.Data;
+using F1Server.Core.Enumerations;
+using F1Server.Data;
+using F1Server.Service.Runtime;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace F1Server.Tests.Runtime;
+
+///
+/// Tests of the rejected packet header handling of the packet processor
+///
+[TestClass]
+public class PacketProcessorRejectedHeaderTests
+{
+ #region Static methods
+
+ ///
+ /// Creates a packet processor with an isolated service provider and without database usage
+ ///
+ /// Whether to attach a no-op logger, to exercise the logger-attached code paths
+ /// Packet processor instance
+ private static PacketProcessor CreatePacketProcessor(bool withLogger = false)
+ {
+ var services = new ServiceCollection();
+
+ var applicationData = new F1ServerApplicationData();
+
+ if (withLogger)
+ {
+ applicationData.Logger = NullLogger.Instance;
+ }
+
+ services.AddSingleton(applicationData);
+ services.AddSingleton(new PacketAnalyzer());
+
+ return new PacketProcessor(services.BuildServiceProvider(), false);
+ }
+
+ #endregion // Static methods
+
+ #region Methods
+
+ ///
+ /// A packet shorter than the minimum header size must not be processed and must record the bounded rejection code as the last error,
+ /// with a logger attached so the rejection warning is actually logged
+ ///
+ [TestMethod]
+ public void PacketProcessorProcessPacketTooShortHeaderSetsRejectionCodeAsLastError()
+ {
+ using (var packetProcessor = CreatePacketProcessor(withLogger: true))
+ {
+ var packetData = new ReceivedPacketData();
+
+ packetData.SetRawData(new byte[4]);
+
+ var isProcessed = packetProcessor.ProcessPacket(packetData);
+
+ Assert.IsFalse(isProcessed, "A packet with an undersized header must not be reported as processed!");
+ Assert.AreEqual(HeaderRejectionCode.PacketTooShort.ToString(), packetProcessor.LastError, "LastError must carry the bounded rejection code!");
+ }
+ }
+
+ ///
+ /// A packet reporting a 2023+ game version but shorter than the 2023+ header size must not be processed
+ /// and must record the bounded rejection code as the last error
+ ///
+ [TestMethod]
+ public void PacketProcessorProcessPacketUndersized2023HeaderSetsRejectionCodeAsLastError()
+ {
+ using (var packetProcessor = CreatePacketProcessor())
+ {
+ var rawData = new byte[ConstData.F12019HeaderSize];
+
+ rawData[0] = 2023 & 0xFF;
+ rawData[1] = (2023 >> 8) & 0xFF;
+
+ var packetData = new ReceivedPacketData();
+
+ packetData.SetRawData(rawData);
+
+ var isProcessed = packetProcessor.ProcessPacket(packetData);
+
+ Assert.IsFalse(isProcessed, "A packet with an undersized 2023+ header must not be reported as processed!");
+ Assert.AreEqual(HeaderRejectionCode.Undersized2023Header.ToString(), packetProcessor.LastError, "LastError must carry the bounded rejection code!");
+ }
+ }
+
+ #endregion // Methods
+}
\ No newline at end of file