up
This commit is contained in:
@@ -13,7 +13,7 @@ Own shared replay domain types, canonicalisation helpers, bundle hashing utiliti
|
||||
1. Maintain deterministic behaviour (lexicographic ordering, canonical JSON, fixed encodings).
|
||||
2. Keep APIs offline-friendly; no network dependencies.
|
||||
3. Coordinate schema and bundle changes with Scanner, Evidence Locker, CLI, and Docs guilds.
|
||||
4. Update module `TASKS.md` statuses alongside `docs/implplan/SPRINT_185_shared_replay_primitives.md`.
|
||||
4. Update module `TASKS.md` statuses alongside `docs/implplan/SPRINT_0185_0001_0001_shared_replay_primitives.md`.
|
||||
|
||||
## Contacts
|
||||
- BE-Base Platform Guild (primary)
|
||||
|
||||
89
src/__Libraries/StellaOps.Replay.Core/CanonicalJson.cs
Normal file
89
src/__Libraries/StellaOps.Replay.Core/CanonicalJson.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Produces deterministic, lexicographically ordered JSON suitable for hashing and DSSE payloads.
|
||||
/// </summary>
|
||||
public static class CanonicalJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value) => Encoding.UTF8.GetString(SerializeToUtf8Bytes(value));
|
||||
|
||||
public static byte[] SerializeToUtf8Bytes<T>(T value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var element = JsonSerializer.SerializeToElement(value, SerializerOptions);
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false
|
||||
});
|
||||
|
||||
WriteCanonical(element, writer);
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(property.Value, writer);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(item, writer);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(element.GetBoolean());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
case JsonValueKind.Undefined:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected JSON value kind: {element.ValueKind}");
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/__Libraries/StellaOps.Replay.Core/DeterministicHash.cs
Normal file
59
src/__Libraries/StellaOps.Replay.Core/DeterministicHash.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic hashing helpers for canonical JSON payloads and Merkle construction.
|
||||
/// </summary>
|
||||
public static class DeterministicHash
|
||||
{
|
||||
public static string Sha256Hex(ReadOnlySpan<byte> data)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
if (!SHA256.TryHashData(data, hash, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to compute SHA-256 hash.");
|
||||
}
|
||||
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string Sha256Hex(string utf8) => Sha256Hex(Encoding.UTF8.GetBytes(utf8));
|
||||
|
||||
public static string MerkleRootHex(IEnumerable<byte[]> leaves)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(leaves);
|
||||
|
||||
var currentLevel = leaves.Select(l => l ?? throw new ArgumentNullException(nameof(leaves), "Leaf cannot be null.")).Select(SHA256.HashData).ToList();
|
||||
if (currentLevel.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one leaf is required to compute a Merkle root.", nameof(leaves));
|
||||
}
|
||||
|
||||
while (currentLevel.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<byte[]>((currentLevel.Count + 1) / 2);
|
||||
for (var i = 0; i < currentLevel.Count; i += 2)
|
||||
{
|
||||
var left = currentLevel[i];
|
||||
var right = i + 1 < currentLevel.Count ? currentLevel[i + 1] : left;
|
||||
|
||||
var combined = new byte[left.Length + right.Length];
|
||||
Buffer.BlockCopy(left, 0, combined, 0, left.Length);
|
||||
Buffer.BlockCopy(right, 0, combined, left.Length, right.Length);
|
||||
|
||||
nextLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
return Convert.ToHexString(currentLevel[0]).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string MerkleRootHex(IEnumerable<string> canonicalJsonNodes) =>
|
||||
MerkleRootHex(canonicalJsonNodes.Select(s => Encoding.UTF8.GetBytes(s ?? string.Empty)));
|
||||
}
|
||||
25
src/__Libraries/StellaOps.Replay.Core/DsseEnvelope.cs
Normal file
25
src/__Libraries/StellaOps.Replay.Core/DsseEnvelope.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
public sealed record DsseSignature(string KeyId, string Sig);
|
||||
|
||||
public sealed record DsseEnvelope(string PayloadType, string Payload, IReadOnlyList<DsseSignature> Signatures)
|
||||
{
|
||||
public string DigestSha256 => DeterministicHash.Sha256Hex(Convert.FromBase64String(Payload));
|
||||
}
|
||||
|
||||
public static class DssePayloadBuilder
|
||||
{
|
||||
public const string ReplayPayloadType = "application/vnd.stellaops.replay+json";
|
||||
|
||||
public static DsseEnvelope BuildUnsigned<T>(T payload, string? payloadType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
var bytes = CanonicalJson.SerializeToUtf8Bytes(payload);
|
||||
var envelope = new DsseEnvelope(payloadType ?? ReplayPayloadType, Convert.ToBase64String(bytes), Array.Empty<DsseSignature>());
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
public sealed record ReplayBundleEntry(string Path, ReadOnlyMemory<byte> Content, int Mode = 0b110_100_100)
|
||||
{
|
||||
public const int DefaultFileMode = 0b110_100_100; // 0644
|
||||
}
|
||||
88
src/__Libraries/StellaOps.Replay.Core/ReplayBundleWriter.cs
Normal file
88
src/__Libraries/StellaOps.Replay.Core/ReplayBundleWriter.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
public sealed record ReplayBundleWriteResult(string TarSha256, string ZstSha256, long TarBytes, long ZstBytes, string CasUri);
|
||||
|
||||
public static class ReplayBundleWriter
|
||||
{
|
||||
private const int DefaultBufferSize = 16 * 1024;
|
||||
|
||||
public static async Task<ReplayBundleWriteResult> WriteTarZstAsync(
|
||||
IEnumerable<ReplayBundleEntry> entries,
|
||||
Stream destination,
|
||||
int compressionLevel = 19,
|
||||
string? casPrefix = "replay",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
|
||||
var orderedEntries = entries.OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
|
||||
if (orderedEntries.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one bundle entry is required.", nameof(entries));
|
||||
}
|
||||
|
||||
await using var tarBuffer = new MemoryStream();
|
||||
await WriteDeterministicTarAsync(orderedEntries, tarBuffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tarBytes = tarBuffer.Length;
|
||||
var tarBytesSpan = tarBuffer.ToArray();
|
||||
var tarDigest = DeterministicHash.Sha256Hex(tarBytesSpan);
|
||||
|
||||
tarBuffer.Position = 0;
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
await using var hashingStream = new CryptoStream(destination, sha, CryptoStreamMode.Write, leaveOpen: true);
|
||||
await using (var compressor = new CompressionStream(hashingStream, compressionLevel, DefaultBufferSize, leaveOpen: true))
|
||||
{
|
||||
await tarBuffer.CopyToAsync(compressor, DefaultBufferSize, cancellationToken).ConfigureAwait(false);
|
||||
await compressor.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
hashingStream.FlushFinalBlock();
|
||||
var zstDigest = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
|
||||
var casUri = BuildCasUri(zstDigest, casPrefix);
|
||||
|
||||
return new ReplayBundleWriteResult(tarDigest, zstDigest, tarBytes, destination.CanSeek ? destination.Position : -1, casUri);
|
||||
}
|
||||
|
||||
private static async Task WriteDeterministicTarAsync(IReadOnlyCollection<ReplayBundleEntry> entries, Stream tarStream, CancellationToken ct)
|
||||
{
|
||||
using var writer = new TarWriter(tarStream, TarEntryFormat.Pax, leaveOpen: true);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.Path)
|
||||
{
|
||||
Mode = (UnixFileMode)entry.Mode,
|
||||
ModificationTime = DateTimeOffset.UnixEpoch,
|
||||
DataStream = new MemoryStream(entry.Content.ToArray(), writable: false)
|
||||
};
|
||||
|
||||
writer.WriteEntry(tarEntry);
|
||||
}
|
||||
|
||||
await writer.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string BuildCasUri(string sha256Hex, string? prefix = "replay")
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sha256Hex);
|
||||
if (sha256Hex.Length < 2)
|
||||
{
|
||||
throw new ArgumentException("Digest must be at least two hex characters for prefixing.", nameof(sha256Hex));
|
||||
}
|
||||
|
||||
var head = sha256Hex[..2];
|
||||
var label = string.IsNullOrWhiteSpace(prefix) ? "replay" : prefix;
|
||||
return $"cas://{label}/{head}/{sha256Hex}.tar.zst";
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,13 @@ namespace StellaOps.Replay.Core;
|
||||
public sealed class ReplayManifest
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = "1.0";
|
||||
public string SchemaVersion { get; set; } = ReplayManifestVersions.V1;
|
||||
|
||||
[JsonPropertyName("scan")]
|
||||
public ReplayScanMetadata Scan { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ReplayReachabilitySection? Reachability { get; set; }
|
||||
public ReplayReachabilitySection Reachability { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReplayScanMetadata
|
||||
@@ -23,11 +22,14 @@ public sealed class ReplayScanMetadata
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("time")]
|
||||
public DateTimeOffset Time { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset Time { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
|
||||
public sealed class ReplayReachabilitySection
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string? AnalysisId { get; set; }
|
||||
|
||||
[JsonPropertyName("graphs")]
|
||||
public List<ReplayReachabilityGraphReference> Graphs { get; set; } = new();
|
||||
|
||||
@@ -46,6 +48,12 @@ public sealed class ReplayReachabilityGraphReference
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string Namespace { get; set; } = "reachability_graphs";
|
||||
|
||||
[JsonPropertyName("callgraphId")]
|
||||
public string? CallgraphId { get; set; }
|
||||
|
||||
[JsonPropertyName("analyzer")]
|
||||
public string Analyzer { get; set; } = string.Empty;
|
||||
|
||||
@@ -64,6 +72,14 @@ public sealed class ReplayReachabilityTraceReference
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string Namespace { get; set; } = "runtime_traces";
|
||||
|
||||
[JsonPropertyName("recordedAt")]
|
||||
public DateTimeOffset RecordedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset RecordedAt { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
|
||||
public static class ReplayManifestVersions
|
||||
{
|
||||
public const string V1 = "1.0";
|
||||
}
|
||||
|
||||
@@ -19,4 +19,22 @@ public static class ReplayManifestExtensions
|
||||
manifest.Reachability ??= new ReplayReachabilitySection();
|
||||
manifest.Reachability.RuntimeTraces.Add(trace);
|
||||
}
|
||||
|
||||
public static byte[] ToCanonicalJson(this ReplayManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
return CanonicalJson.SerializeToUtf8Bytes(manifest);
|
||||
}
|
||||
|
||||
public static string ComputeCanonicalSha256(this ReplayManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
return DeterministicHash.Sha256Hex(manifest.ToCanonicalJson());
|
||||
}
|
||||
|
||||
public static DsseEnvelope ToDsseEnvelope(this ReplayManifest manifest, string? payloadType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
return DssePayloadBuilder.BuildUnsigned(manifest, payloadType);
|
||||
}
|
||||
}
|
||||
|
||||
94
src/__Libraries/StellaOps.Replay.Core/ReplayMongoModels.cs
Normal file
94
src/__Libraries/StellaOps.Replay.Core/ReplayMongoModels.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
public static class ReplayCollections
|
||||
{
|
||||
public const string Runs = "replay_runs";
|
||||
public const string Bundles = "replay_bundles";
|
||||
public const string Subjects = "replay_subjects";
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ReplayRunRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty; // scan UUID
|
||||
|
||||
public string ManifestHash { get; set; } = string.Empty; // sha256:...
|
||||
|
||||
public string Status { get; set; } = "pending"; // verified|failed|replayed
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UnixEpoch, DateTimeKind.Utc);
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UnixEpoch, DateTimeKind.Utc);
|
||||
|
||||
public ReplayRunOutputs Outputs { get; set; } = new();
|
||||
|
||||
public List<ReplaySignatureRecord> Signatures { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReplayRunOutputs
|
||||
{
|
||||
public string Sbom { get; set; } = string.Empty; // sha256:...
|
||||
public string Findings { get; set; } = string.Empty;
|
||||
public string? Vex { get; set; }
|
||||
= null;
|
||||
public string? Log { get; set; }
|
||||
= null;
|
||||
}
|
||||
|
||||
public sealed class ReplaySignatureRecord
|
||||
{
|
||||
public string Profile { get; set; } = string.Empty; // e.g., FIPS, GOST
|
||||
public bool Verified { get; set; }
|
||||
= false;
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ReplayBundleRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty; // sha256 hex
|
||||
|
||||
public string Type { get; set; } = string.Empty; // input|output|rootpack|reachability
|
||||
|
||||
public long Size { get; set; }
|
||||
= 0;
|
||||
|
||||
public string Location { get; set; } = string.Empty; // cas://.../digest.tar.zst
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UnixEpoch, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ReplaySubjectRecord
|
||||
{
|
||||
[BsonId]
|
||||
public string OciDigest { get; set; } = string.Empty;
|
||||
|
||||
public List<ReplayLayerRecord> Layers { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReplayLayerRecord
|
||||
{
|
||||
public string LayerDigest { get; set; } = string.Empty;
|
||||
public string MerkleRoot { get; set; } = string.Empty;
|
||||
public int LeafCount { get; set; }
|
||||
= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index names to keep mongod migrations deterministic.
|
||||
/// </summary>
|
||||
public static class ReplayIndexes
|
||||
{
|
||||
public const string Runs_ManifestHash = "runs_manifestHash_unique";
|
||||
public const string Runs_Status_CreatedAt = "runs_status_createdAt";
|
||||
public const string Bundles_Type = "bundles_type";
|
||||
public const string Bundles_Location = "bundles_location";
|
||||
public const string Subjects_LayerDigest = "subjects_layerDigest";
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="ZstdSharp.Port" Version="0.8.6" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="2.25.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
16
src/__Libraries/StellaOps.Replay.Core/TASKS.md
Normal file
16
src/__Libraries/StellaOps.Replay.Core/TASKS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# StellaOps.Replay.Core task board
|
||||
|
||||
Keep this table in sync with `docs/implplan/SPRINT_0185_0001_0001_shared_replay_primitives.md`.
|
||||
|
||||
| Task ID | Status | Owners | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| REPLAY-CORE-185-001 | DONE (2025-11-25) | BE-Base Platform Guild | Library scaffolding: manifest schema types, canonical JSON rules, Merkle utilities, DSSE payload builders. |
|
||||
| REPLAY-CORE-185-002 | DONE (2025-11-25) | Platform Guild | Deterministic bundle writer (tar.zst, CAS naming) and hashing abstractions; update platform architecture doc with “Replay CAS” subsection. |
|
||||
| REPLAY-CORE-185-003 | DONE (2025-11-25) | Platform Data Guild | Mongo collections (`replay_runs`, `replay_bundles`, `replay_subjects`) and indices aligned with schema doc. |
|
||||
| DOCS-REPLAY-185-003 | DONE (2025-11-25) | Docs Guild · Platform Data Guild | `docs/data/replay_schema.md` detailing collections, index guidance, offline sync strategy. |
|
||||
| DOCS-REPLAY-185-004 | DONE (2025-11-25) | Docs Guild | Expand `docs/replay/DEVS_GUIDE_REPLAY.md` with integration guidance and deterministic replay checklist. |
|
||||
|
||||
## Status rules
|
||||
- Use TODO → DOING → DONE/BLOCKED and mirror every change in the sprint Delivery Tracker.
|
||||
- Note dates in parentheses when flipping to DOING/DONE for traceability.
|
||||
- Capture contract or runbook changes in the relevant docs under `docs/replay` or `docs/data`.
|
||||
Reference in New Issue
Block a user