534 lines
20 KiB
C#
534 lines
20 KiB
C#
// <copyright file="SuppressionWitnessIdPropertyTests.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
|
// </copyright>
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// SuppressionWitnessIdPropertyTests.cs
|
|
// Sprint: SPRINT_20260106_001_002_SCANNER
|
|
// Task: SUP-024 - Write property tests: witness ID determinism
|
|
// Description: Property-based tests ensuring witness IDs are deterministic,
|
|
// content-addressed, and follow the expected format.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Security.Cryptography;
|
|
using FluentAssertions;
|
|
using FsCheck.Xunit;
|
|
using Moq;
|
|
using StellaOps.Cryptography;
|
|
using StellaOps.Scanner.Reachability.Witnesses;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scanner.Reachability.Tests.Witnesses;
|
|
|
|
/// <summary>
|
|
/// Property-based tests for SuppressionWitness ID determinism.
|
|
/// Uses FsCheck to verify properties across many random inputs.
|
|
/// </summary>
|
|
[Trait("Category", "Property")]
|
|
public sealed class SuppressionWitnessIdPropertyTests
|
|
{
|
|
private static readonly DateTimeOffset FixedTime = new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
/// <summary>
|
|
/// Test implementation of ICryptoHash that uses real SHA256 for determinism verification.
|
|
/// </summary>
|
|
private sealed class TestCryptoHash : ICryptoHash
|
|
{
|
|
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
|
|
=> SHA256.HashData(data);
|
|
|
|
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
|
|
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
|
|
|
|
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
|
|
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
|
|
|
|
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
|
=> await SHA256.HashDataAsync(stream, cancellationToken);
|
|
|
|
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
|
=> Convert.ToHexString(await ComputeHashAsync(stream, algorithmId, cancellationToken)).ToLowerInvariant();
|
|
|
|
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
|
=> ComputeHash(data);
|
|
|
|
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
|
|
=> ComputeHashHex(data);
|
|
|
|
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
|
|
=> ComputeHashBase64(data);
|
|
|
|
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
|
=> ComputeHashAsync(stream, null, cancellationToken);
|
|
|
|
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
|
=> ComputeHashHexAsync(stream, null, cancellationToken);
|
|
|
|
public string GetAlgorithmForPurpose(string purpose)
|
|
=> "sha256";
|
|
|
|
public string GetHashPrefix(string purpose)
|
|
=> "sha256:";
|
|
|
|
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
|
=> GetHashPrefix(purpose) + ComputeHashHex(data);
|
|
}
|
|
|
|
private static SuppressionWitnessBuilder CreateBuilder()
|
|
{
|
|
var timeProvider = new Mock<TimeProvider>();
|
|
timeProvider.Setup(x => x.GetUtcNow()).Returns(FixedTime);
|
|
return new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider.Object);
|
|
}
|
|
|
|
#region Determinism Properties
|
|
|
|
[Property(MaxTest = 100)]
|
|
public bool SameInputs_AlwaysProduceSameWitnessId(string sbomDigest, string componentPurl, string vulnId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sbomDigest) ||
|
|
string.IsNullOrWhiteSpace(componentPurl) ||
|
|
string.IsNullOrWhiteSpace(vulnId))
|
|
{
|
|
return true; // Skip invalid inputs
|
|
}
|
|
|
|
var builder = CreateBuilder();
|
|
var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId);
|
|
|
|
var result1 = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult();
|
|
var result2 = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult();
|
|
|
|
return result1.WitnessId == result2.WitnessId;
|
|
}
|
|
|
|
[Property(MaxTest = 100)]
|
|
public bool DifferentSbomDigest_ProducesDifferentWitnessId(
|
|
string sbomDigest1, string sbomDigest2, string componentPurl, string vulnId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sbomDigest1) ||
|
|
string.IsNullOrWhiteSpace(sbomDigest2) ||
|
|
string.IsNullOrWhiteSpace(componentPurl) ||
|
|
string.IsNullOrWhiteSpace(vulnId) ||
|
|
sbomDigest1 == sbomDigest2)
|
|
{
|
|
return true; // Skip invalid or same inputs
|
|
}
|
|
|
|
var builder = CreateBuilder();
|
|
var request1 = CreateUnreachabilityRequest(sbomDigest1, componentPurl, vulnId);
|
|
var request2 = CreateUnreachabilityRequest(sbomDigest2, componentPurl, vulnId);
|
|
|
|
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
|
|
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
|
|
|
|
return result1.WitnessId != result2.WitnessId;
|
|
}
|
|
|
|
[Property(MaxTest = 100)]
|
|
public bool DifferentComponentPurl_ProducesDifferentWitnessId(
|
|
string sbomDigest, string componentPurl1, string componentPurl2, string vulnId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sbomDigest) ||
|
|
string.IsNullOrWhiteSpace(componentPurl1) ||
|
|
string.IsNullOrWhiteSpace(componentPurl2) ||
|
|
string.IsNullOrWhiteSpace(vulnId) ||
|
|
componentPurl1 == componentPurl2)
|
|
{
|
|
return true; // Skip invalid or same inputs
|
|
}
|
|
|
|
var builder = CreateBuilder();
|
|
var request1 = CreateUnreachabilityRequest(sbomDigest, componentPurl1, vulnId);
|
|
var request2 = CreateUnreachabilityRequest(sbomDigest, componentPurl2, vulnId);
|
|
|
|
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
|
|
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
|
|
|
|
return result1.WitnessId != result2.WitnessId;
|
|
}
|
|
|
|
[Property(MaxTest = 100)]
|
|
public bool DifferentVulnId_ProducesDifferentWitnessId(
|
|
string sbomDigest, string componentPurl, string vulnId1, string vulnId2)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sbomDigest) ||
|
|
string.IsNullOrWhiteSpace(componentPurl) ||
|
|
string.IsNullOrWhiteSpace(vulnId1) ||
|
|
string.IsNullOrWhiteSpace(vulnId2) ||
|
|
vulnId1 == vulnId2)
|
|
{
|
|
return true; // Skip invalid or same inputs
|
|
}
|
|
|
|
var builder = CreateBuilder();
|
|
var request1 = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId1);
|
|
var request2 = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId2);
|
|
|
|
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
|
|
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
|
|
|
|
return result1.WitnessId != result2.WitnessId;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Format Properties
|
|
|
|
[Property(MaxTest = 100)]
|
|
public bool WitnessId_AlwaysStartsWithSupPrefix(string sbomDigest, string componentPurl, string vulnId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sbomDigest) ||
|
|
string.IsNullOrWhiteSpace(componentPurl) ||
|
|
string.IsNullOrWhiteSpace(vulnId))
|
|
{
|
|
return true; // Skip invalid inputs
|
|
}
|
|
|
|
var builder = CreateBuilder();
|
|
var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId);
|
|
|
|
var result = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult();
|
|
|
|
return result.WitnessId.StartsWith("sup:sha256:");
|
|
}
|
|
|
|
[Property(MaxTest = 100)]
|
|
public bool WitnessId_ContainsValidHexDigest(string sbomDigest, string componentPurl, string vulnId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sbomDigest) ||
|
|
string.IsNullOrWhiteSpace(componentPurl) ||
|
|
string.IsNullOrWhiteSpace(vulnId))
|
|
{
|
|
return true; // Skip invalid inputs
|
|
}
|
|
|
|
var builder = CreateBuilder();
|
|
var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId);
|
|
|
|
var result = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult();
|
|
|
|
// Extract hex part after "sup:sha256:"
|
|
var hexPart = result.WitnessId["sup:sha256:".Length..];
|
|
|
|
// Should be valid lowercase hex and have correct length (SHA256 = 64 hex chars)
|
|
return hexPart.Length == 64 &&
|
|
hexPart.All(c => char.IsAsciiHexDigitLower(c) || char.IsDigit(c));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Suppression Type Independence
|
|
|
|
[Property(MaxTest = 50)]
|
|
public bool DifferentSuppressionTypes_WithSameArtifactAndVuln_ProduceDifferentWitnessIds(
|
|
string sbomDigest, string componentPurl, string vulnId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sbomDigest) ||
|
|
string.IsNullOrWhiteSpace(componentPurl) ||
|
|
string.IsNullOrWhiteSpace(vulnId))
|
|
{
|
|
return true; // Skip invalid inputs
|
|
}
|
|
|
|
var builder = CreateBuilder();
|
|
|
|
var unreachableRequest = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId);
|
|
var versionRequest = new VersionRangeRequest
|
|
{
|
|
SbomDigest = sbomDigest,
|
|
ComponentPurl = componentPurl,
|
|
VulnId = vulnId,
|
|
VulnSource = "NVD",
|
|
AffectedRange = "< 2.0.0",
|
|
Justification = "Version not affected",
|
|
InstalledVersion = "2.0.0",
|
|
ComparisonResult = "not_affected",
|
|
VersionScheme = "semver",
|
|
Confidence = 1.0
|
|
};
|
|
|
|
var unreachableResult = builder.BuildUnreachableAsync(unreachableRequest).GetAwaiter().GetResult();
|
|
var versionResult = builder.BuildVersionNotAffectedAsync(versionRequest).GetAwaiter().GetResult();
|
|
|
|
// Different suppression types should produce different witness IDs
|
|
return unreachableResult.WitnessId != versionResult.WitnessId;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Content-Addressed Behavior
|
|
|
|
[Fact]
|
|
public async Task WitnessId_IncludesObservedAtInHash()
|
|
{
|
|
// The witness ID is content-addressed over the entire witness document,
|
|
// including ObservedAt. Different timestamps produce different IDs.
|
|
// This ensures audit trail integrity.
|
|
|
|
// Arrange
|
|
var time1 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var time2 = new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
|
|
|
var timeProvider1 = new Mock<TimeProvider>();
|
|
timeProvider1.Setup(x => x.GetUtcNow()).Returns(time1);
|
|
|
|
var timeProvider2 = new Mock<TimeProvider>();
|
|
timeProvider2.Setup(x => x.GetUtcNow()).Returns(time2);
|
|
|
|
var builder1 = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider1.Object);
|
|
var builder2 = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider2.Object);
|
|
|
|
var request = CreateUnreachabilityRequest("sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234");
|
|
|
|
// Act
|
|
var result1 = await builder1.BuildUnreachableAsync(request);
|
|
var result2 = await builder2.BuildUnreachableAsync(request);
|
|
|
|
// Assert - different timestamps produce different witness IDs (content-addressed)
|
|
result1.WitnessId.Should().NotBe(result2.WitnessId);
|
|
result1.ObservedAt.Should().NotBe(result2.ObservedAt);
|
|
|
|
// But both should still be valid witness IDs
|
|
result1.WitnessId.Should().StartWith("sup:sha256:");
|
|
result2.WitnessId.Should().StartWith("sup:sha256:");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WitnessId_SameTimestamp_ProducesSameId()
|
|
{
|
|
// With the same timestamp, the witness ID should be deterministic
|
|
var fixedTime = new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
var timeProvider = new Mock<TimeProvider>();
|
|
timeProvider.Setup(x => x.GetUtcNow()).Returns(fixedTime);
|
|
|
|
var builder = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider.Object);
|
|
var request = CreateUnreachabilityRequest("sbom:sha256:test", "pkg:npm/lib@1.0.0", "CVE-2026-5555");
|
|
|
|
// Act
|
|
var result1 = await builder.BuildUnreachableAsync(request);
|
|
var result2 = await builder.BuildUnreachableAsync(request);
|
|
|
|
// Assert - same inputs with same timestamp = same ID
|
|
result1.WitnessId.Should().Be(result2.WitnessId);
|
|
}
|
|
|
|
[Property(MaxTest = 50)]
|
|
public bool WitnessId_IncludesConfidenceInHash(double confidence1, double confidence2)
|
|
{
|
|
// Skip invalid doubles (infinity, NaN)
|
|
if (!double.IsFinite(confidence1) || !double.IsFinite(confidence2))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// The witness ID is content-addressed over the entire witness including confidence.
|
|
// Different confidence values produce different IDs.
|
|
|
|
// Clamp to valid range [0, 1] but ensure they're different
|
|
confidence1 = Math.Clamp(Math.Abs(confidence1) % 0.5, 0.01, 0.49);
|
|
confidence2 = Math.Clamp(Math.Abs(confidence2) % 0.5 + 0.5, 0.51, 1.0);
|
|
|
|
var builder = CreateBuilder();
|
|
|
|
var request1 = CreateUnreachabilityRequest(
|
|
"sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234",
|
|
confidence: confidence1);
|
|
var request2 = CreateUnreachabilityRequest(
|
|
"sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234",
|
|
confidence: confidence2);
|
|
|
|
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
|
|
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
|
|
|
|
// Different confidence values produce different witness IDs
|
|
return result1.WitnessId != result2.WitnessId;
|
|
}
|
|
|
|
[Property(MaxTest = 50)]
|
|
public bool WitnessId_SameConfidence_ProducesSameId(double confidence)
|
|
{
|
|
// Skip invalid doubles (infinity, NaN)
|
|
if (!double.IsFinite(confidence))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Same confidence should produce same witness ID
|
|
confidence = Math.Clamp(Math.Abs(confidence) % 1.0, 0.01, 1.0);
|
|
|
|
var builder = CreateBuilder();
|
|
|
|
var request1 = CreateUnreachabilityRequest(
|
|
"sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234",
|
|
confidence: confidence);
|
|
var request2 = CreateUnreachabilityRequest(
|
|
"sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234",
|
|
confidence: confidence);
|
|
|
|
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
|
|
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
|
|
|
|
return result1.WitnessId == result2.WitnessId;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Collision Resistance
|
|
|
|
[Fact]
|
|
public async Task GeneratedWitnessIds_AreUnique_AcrossManyInputs()
|
|
{
|
|
// Arrange
|
|
var builder = CreateBuilder();
|
|
var witnessIds = new HashSet<string>();
|
|
var iterations = 1000;
|
|
|
|
// Act
|
|
for (int i = 0; i < iterations; i++)
|
|
{
|
|
var request = CreateUnreachabilityRequest(
|
|
$"sbom:sha256:{i:x8}",
|
|
$"pkg:npm/test@{i}.0.0",
|
|
$"CVE-2026-{i:D4}");
|
|
|
|
var result = await builder.BuildUnreachableAsync(request);
|
|
witnessIds.Add(result.WitnessId);
|
|
}
|
|
|
|
// Assert - All witness IDs should be unique (no collisions)
|
|
witnessIds.Should().HaveCount(iterations);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Cross-Builder Determinism
|
|
|
|
[Fact]
|
|
public async Task DifferentBuilderInstances_SameInputs_ProduceSameWitnessId()
|
|
{
|
|
// Arrange
|
|
var builder1 = CreateBuilder();
|
|
var builder2 = CreateBuilder();
|
|
|
|
var request = CreateUnreachabilityRequest(
|
|
"sbom:sha256:determinism",
|
|
"pkg:npm/determinism@1.0.0",
|
|
"CVE-2026-0001");
|
|
|
|
// Act
|
|
var result1 = await builder1.BuildUnreachableAsync(request);
|
|
var result2 = await builder2.BuildUnreachableAsync(request);
|
|
|
|
// Assert
|
|
result1.WitnessId.Should().Be(result2.WitnessId);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region All Suppression Types Produce Valid IDs
|
|
|
|
[Fact]
|
|
public async Task AllSuppressionTypes_ProduceValidWitnessIds()
|
|
{
|
|
// Arrange
|
|
var builder = CreateBuilder();
|
|
|
|
// Act & Assert - Test each suppression type
|
|
var unreachable = await builder.BuildUnreachableAsync(new UnreachabilityRequest
|
|
{
|
|
SbomDigest = "sbom:sha256:ur",
|
|
ComponentPurl = "pkg:npm/test@1.0.0",
|
|
VulnId = "CVE-2026-0001",
|
|
VulnSource = "NVD",
|
|
AffectedRange = "< 2.0.0",
|
|
Justification = "Unreachable",
|
|
GraphDigest = "graph:sha256:def",
|
|
AnalyzedEntrypoints = 1,
|
|
UnreachableSymbol = "func",
|
|
AnalysisMethod = "static",
|
|
Confidence = 0.95
|
|
});
|
|
unreachable.WitnessId.Should().StartWith("sup:sha256:");
|
|
|
|
var patched = await builder.BuildPatchedSymbolAsync(new PatchedSymbolRequest
|
|
{
|
|
SbomDigest = "sbom:sha256:ps",
|
|
ComponentPurl = "pkg:deb/openssl@1.1.1",
|
|
VulnId = "CVE-2026-0002",
|
|
VulnSource = "Debian",
|
|
AffectedRange = "<= 1.1.0",
|
|
Justification = "Backported",
|
|
VulnerableSymbol = "old_func",
|
|
PatchedSymbol = "new_func",
|
|
SymbolDiff = "diff",
|
|
PatchRef = "debian/patches/fix.patch",
|
|
Confidence = 0.99
|
|
});
|
|
patched.WitnessId.Should().StartWith("sup:sha256:");
|
|
|
|
var functionAbsent = await builder.BuildFunctionAbsentAsync(new FunctionAbsentRequest
|
|
{
|
|
SbomDigest = "sbom:sha256:fa",
|
|
ComponentPurl = "pkg:generic/app@3.0.0",
|
|
VulnId = "CVE-2026-0003",
|
|
VulnSource = "GitHub",
|
|
AffectedRange = "< 3.0.0",
|
|
Justification = "Function removed",
|
|
FunctionName = "deprecated_api",
|
|
BinaryDigest = "binary:sha256:123",
|
|
VerificationMethod = "symbol-table",
|
|
Confidence = 1.0
|
|
});
|
|
functionAbsent.WitnessId.Should().StartWith("sup:sha256:");
|
|
|
|
var versionNotAffected = await builder.BuildVersionNotAffectedAsync(new VersionRangeRequest
|
|
{
|
|
SbomDigest = "sbom:sha256:vna",
|
|
ComponentPurl = "pkg:pypi/django@4.2.0",
|
|
VulnId = "CVE-2026-0004",
|
|
VulnSource = "OSV",
|
|
AffectedRange = ">= 3.0.0, < 4.0.0",
|
|
Justification = "Version outside range",
|
|
InstalledVersion = "4.2.0",
|
|
ComparisonResult = "not_affected",
|
|
VersionScheme = "semver",
|
|
Confidence = 1.0
|
|
});
|
|
versionNotAffected.WitnessId.Should().StartWith("sup:sha256:");
|
|
|
|
// Verify all IDs are unique
|
|
var allIds = new[] { unreachable.WitnessId, patched.WitnessId, functionAbsent.WitnessId, versionNotAffected.WitnessId };
|
|
allIds.Should().OnlyHaveUniqueItems();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
private static UnreachabilityRequest CreateUnreachabilityRequest(
|
|
string sbomDigest,
|
|
string componentPurl,
|
|
string vulnId,
|
|
double confidence = 0.95)
|
|
{
|
|
return new UnreachabilityRequest
|
|
{
|
|
SbomDigest = sbomDigest,
|
|
ComponentPurl = componentPurl,
|
|
VulnId = vulnId,
|
|
VulnSource = "NVD",
|
|
AffectedRange = "< 2.0.0",
|
|
Justification = "Property test",
|
|
GraphDigest = "graph:sha256:fixed",
|
|
AnalyzedEntrypoints = 1,
|
|
UnreachableSymbol = "vulnerable_func",
|
|
AnalysisMethod = "static",
|
|
Confidence = confidence
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|