save checkpoint: save features

This commit is contained in:
master
2026-02-12 10:27:23 +02:00
parent dca86e1248
commit 5bca406787
8837 changed files with 1796879 additions and 5294 deletions

View File

@@ -0,0 +1,332 @@
// <copyright file="FeedSnapshotSupport.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Replay.Core.FeedSnapshots;
namespace StellaOps.Replay.WebService;
internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
{
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new(StringComparer.Ordinal);
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
{
_blobs[blob.Digest] = blob;
return Task.CompletedTask;
}
public Task<FeedSnapshotBlob?> GetByDigestAsync(string digest, CancellationToken ct = default)
{
_blobs.TryGetValue(digest, out var blob);
return Task.FromResult(blob);
}
public Task<bool> ExistsAsync(string digest, CancellationToken ct = default)
{
return Task.FromResult(_blobs.ContainsKey(digest));
}
public Task DeleteAsync(string digest, CancellationToken ct = default)
{
_blobs.TryRemove(digest, out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
{
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new(StringComparer.Ordinal);
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
{
var entries = _index.GetOrAdd(entry.ProviderId, static _ => []);
lock (entries)
{
entries.Add(entry);
}
return Task.CompletedTask;
}
public Task<FeedSnapshotIndexEntry?> FindSnapshotAtTimeAsync(
string providerId,
DateTimeOffset pointInTime,
CancellationToken ct = default)
{
if (!_index.TryGetValue(providerId, out var entries))
{
return Task.FromResult<FeedSnapshotIndexEntry?>(null);
}
lock (entries)
{
var found = entries
.Where(e => e.CapturedAt <= pointInTime)
.OrderByDescending(e => e.CapturedAt)
.FirstOrDefault();
return Task.FromResult(found);
}
}
public Task<ImmutableArray<FeedSnapshotIndexEntry>> ListSnapshotsAsync(
string providerId,
DateTimeOffset from,
DateTimeOffset to,
int limit,
CancellationToken ct = default)
{
if (!_index.TryGetValue(providerId, out var entries))
{
return Task.FromResult(ImmutableArray<FeedSnapshotIndexEntry>.Empty);
}
lock (entries)
{
IEnumerable<FeedSnapshotIndexEntry> query = entries
.Where(e => e.CapturedAt >= from && e.CapturedAt <= to)
.OrderBy(e => e.CapturedAt);
if (limit > 0)
{
query = query.Take(limit);
}
return Task.FromResult(query.ToImmutableArray());
}
}
}
internal sealed class JsonAdvisoryExtractor : IAdvisoryExtractor
{
public Task<AdvisoryData?> ExtractAdvisoryAsync(
string cveId,
byte[] content,
FeedSnapshotFormat format,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(cveId) || content.Length == 0)
{
return Task.FromResult<AdvisoryData?>(null);
}
try
{
using var doc = JsonDocument.Parse(content);
var advisory = TryExtract(cveId, doc.RootElement);
return Task.FromResult(advisory);
}
catch (JsonException)
{
return Task.FromResult<AdvisoryData?>(null);
}
}
private static AdvisoryData? TryExtract(string cveId, JsonElement root)
{
if (root.ValueKind == JsonValueKind.Object)
{
if (TryParseAdvisoryObject(cveId, root, out var direct))
{
return direct;
}
if (TryGetPropertyIgnoreCase(root, "advisories", out var advisories) &&
advisories.ValueKind == JsonValueKind.Array)
{
foreach (var item in advisories.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Object &&
TryParseAdvisoryObject(cveId, item, out var parsed))
{
return parsed;
}
}
}
if (TryGetPropertyIgnoreCase(root, "cves", out var cves))
{
if (cves.ValueKind == JsonValueKind.Object)
{
foreach (var prop in cves.EnumerateObject())
{
if (string.Equals(prop.Name, cveId, StringComparison.OrdinalIgnoreCase) &&
prop.Value.ValueKind == JsonValueKind.Object &&
TryParseAdvisoryObject(cveId, prop.Value, out var parsed))
{
return parsed;
}
}
}
if (cves.ValueKind == JsonValueKind.Array)
{
foreach (var item in cves.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Object &&
TryParseAdvisoryObject(cveId, item, out var parsed))
{
return parsed;
}
}
}
}
}
if (root.ValueKind == JsonValueKind.Array)
{
foreach (var item in root.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Object &&
TryParseAdvisoryObject(cveId, item, out var parsed))
{
return parsed;
}
}
}
return null;
}
private static bool TryParseAdvisoryObject(string requestedCveId, JsonElement obj, out AdvisoryData? advisory)
{
advisory = null;
var cveId = GetString(obj, "cveId", "cve", "cve_id", "id");
if (string.IsNullOrWhiteSpace(cveId))
{
return false;
}
if (!string.Equals(cveId, requestedCveId, StringComparison.OrdinalIgnoreCase))
{
return false;
}
advisory = new AdvisoryData
{
CveId = cveId,
Severity = GetString(obj, "severity"),
CvssScore = GetDecimal(obj, "cvssScore", "cvss", "score"),
CvssVector = GetString(obj, "cvssVector", "vector"),
Description = GetString(obj, "description", "summary"),
FixStatus = GetString(obj, "fixStatus", "fix_state", "status"),
AffectedProducts = GetStringArray(obj, "affectedProducts", "affected_products", "packages"),
References = GetStringArray(obj, "references", "refs", "links"),
PublishedAt = GetDateTimeOffset(obj, "publishedAt", "published_at"),
LastModifiedAt = GetDateTimeOffset(obj, "lastModifiedAt", "last_modified_at")
};
return true;
}
private static string? GetString(JsonElement obj, params string[] names)
{
foreach (var name in names)
{
if (TryGetPropertyIgnoreCase(obj, name, out var value))
{
if (value.ValueKind == JsonValueKind.String)
{
return value.GetString();
}
if (value.ValueKind != JsonValueKind.Null && value.ValueKind != JsonValueKind.Undefined)
{
return value.ToString();
}
}
}
return null;
}
private static decimal? GetDecimal(JsonElement obj, params string[] names)
{
foreach (var name in names)
{
if (!TryGetPropertyIgnoreCase(obj, name, out var value))
{
continue;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var num))
{
return num;
}
if (value.ValueKind == JsonValueKind.String &&
decimal.TryParse(value.GetString(), out var parsed))
{
return parsed;
}
}
return null;
}
private static DateTimeOffset? GetDateTimeOffset(JsonElement obj, params string[] names)
{
foreach (var name in names)
{
if (!TryGetPropertyIgnoreCase(obj, name, out var value))
{
continue;
}
if (value.ValueKind == JsonValueKind.String &&
DateTimeOffset.TryParse(value.GetString(), out var parsed))
{
return parsed;
}
}
return null;
}
private static ImmutableArray<string> GetStringArray(JsonElement obj, params string[] names)
{
foreach (var name in names)
{
if (!TryGetPropertyIgnoreCase(obj, name, out var value) ||
value.ValueKind != JsonValueKind.Array)
{
continue;
}
var values = new List<string>();
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var str = item.GetString();
if (!string.IsNullOrWhiteSpace(str))
{
values.Add(str);
}
}
}
return values.ToImmutableArray();
}
return ImmutableArray<string>.Empty;
}
private static bool TryGetPropertyIgnoreCase(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
}

