653 lines
20 KiB
C#
653 lines
20 KiB
C#
// -----------------------------------------------------------------------------
|
|
// BundleImportServiceTests.cs
|
|
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
|
// Task: GCB-002 - Implement offline corpus bundle import and verification
|
|
// Description: Unit tests for BundleImportService corpus bundle import and verification
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.IO.Compression;
|
|
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.BinaryIndex.GroundTruth.Reproducible.Models;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.BinaryIndex.GroundTruth.Reproducible.Tests;
|
|
|
|
public sealed class BundleImportServiceTests : IDisposable
|
|
{
|
|
private readonly string _tempDir;
|
|
private readonly string _tempBundleDir;
|
|
private readonly BundleImportService _sut;
|
|
|
|
public BundleImportServiceTests()
|
|
{
|
|
_tempDir = Path.Combine(Path.GetTempPath(), $"import-test-{Guid.NewGuid():N}");
|
|
_tempBundleDir = Path.Combine(Path.GetTempPath(), $"bundle-test-{Guid.NewGuid():N}");
|
|
|
|
Directory.CreateDirectory(_tempDir);
|
|
Directory.CreateDirectory(_tempBundleDir);
|
|
|
|
var options = Options.Create(new BundleImportOptions
|
|
{
|
|
StagingDirectory = Path.Combine(Path.GetTempPath(), "import-staging-test")
|
|
});
|
|
|
|
_sut = new BundleImportService(
|
|
options,
|
|
NullLogger<BundleImportService>.Instance);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_tempDir))
|
|
{
|
|
Directory.Delete(_tempDir, recursive: true);
|
|
}
|
|
|
|
if (Directory.Exists(_tempBundleDir))
|
|
{
|
|
Directory.Delete(_tempBundleDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
#region Validation Tests
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_NonexistentFile_ReturnsInvalid()
|
|
{
|
|
// Arrange
|
|
var bundlePath = Path.Combine(_tempDir, "nonexistent.tar.gz");
|
|
|
|
// Act
|
|
var result = await _sut.ValidateAsync(bundlePath);
|
|
|
|
// Assert
|
|
result.IsValid.Should().BeFalse();
|
|
result.Errors.Should().Contain(e => e.Contains("not found"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_ValidBundle_ReturnsValid()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundle("openssl", "CVE-2024-1234", "debian");
|
|
|
|
// Act
|
|
var result = await _sut.ValidateAsync(bundlePath);
|
|
|
|
// Assert
|
|
result.IsValid.Should().BeTrue();
|
|
result.Metadata.Should().NotBeNull();
|
|
result.Metadata!.BundleId.Should().NotBeNullOrEmpty();
|
|
result.Metadata.SchemaVersion.Should().Be("1.0.0");
|
|
result.Metadata.PairCount.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_MissingManifest_ReturnsInvalid()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundleWithoutManifest();
|
|
|
|
// Act
|
|
var result = await _sut.ValidateAsync(bundlePath);
|
|
|
|
// Assert
|
|
result.IsValid.Should().BeFalse();
|
|
result.Errors.Should().Contain(e => e.Contains("manifest"));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Import Tests
|
|
|
|
[Fact]
|
|
public async Task ImportAsync_NonexistentFile_ReturnsFailed()
|
|
{
|
|
// Arrange
|
|
var request = new BundleImportRequest
|
|
{
|
|
InputPath = Path.Combine(_tempDir, "nonexistent.tar.gz")
|
|
};
|
|
|
|
// Act
|
|
var result = await _sut.ImportAsync(request);
|
|
|
|
// Assert
|
|
result.Success.Should().BeFalse();
|
|
result.OverallStatus.Should().Be(VerificationStatus.Failed);
|
|
result.Error.Should().Contain("not found");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportAsync_ValidBundle_ReturnsSuccess()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundle("openssl", "CVE-2024-1234", "debian");
|
|
var request = new BundleImportRequest
|
|
{
|
|
InputPath = bundlePath,
|
|
VerifySignatures = false,
|
|
VerifyTimestamps = false,
|
|
VerifyDigests = true,
|
|
RunMatcher = true
|
|
};
|
|
|
|
// Act
|
|
var result = await _sut.ImportAsync(request);
|
|
|
|
// Assert
|
|
result.Success.Should().BeTrue();
|
|
result.OverallStatus.Should().Be(VerificationStatus.Passed);
|
|
result.Metadata.Should().NotBeNull();
|
|
result.DigestResult.Should().NotBeNull();
|
|
result.DigestResult!.Passed.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportAsync_WithSignatureVerification_FailsForUnsignedBundle()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundle("openssl", "CVE-2024-1234", "debian");
|
|
var request = new BundleImportRequest
|
|
{
|
|
InputPath = bundlePath,
|
|
VerifySignatures = true,
|
|
VerifyTimestamps = false,
|
|
VerifyDigests = false,
|
|
RunMatcher = false
|
|
};
|
|
|
|
// Act
|
|
var result = await _sut.ImportAsync(request);
|
|
|
|
// Assert
|
|
result.SignatureResult.Should().NotBeNull();
|
|
result.SignatureResult!.Passed.Should().BeFalse();
|
|
result.OverallStatus.Should().Be(VerificationStatus.Warning);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportAsync_WithPlaceholderSignature_FailsVerification()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundleWithPlaceholderSignature();
|
|
var request = new BundleImportRequest
|
|
{
|
|
InputPath = bundlePath,
|
|
VerifySignatures = true,
|
|
VerifyTimestamps = false,
|
|
VerifyDigests = false,
|
|
RunMatcher = false
|
|
};
|
|
|
|
// Act
|
|
var result = await _sut.ImportAsync(request);
|
|
|
|
// Assert
|
|
result.SignatureResult.Should().NotBeNull();
|
|
result.SignatureResult!.Passed.Should().BeFalse();
|
|
result.SignatureResult.Error.Should().Contain("placeholder");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportAsync_DigestMismatch_ReturnsFailed()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundleWithBadDigest();
|
|
var request = new BundleImportRequest
|
|
{
|
|
InputPath = bundlePath,
|
|
VerifySignatures = false,
|
|
VerifyTimestamps = false,
|
|
VerifyDigests = true,
|
|
RunMatcher = false
|
|
};
|
|
|
|
// Act
|
|
var result = await _sut.ImportAsync(request);
|
|
|
|
// Assert
|
|
result.Success.Should().BeFalse();
|
|
result.OverallStatus.Should().Be(VerificationStatus.Failed);
|
|
result.DigestResult.Should().NotBeNull();
|
|
result.DigestResult!.Passed.Should().BeFalse();
|
|
result.DigestResult.Mismatches.Should().NotBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportAsync_WithPairVerification_VerifiesPairs()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundle("openssl", "CVE-2024-1234", "debian");
|
|
var request = new BundleImportRequest
|
|
{
|
|
InputPath = bundlePath,
|
|
VerifySignatures = false,
|
|
VerifyTimestamps = false,
|
|
VerifyDigests = false,
|
|
RunMatcher = true
|
|
};
|
|
|
|
// Act
|
|
var result = await _sut.ImportAsync(request);
|
|
|
|
// Assert
|
|
result.PairResults.Should().HaveCount(1);
|
|
var pair = result.PairResults[0];
|
|
pair.Package.Should().Be("openssl");
|
|
pair.AdvisoryId.Should().Be("CVE-2024-1234");
|
|
pair.Passed.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportAsync_WithProgress_ReportsProgress()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundle("openssl", "CVE-2024-1234", "debian");
|
|
var progressReports = new List<BundleImportProgress>();
|
|
var progress = new Progress<BundleImportProgress>(p => progressReports.Add(p));
|
|
|
|
var request = new BundleImportRequest
|
|
{
|
|
InputPath = bundlePath,
|
|
VerifySignatures = false,
|
|
VerifyTimestamps = false,
|
|
VerifyDigests = true,
|
|
RunMatcher = true
|
|
};
|
|
|
|
// Act
|
|
var result = await _sut.ImportAsync(request, progress);
|
|
|
|
// Wait for progress reports
|
|
await Task.Delay(100);
|
|
|
|
// Assert
|
|
result.Success.Should().BeTrue();
|
|
progressReports.Should().NotBeEmpty();
|
|
progressReports.Select(p => p.Stage).Should().Contain("Extracting bundle");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportAsync_WithCancellation_ThrowsCancelled()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundle("openssl", "CVE-2024-1234", "debian");
|
|
var request = new BundleImportRequest
|
|
{
|
|
InputPath = bundlePath
|
|
};
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await cts.CancelAsync();
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<OperationCanceledException>(
|
|
() => _sut.ImportAsync(request, cancellationToken: cts.Token));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Extract Tests
|
|
|
|
[Fact]
|
|
public async Task ExtractAsync_ValidBundle_ExtractsContents()
|
|
{
|
|
// Arrange
|
|
var bundlePath = CreateTestBundle("openssl", "CVE-2024-1234", "debian");
|
|
var extractPath = Path.Combine(_tempDir, "extracted");
|
|
|
|
// Act
|
|
var resultPath = await _sut.ExtractAsync(bundlePath, extractPath);
|
|
|
|
// Assert
|
|
resultPath.Should().Be(extractPath);
|
|
Directory.Exists(extractPath).Should().BeTrue();
|
|
File.Exists(Path.Combine(extractPath, "manifest.json")).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExtractAsync_NonexistentFile_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var bundlePath = Path.Combine(_tempDir, "nonexistent.tar.gz");
|
|
var extractPath = Path.Combine(_tempDir, "extracted");
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<FileNotFoundException>(
|
|
() => _sut.ExtractAsync(bundlePath, extractPath));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Report Generation Tests
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_MarkdownFormat_GeneratesMarkdown()
|
|
{
|
|
// Arrange
|
|
var result = CreateTestImportResult();
|
|
var outputPath = Path.Combine(_tempDir, "report");
|
|
|
|
// Act
|
|
var reportPath = await _sut.GenerateReportAsync(
|
|
result,
|
|
BundleReportFormat.Markdown,
|
|
outputPath);
|
|
|
|
// Assert
|
|
reportPath.Should().EndWith(".md");
|
|
File.Exists(reportPath).Should().BeTrue();
|
|
var content = await File.ReadAllTextAsync(reportPath);
|
|
content.Should().Contain("# Bundle Verification Report");
|
|
content.Should().Contain("PASSED");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_JsonFormat_GeneratesJson()
|
|
{
|
|
// Arrange
|
|
var result = CreateTestImportResult();
|
|
var outputPath = Path.Combine(_tempDir, "report");
|
|
|
|
// Act
|
|
var reportPath = await _sut.GenerateReportAsync(
|
|
result,
|
|
BundleReportFormat.Json,
|
|
outputPath);
|
|
|
|
// Assert
|
|
reportPath.Should().EndWith(".json");
|
|
File.Exists(reportPath).Should().BeTrue();
|
|
var content = await File.ReadAllTextAsync(reportPath);
|
|
var json = JsonDocument.Parse(content);
|
|
json.RootElement.GetProperty("success").GetBoolean().Should().BeTrue();
|
|
json.RootElement.GetProperty("overallStatus").GetString().Should().Be("Passed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_HtmlFormat_GeneratesHtml()
|
|
{
|
|
// Arrange
|
|
var result = CreateTestImportResult();
|
|
var outputPath = Path.Combine(_tempDir, "report");
|
|
|
|
// Act
|
|
var reportPath = await _sut.GenerateReportAsync(
|
|
result,
|
|
BundleReportFormat.Html,
|
|
outputPath);
|
|
|
|
// Assert
|
|
reportPath.Should().EndWith(".html");
|
|
File.Exists(reportPath).Should().BeTrue();
|
|
var content = await File.ReadAllTextAsync(reportPath);
|
|
content.Should().Contain("<html");
|
|
content.Should().Contain("Bundle Verification Report");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_WithFailedResult_IncludesErrors()
|
|
{
|
|
// Arrange
|
|
var result = BundleImportResult.Failed("Test error message");
|
|
var outputPath = Path.Combine(_tempDir, "failed-report");
|
|
|
|
// Act
|
|
var reportPath = await _sut.GenerateReportAsync(
|
|
result,
|
|
BundleReportFormat.Markdown,
|
|
outputPath);
|
|
|
|
// Assert
|
|
var content = await File.ReadAllTextAsync(reportPath);
|
|
content.Should().Contain("FAILED");
|
|
content.Should().Contain("Test error message");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
private string CreateTestBundle(string package, string advisoryId, string distribution)
|
|
{
|
|
var stagingDir = Path.Combine(_tempBundleDir, Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(stagingDir);
|
|
|
|
// Create pairs directory
|
|
var pairId = $"{package}-{advisoryId}-{distribution}";
|
|
var pairDir = Path.Combine(stagingDir, "pairs", pairId);
|
|
Directory.CreateDirectory(pairDir);
|
|
|
|
// Create pre and post binaries
|
|
File.WriteAllBytes(Path.Combine(pairDir, "pre.bin"), new byte[] { 1, 2, 3, 4 });
|
|
File.WriteAllBytes(Path.Combine(pairDir, "post.bin"), new byte[] { 5, 6, 7, 8 });
|
|
|
|
// Create SBOM
|
|
var sbom = new { spdxVersion = "SPDX-3.0.1", name = $"{package}-sbom" };
|
|
var sbomContent = JsonSerializer.SerializeToUtf8Bytes(sbom);
|
|
File.WriteAllBytes(Path.Combine(pairDir, "sbom.spdx.json"), sbomContent);
|
|
var sbomDigest = ComputeHash(sbomContent);
|
|
|
|
// Create delta-sig predicate
|
|
var predicate = new { payloadType = "application/vnd.stella-ops.delta-sig+json", payload = "test" };
|
|
var predicateContent = JsonSerializer.SerializeToUtf8Bytes(predicate);
|
|
File.WriteAllBytes(Path.Combine(pairDir, "delta-sig.dsse.json"), predicateContent);
|
|
var predicateDigest = ComputeHash(predicateContent);
|
|
|
|
// Create manifest
|
|
var manifest = new
|
|
{
|
|
bundleId = $"test-bundle-{Guid.NewGuid():N}",
|
|
schemaVersion = "1.0.0",
|
|
createdAt = DateTimeOffset.UtcNow,
|
|
generator = "BundleImportServiceTests",
|
|
pairs = new[]
|
|
{
|
|
new
|
|
{
|
|
pairId,
|
|
package,
|
|
advisoryId,
|
|
distribution,
|
|
vulnerableVersion = "1.0.0",
|
|
patchedVersion = "1.0.1",
|
|
debugSymbolsIncluded = false,
|
|
sbomDigest,
|
|
deltaSigDigest = predicateDigest
|
|
}
|
|
}
|
|
};
|
|
|
|
File.WriteAllText(
|
|
Path.Combine(stagingDir, "manifest.json"),
|
|
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
|
|
|
|
// Create tarball
|
|
return CreateTarball(stagingDir);
|
|
}
|
|
|
|
private string CreateTestBundleWithoutManifest()
|
|
{
|
|
var stagingDir = Path.Combine(_tempBundleDir, Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(stagingDir);
|
|
|
|
// Create some content but no manifest
|
|
var pairDir = Path.Combine(stagingDir, "pairs", "test-pair");
|
|
Directory.CreateDirectory(pairDir);
|
|
File.WriteAllBytes(Path.Combine(pairDir, "pre.bin"), new byte[] { 1, 2, 3, 4 });
|
|
|
|
return CreateTarball(stagingDir);
|
|
}
|
|
|
|
private string CreateTestBundleWithPlaceholderSignature()
|
|
{
|
|
var stagingDir = Path.Combine(_tempBundleDir, Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(stagingDir);
|
|
|
|
// Create manifest
|
|
var manifest = new
|
|
{
|
|
bundleId = $"test-bundle-{Guid.NewGuid():N}",
|
|
schemaVersion = "1.0.0",
|
|
createdAt = DateTimeOffset.UtcNow,
|
|
generator = "Test",
|
|
pairs = Array.Empty<object>()
|
|
};
|
|
File.WriteAllText(
|
|
Path.Combine(stagingDir, "manifest.json"),
|
|
JsonSerializer.Serialize(manifest));
|
|
|
|
// Create placeholder signature
|
|
var signature = new
|
|
{
|
|
signatureType = "cosign",
|
|
keyId = "test-key",
|
|
placeholder = true,
|
|
message = "Signing integration pending"
|
|
};
|
|
File.WriteAllText(
|
|
Path.Combine(stagingDir, "manifest.json.sig"),
|
|
JsonSerializer.Serialize(signature));
|
|
|
|
return CreateTarball(stagingDir);
|
|
}
|
|
|
|
private string CreateTestBundleWithBadDigest()
|
|
{
|
|
var stagingDir = Path.Combine(_tempBundleDir, Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(stagingDir);
|
|
|
|
// Create pairs directory
|
|
var pairId = "openssl-CVE-2024-1234-debian";
|
|
var pairDir = Path.Combine(stagingDir, "pairs", pairId);
|
|
Directory.CreateDirectory(pairDir);
|
|
|
|
// Create SBOM with content that won't match the digest
|
|
var sbom = new { spdxVersion = "SPDX-3.0.1", name = "openssl-sbom" };
|
|
File.WriteAllText(
|
|
Path.Combine(pairDir, "sbom.spdx.json"),
|
|
JsonSerializer.Serialize(sbom));
|
|
|
|
// Create manifest with wrong digest
|
|
var manifest = new
|
|
{
|
|
bundleId = $"test-bundle-{Guid.NewGuid():N}",
|
|
schemaVersion = "1.0.0",
|
|
createdAt = DateTimeOffset.UtcNow,
|
|
generator = "Test",
|
|
pairs = new[]
|
|
{
|
|
new
|
|
{
|
|
pairId,
|
|
package = "openssl",
|
|
advisoryId = "CVE-2024-1234",
|
|
distribution = "debian",
|
|
sbomDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000", // Wrong!
|
|
deltaSigDigest = (string?)null
|
|
}
|
|
}
|
|
};
|
|
File.WriteAllText(
|
|
Path.Combine(stagingDir, "manifest.json"),
|
|
JsonSerializer.Serialize(manifest));
|
|
|
|
return CreateTarball(stagingDir);
|
|
}
|
|
|
|
private string CreateTarball(string sourceDir)
|
|
{
|
|
var tarPath = Path.Combine(_tempBundleDir, $"{Guid.NewGuid():N}.tar.gz");
|
|
|
|
// Create tar
|
|
var tempTar = Path.GetTempFileName();
|
|
try
|
|
{
|
|
using (var tarStream = File.Create(tempTar))
|
|
{
|
|
System.Formats.Tar.TarFile.CreateFromDirectory(
|
|
sourceDir,
|
|
tarStream,
|
|
includeBaseDirectory: false);
|
|
}
|
|
|
|
// Gzip it
|
|
using var inputStream = File.OpenRead(tempTar);
|
|
using var outputStream = File.Create(tarPath);
|
|
using var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal);
|
|
inputStream.CopyTo(gzipStream);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(tempTar))
|
|
{
|
|
File.Delete(tempTar);
|
|
}
|
|
|
|
// Cleanup staging
|
|
Directory.Delete(sourceDir, recursive: true);
|
|
}
|
|
|
|
return tarPath;
|
|
}
|
|
|
|
private static string ComputeHash(byte[] data)
|
|
{
|
|
var hash = System.Security.Cryptography.SHA256.HashData(data);
|
|
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
|
}
|
|
|
|
private static BundleImportResult CreateTestImportResult()
|
|
{
|
|
return new BundleImportResult
|
|
{
|
|
Success = true,
|
|
OverallStatus = VerificationStatus.Passed,
|
|
ManifestDigest = "sha256:abc123",
|
|
Metadata = new BundleMetadata
|
|
{
|
|
BundleId = "test-bundle",
|
|
SchemaVersion = "1.0.0",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
Generator = "Test",
|
|
PairCount = 1,
|
|
TotalSizeBytes = 1024
|
|
},
|
|
SignatureResult = new SignatureVerificationResult
|
|
{
|
|
Passed = true,
|
|
SignatureCount = 1,
|
|
SignerKeyIds = ["test-key"]
|
|
},
|
|
DigestResult = new DigestVerificationResult
|
|
{
|
|
Passed = true,
|
|
TotalBlobs = 2,
|
|
MatchedBlobs = 2
|
|
},
|
|
PairResults =
|
|
[
|
|
new PairVerificationResult
|
|
{
|
|
PairId = "openssl-CVE-2024-1234-debian",
|
|
Package = "openssl",
|
|
AdvisoryId = "CVE-2024-1234",
|
|
Passed = true,
|
|
SbomStatus = VerificationStatus.Passed,
|
|
DeltaSigStatus = VerificationStatus.Passed,
|
|
MatcherStatus = VerificationStatus.Passed,
|
|
FunctionMatchRate = 0.95,
|
|
Duration = TimeSpan.FromSeconds(1.5)
|
|
}
|
|
],
|
|
Duration = TimeSpan.FromSeconds(5)
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|