View File

@@ -12,9 +12,12 @@ using Serilog.Events;
using StellaOps.Audit.ReplayToken;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.AuditPack.Services;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.DependencyInjection;
using StellaOps.Replay.Core.FeedSnapshots;
using StellaOps.Replay.WebService;
using StellaOps.Telemetry.Core;
const string ReplayReadPolicy = "replay.token.read";
@@ -48,6 +51,17 @@ builder.Services.AddOptions<ReplayServiceOptions>()
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ICryptoHash, DefaultCryptoHash>();
builder.Services.AddSingleton<IReplayTokenGenerator, Sha256ReplayTokenGenerator>();
builder.Services.AddSingleton<IReplayExecutor, ReplayExecutor>();
builder.Services.AddSingleton<IAuditBundleReader, AuditBundleReader>();
builder.Services.AddSingleton<IVerdictReplayPredicate, VerdictReplayPredicate>();
builder.Services.AddSingleton<IFeedSnapshotBlobStore, InMemoryFeedSnapshotBlobStore>();
builder.Services.AddSingleton<IFeedSnapshotIndexStore, InMemoryFeedSnapshotIndexStore>();
builder.Services.AddSingleton(new FeedSnapshotServiceOptions());
builder.Services.AddSingleton<FeedSnapshotService>();
builder.Services.AddSingleton<IAdvisoryExtractor, JsonAdvisoryExtractor>();
builder.Services.AddSingleton<PointInTimeAdvisoryResolver>();
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddHealthChecks();
@@ -125,6 +139,8 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapVerdictReplayEndpoints();
app.MapPointInTimeQueryEndpoints();
// POST /v1/replay/tokens - Generate a new replay token
app.MapPost("/v1/replay/tokens", Task<Results<Created<GenerateTokenResponse>, ProblemHttpResult>> (
@@ -484,3 +500,5 @@ public class AuthorityConfig
public List<string> Audiences { get; set; } = new() { "stella-ops-api" };
public List<string> RequiredScopes { get; set; } = new() { "vuln.operate" };
}
public partial class Program;

View File

@@ -21,7 +21,7 @@ public sealed class PointInTimeAdvisoryResolverTests
private readonly TestAdvisoryExtractor _advisoryExtractor = new();
private FeedSnapshotService CreateSnapshotService() => new(
_blobStore,
ResetState(),
_indexStore,
_timeProvider,
new FeedSnapshotServiceOptions(),
@@ -32,6 +32,14 @@ public sealed class PointInTimeAdvisoryResolverTests
_advisoryExtractor,
NullLogger<PointInTimeAdvisoryResolver>.Instance);
private InMemoryFeedSnapshotBlobStore ResetState()
{
_blobStore.Clear();
_indexStore.Clear();
_advisoryExtractor.Clear();
return _blobStore;
}
[Fact]
public async Task ResolveAdvisoryAsync_ReturnsAdvisory_WhenFoundInSnapshot()
{
@@ -207,7 +215,7 @@ public sealed class PointInTimeAdvisoryResolverTests
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 1, new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "MEDIUM",
@@ -224,7 +232,7 @@ public sealed class PointInTimeAdvisoryResolverTests
_timeProvider.Advance(TimeSpan.FromHours(1));
// Update the advisory
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 2, new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH",
@@ -269,7 +277,7 @@ public sealed class PointInTimeAdvisoryResolverTests
_timeProvider.Advance(TimeSpan.FromHours(1));
// Add advisory
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 2, new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"
@@ -400,6 +408,8 @@ internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
{
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new();
public void Clear() => _blobs.Clear();
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
{
_blobs[blob.Digest] = blob;
@@ -428,6 +438,8 @@ internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
{
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new();
public void Clear() => _index.Clear();
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
{
var entries = _index.GetOrAdd(entry.ProviderId, _ => new List<FeedSnapshotIndexEntry>());
@@ -486,6 +498,14 @@ internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
{
private readonly ConcurrentDictionary<string, AdvisoryData> _advisories = new();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, AdvisoryData>> _providerAdvisories = new();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, AdvisoryData>> _versionedAdvisories = new();
public void Clear()
{
_advisories.Clear();
_providerAdvisories.Clear();
_versionedAdvisories.Clear();
}
public void SetAdvisory(string cveId, AdvisoryData advisory)
{
@@ -498,6 +518,12 @@ internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
providerDict[cveId] = advisory;
}
public void SetVersionedAdvisory(string cveId, int version, AdvisoryData advisory)
{
var versionMap = _versionedAdvisories.GetOrAdd(cveId, _ => new ConcurrentDictionary<int, AdvisoryData>());
versionMap[version] = advisory;
}
public Task<AdvisoryData?> ExtractAdvisoryAsync(
string cveId,
byte[] content,
@@ -518,6 +544,19 @@ internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
return Task.FromResult<AdvisoryData?>(providerAdvisory);
}
}
// Versioned replay fixtures (version/v fields) resolve advisory state
// pinned to snapshot version.
if (TryGetVersion(json, out var version) &&
_versionedAdvisories.TryGetValue(cveId, out var versionMap))
{
if (versionMap.TryGetValue(version, out var versionedAdvisory))
{
return Task.FromResult<AdvisoryData?>(versionedAdvisory);
}
return Task.FromResult<AdvisoryData?>(null);
}
}
catch
{
@@ -527,6 +566,27 @@ internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
_advisories.TryGetValue(cveId, out var advisory);
return Task.FromResult(advisory);
}
private static bool TryGetVersion(JsonElement json, out int version)
{
version = 0;
if (json.TryGetProperty("version", out var versionElement) &&
versionElement.ValueKind == JsonValueKind.Number &&
versionElement.TryGetInt32(out version))
{
return true;
}
if (json.TryGetProperty("v", out var shortVersionElement) &&
shortVersionElement.ValueKind == JsonValueKind.Number &&
shortVersionElement.TryGetInt32(out version))
{
return true;
}
return false;
}
}
#endregion

View File

@@ -0,0 +1,151 @@
// <copyright file="PointInTimeQueryApiIntegrationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using StellaOps.Replay.WebService;
using Xunit;
namespace StellaOps.Replay.Core.Tests.FeedSnapshots;
[Trait("Category", "Integration")]
[Trait("Intent", "Operational")]
public sealed class PointInTimeQueryApiIntegrationTests
{
[Fact]
public async Task PointInTimeEndpoints_ExposeSnapshotQueryAndDiffFlow()
{
await using var factory = CreateFactory();
using var client = factory.CreateClient();
var cveId = "CVE-2024-7777";
var providerId = "nvd-e2e";
var health = await client.GetAsync("/healthz");
health.StatusCode.Should().Be(HttpStatusCode.OK);
var captureOneResponse = await client.PostAsJsonAsync(
"/v1/pit/snapshots/",
new SnapshotCaptureRequest
{
ProviderId = providerId,
ProviderName = "NVD",
FeedType = "cve",
FeedData = new
{
advisories = new[]
{
new { cveId, severity = "MEDIUM", fixStatus = "unfixed" }
}
}
});
captureOneResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var captureOne = await captureOneResponse.Content.ReadFromJsonAsync<SnapshotCaptureResponse>();
captureOne.Should().NotBeNull();
await Task.Delay(20);
var captureTwoResponse = await client.PostAsJsonAsync(
"/v1/pit/snapshots/",
new SnapshotCaptureRequest
{
ProviderId = providerId,
ProviderName = "NVD",
FeedType = "cve",
FeedData = new
{
advisories = new[]
{
new { cveId, severity = "HIGH", fixStatus = "fixed" }
}
}
});
captureTwoResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var captureTwo = await captureTwoResponse.Content.ReadFromJsonAsync<SnapshotCaptureResponse>();
captureTwo.Should().NotBeNull();
var earlyPoint = Uri.EscapeDataString(captureOne!.CapturedAt.AddMilliseconds(10).ToString("o"));
var latePoint = Uri.EscapeDataString(captureTwo!.CapturedAt.AddMilliseconds(10).ToString("o"));
var earlyQueryResponse = await client.GetAsync(
$"/v1/pit/advisory/{cveId}?providerId={providerId}&pointInTime={earlyPoint}");
earlyQueryResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var earlyQuery = await earlyQueryResponse.Content.ReadFromJsonAsync<AdvisoryQueryResponse>();
earlyQuery.Should().NotBeNull();
earlyQuery!.Status.Should().Be("Found");
earlyQuery.Advisory.Should().NotBeNull();
earlyQuery.Advisory!.Severity.Should().Be("MEDIUM");
var lateQueryResponse = await client.GetAsync(
$"/v1/pit/advisory/{cveId}?providerId={providerId}&pointInTime={latePoint}");
lateQueryResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var lateQuery = await lateQueryResponse.Content.ReadFromJsonAsync<AdvisoryQueryResponse>();
lateQuery.Should().NotBeNull();
lateQuery!.Status.Should().Be("Found");
lateQuery.Advisory.Should().NotBeNull();
lateQuery.Advisory!.Severity.Should().Be("HIGH");
var diffResponse = await client.PostAsJsonAsync(
"/v1/pit/advisory/diff",
new AdvisoryDiffRequest
{
CveId = cveId,
ProviderId = providerId,
Time1 = captureOne.CapturedAt.AddMilliseconds(10),
Time2 = captureTwo.CapturedAt.AddMilliseconds(10)
});
diffResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var diff = await diffResponse.Content.ReadFromJsonAsync<AdvisoryDiffResponse>();
diff.Should().NotBeNull();
diff!.DiffType.Should().Be("Modified");
diff.Changes.Should().Contain(c => c.Field == "Severity");
}
[Fact]
public async Task DiffEndpoint_ReturnsBadRequest_WhenProviderIsEmpty()
{
await using var factory = CreateFactory();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/v1/pit/advisory/diff",
new
{
cveId = "CVE-2024-7777",
providerId = string.Empty,
time1 = "2026-01-01T00:00:00Z",
time2 = "2026-01-02T00:00:00Z"
});
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var payload = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(payload);
doc.RootElement.TryGetProperty("title", out var title).Should().BeTrue();
title.GetString().Should().Be("missing_required_fields");
}
private static WebApplicationFactory<Program> CreateFactory()
{
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Replay:Authority:Issuer"] = "https://authority.stella-ops.local",
["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration",
["Replay:Authority:RequireHttpsMetadata"] = "false",
});
});
});
}
}

View File

@@ -23,7 +23,7 @@ public sealed class PointInTimeQueryEndpointsTests
private readonly TestAdvisoryExtractor _advisoryExtractor = new();
private FeedSnapshotService CreateSnapshotService() => new(
_blobStore,
ResetState(),
_indexStore,
_timeProvider,
new FeedSnapshotServiceOptions(),
@@ -34,6 +34,14 @@ public sealed class PointInTimeQueryEndpointsTests
_advisoryExtractor,
NullLogger<PointInTimeAdvisoryResolver>.Instance);
private InMemoryFeedSnapshotBlobStore ResetState()
{
_blobStore.Clear();
_indexStore.Clear();
_advisoryExtractor.Clear();
return _blobStore;
}
[Fact]
public async Task QueryAdvisoryAsync_ReturnsAdvisory_WhenFound()
{
@@ -153,7 +161,7 @@ public sealed class PointInTimeQueryEndpointsTests
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(baseTime);
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 1, new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "MEDIUM"
@@ -167,7 +175,7 @@ public sealed class PointInTimeQueryEndpointsTests
_timeProvider.Advance(TimeSpan.FromHours(1));
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 2, new AdvisoryData
{
CveId = "CVE-2024-1234",
Severity = "HIGH"

View File

@@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
@@ -20,4 +21,4 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../StellaOps.Replay.WebService/StellaOps.Replay.WebService.csproj" />
</ItemGroup>
</Project>
</Project>