using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Core.KV { public enum KVSyncStatus { NotLoaded, Ready, ReadFailed, Conflict, UnknownOutcome, Unauthorized, StorageError, Closed } public enum KVOpenError { None, Validation, Busy, StorageError, RecoveryRequired } public enum KVChangeError { None, Validation, Blocked, StorageError } public sealed class KvStateResult { public bool Success => Error == KVChangeError.None; public KVChangeError Error { get; } public long AttemptSequence { get; } public bool LocalCommitMayHaveSucceeded { get; } internal KvStateResult(KVChangeError error, long sequence = 0, bool uncertain = false) { Error = error; AttemptSequence = sequence; LocalCommitMayHaveSucceeded = uncertain; } } public sealed class KvOpenResult { public bool Success => Error == KVOpenError.None; public KVOpenError Error { get; } public KvSyncState State { get; } internal KvOpenResult(KVOpenError error, KvSyncState state = null) { Error = error; State = state; } } // Existence is separate from value: an empty string is not a deletion. public sealed class KvValue : IEquatable { public static readonly KvValue Missing = new KvValue(false, null); public bool Exists { get; } public string Value { get; } private KvValue(bool exists, string value) { Exists = exists; Value = value; } public static KvValue Present(string value) => new KvValue(true, value ?? throw new ArgumentNullException(nameof(value))); public bool Equals(KvValue other) => other != null && Exists == other.Exists && Value == other.Value; public override bool Equals(object obj) => Equals(obj as KvValue); public override int GetHashCode() => Exists ? (Value.GetHashCode() ^ 397) : 0; internal static KvValue At(IReadOnlyDictionary data, string key) => data.TryGetValue(key, out var value) ? Present(value) : Missing; } public sealed class KvChange { public string Key { get; } public long Sequence { get; } public KvValue BaseValue { get; } public KvValue Target { get; } internal KvChange(string key, long sequence, KvValue baseline, KvValue target) { Key = key; Sequence = sequence; BaseValue = baseline; Target = target; } internal KvChange WithBase(KvValue baseline) => new KvChange(Key, Sequence, baseline, Target); } public sealed class KvBatch { public string Id { get; } public IReadOnlyList Items { get; } public bool IsDelete => !Items[0].Target.Exists; public bool IsOrdinary { get; } internal KvBatch(string id, IEnumerable items, bool ordinary = false) { Id = id; Items = Array.AsReadOnly(items.ToArray()); IsOrdinary = ordinary; } } public sealed class KvDiagnosticSnapshot { public long NextSequence { get; } public KvBatch ActiveBatch { get; } public KvBatch UnknownBatch { get; } public IReadOnlyList> OrdinaryBuffers { get; } public IReadOnlyList PreparedOrdinaryBatches { get; } public IReadOnlyList OrdinaryUnknownBatches { get; } public IReadOnlyDictionary LegacyPending { get; } public IReadOnlyDictionary ExplicitDeletes { get; } internal KvDiagnosticSnapshot(KvSnapshot source) { NextSequence = source.NextSequence; ActiveBatch = source.Active; UnknownBatch = source.Unknown; OrdinaryBuffers = Array.AsReadOnly(source.OrdinaryBuffers.Select(bucket => ReadOnly(bucket)).ToArray()); PreparedOrdinaryBatches = Array.AsReadOnly(source.PreparedOrdinaryBatches.ToArray()); OrdinaryUnknownBatches = Array.AsReadOnly(source.OrdinaryUnknownBatches.ToArray()); LegacyPending = ReadOnly(source.LegacyPending); ExplicitDeletes = ReadOnly(source.ExplicitDeletes); } private static IReadOnlyDictionary ReadOnly(Dictionary source) => new ReadOnlyDictionary(new Dictionary(source, StringComparer.Ordinal)); } internal sealed class KvIdentity { internal readonly string Api, PlayerId, DirectoryPath; internal KvIdentity(string root, Uri api, string playerId) { if (string.IsNullOrWhiteSpace(root) || !Path.IsPathFullyQualified(root) || api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") || api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0 || !long.TryParse(playerId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0) { throw new ArgumentException("Invalid KV cache identity."); } Api = api.AbsoluteUri.TrimEnd('/') + "/"; PlayerId = id.ToString(CultureInfo.InvariantCulture); using (var hash = SHA256.Create()) { var bytes = hash.ComputeHash(Encoding.UTF8.GetBytes(Api)); var fingerprint = string.Concat(bytes.Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); DirectoryPath = Path.Combine(Path.GetFullPath(root), "player-kv", "v1", fingerprint, PlayerId); } } } internal sealed class KvSnapshot { internal long Revision, NextSequence = 1; internal bool HasBaseline, AuthenticationInvalid; internal Dictionary Baseline = new Dictionary(StringComparer.Ordinal); internal Dictionary LocalData = new Dictionary(StringComparer.Ordinal); internal Dictionary LocalUpdateTicks = new Dictionary(StringComparer.Ordinal); // A local origin has no second copy of its value. Ordinary origins can outlive a sent batch. internal Dictionary LocalOrigins = new Dictionary(StringComparer.Ordinal); internal HashSet ImmediatePreservedKeys = new HashSet(StringComparer.Ordinal); // Only v1/v2 conversion can populate this lower layer; the first successful Get removes it. internal Dictionary LegacyLocalOnlyUnderImmediate = new Dictionary(StringComparer.Ordinal); internal List> OrdinaryBuffers = Enumerable.Range(0, 5) .Select(_ => new Dictionary(StringComparer.Ordinal)).ToList(); internal List PreparedOrdinaryBatches = new List(); internal List OrdinaryUnknownBatches = new List(); internal Dictionary LegacyPending = new Dictionary(StringComparer.Ordinal); internal Dictionary ExplicitDeletes = new Dictionary(StringComparer.Ordinal); internal Dictionary Conflicts = new Dictionary(StringComparer.Ordinal); internal Dictionary RemoteOnlyWrites = new Dictionary(); internal KvBatch Active, Unknown; internal IEnumerable Unsent() { return OrdinaryBuffers.SelectMany(bucket => bucket.Values).Concat(LegacyPending.Values).Concat(ExplicitDeletes.Values); } internal KvSnapshot Clone() { return new KvSnapshot { Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid, Baseline = new Dictionary(Baseline, StringComparer.Ordinal), LocalData = new Dictionary(LocalData, StringComparer.Ordinal), LocalUpdateTicks = new Dictionary(LocalUpdateTicks, StringComparer.Ordinal), LocalOrigins = new Dictionary(LocalOrigins, StringComparer.Ordinal), ImmediatePreservedKeys = new HashSet(ImmediatePreservedKeys, StringComparer.Ordinal), LegacyLocalOnlyUnderImmediate = new Dictionary(LegacyLocalOnlyUnderImmediate, StringComparer.Ordinal), OrdinaryBuffers = OrdinaryBuffers.Select(bucket => new Dictionary(bucket, StringComparer.Ordinal)).ToList(), PreparedOrdinaryBatches = new List(PreparedOrdinaryBatches), OrdinaryUnknownBatches = new List(OrdinaryUnknownBatches), LegacyPending = new Dictionary(LegacyPending, StringComparer.Ordinal), ExplicitDeletes = new Dictionary(ExplicitDeletes, StringComparer.Ordinal), Conflicts = new Dictionary(Conflicts, StringComparer.Ordinal), Active = Active, Unknown = Unknown, RemoteOnlyWrites = new Dictionary(RemoteOnlyWrites) }; } internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128; internal string Encode(KvIdentity identity) { var conflicts = new JObject(); foreach (var pair in Conflicts) { conflicts.Add(pair.Key, ValueJson(pair.Value)); } var origins = new JObject(); foreach (var pair in LocalOrigins) { origins.Add(pair.Key, new JObject { ["localOnly"] = pair.Value.LocalOnly, ["sequence"] = pair.Value.Sequence }); } return new JObject { ["formatVersion"] = 3, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId, ["revision"] = Revision, ["nextSequence"] = NextSequence, ["hasBaseline"] = HasBaseline, ["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline), ["localData"] = JObject.FromObject(LocalData), ["localUpdateTicks"] = JObject.FromObject(LocalUpdateTicks), ["localOrigins"] = origins, ["immediatePreservedKeys"] = new JArray(ImmediatePreservedKeys), ["legacyLocalOnlyUnderImmediate"] = JObject.FromObject(LegacyLocalOnlyUnderImmediate), ["ordinaryBuffers"] = new JArray(OrdinaryBuffers.Select(bucket => (JToken)ChangesJson(bucket.Values))), ["preparedOrdinaryBatches"] = new JArray(PreparedOrdinaryBatches.Select(BatchJson)), ["ordinaryUnknownBatches"] = new JArray(OrdinaryUnknownBatches.Select(BatchJson)), ["legacyPending"] = ChangesJson(LegacyPending.Values), ["explicitDeletes"] = ChangesJson(ExplicitDeletes.Values), ["conflictObservations"] = conflicts, ["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown), ["remoteOnlyWrites"] = ChangesJson(RemoteOnlyWrites.Values) }.ToString(Formatting.None); } private static JArray ChangesJson(IEnumerable values) { return new JArray(values.OrderBy(item => item.Sequence).Select(ChangeJson)); } private static JObject ValueJson(KvValue value) { return new JObject { ["exists"] = value.Exists, ["value"] = value.Value }; } private static JObject ChangeJson(KvChange item) { return new JObject { ["key"] = item.Key, ["sequence"] = item.Sequence, ["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target) }; } private static JToken BatchJson(KvBatch batch) { if (batch == null) { return JValue.CreateNull(); } return new JObject { ["id"] = batch.Id, ["ordinary"] = batch.IsOrdinary, ["items"] = ChangesJson(batch.Items) }; } internal static KvSnapshot Decode(string text, KvIdentity identity) { JObject root; using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None }) { root = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); if (reader.Read()) { throw Invalid(); } } var version = Integer(root, "formatVersion"); if (version < 1 || version > 3 || String(root, "apiBaseUri") != identity.Api || String(root, "playerId") != identity.PlayerId) { throw Invalid(); } var result = new KvSnapshot { Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"), HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"), Baseline = Strings(Field(root, "baseline", JTokenType.Object)), Active = Batch(root, "activeBatch", version == 3), Unknown = Batch(root, "unknownBatch", version == 3) }; foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties()) { if (!ValidKey(property.Name)) { throw Invalid(); } result.Conflicts.Add(property.Name, Value(property.Value)); } if (version >= 2) { foreach (var item in Field(root, "remoteOnlyWrites", JTokenType.Array)) { var change = Change(item); if (!change.Target.Exists || result.RemoteOnlyWrites.ContainsKey(change.Sequence)) { throw Invalid(); } result.RemoteOnlyWrites.Add(change.Sequence, change); } } if (version < 3) { DecodeLegacy(root, version, result); } else { result.LocalData = Strings(Field(root, "localData", JTokenType.Object)); result.LegacyLocalOnlyUnderImmediate = Strings(Field(root, "legacyLocalOnlyUnderImmediate", JTokenType.Object)); foreach (var property in ((JObject)Field(root, "localUpdateTicks", JTokenType.Object)).Properties()) { if (!ValidKey(property.Name) || property.Value.Type != JTokenType.Integer) { throw Invalid(); } result.LocalUpdateTicks.Add(property.Name, (long)property.Value); } foreach (var property in ((JObject)Field(root, "localOrigins", JTokenType.Object)).Properties()) { if (!ValidKey(property.Name)) { throw Invalid(); } result.LocalOrigins.Add(property.Name, (Boolean(property.Value, "localOnly"), Integer(property.Value, "sequence"))); } foreach (var key in Field(root, "immediatePreservedKeys", JTokenType.Array)) { if (key.Type != JTokenType.String || !ValidKey((string)key) || !result.ImmediatePreservedKeys.Add((string)key)) { throw Invalid(); } } result.OrdinaryBuffers = Field(root, "ordinaryBuffers", JTokenType.Array).Select(Changes).ToList(); result.LegacyPending = Changes(Field(root, "legacyPending", JTokenType.Array)); result.ExplicitDeletes = Changes(Field(root, "explicitDeletes", JTokenType.Array)); result.PreparedOrdinaryBatches = ReadBatches(root, "preparedOrdinaryBatches"); result.OrdinaryUnknownBatches = ReadBatches(root, "ordinaryUnknownBatches"); } result.Validate(); return result; } private static Dictionary Changes(JToken array) { if (array.Type != JTokenType.Array) { throw Invalid(); } var values = new Dictionary(StringComparer.Ordinal); foreach (var token in array) { var item = Change(token); if (values.ContainsKey(item.Key)) { throw Invalid(); } values.Add(item.Key, item); } return values; } private static List ReadBatches(JToken root, string name) { var result = new List(); foreach (var item in Field(root, name, JTokenType.Array)) { var wrapper = new JObject { ["batch"] = item.DeepClone() }; var batch = Batch(wrapper, "batch", true); if (batch == null || !batch.IsOrdinary || batch.IsDelete) { throw Invalid(); } result.Add(batch); } return result; } private static void DecodeLegacy(JObject root, long version, KvSnapshot result) { var pending = Changes(Field(root, "pending", JTokenType.Array)); var localOnly = Strings(Field(root, "localOnly", JTokenType.Object)); var overrides = new Dictionary(StringComparer.Ordinal); if (version == 2) { foreach (var property in ((JObject)Field(root, "remoteLocalOverrides", JTokenType.Object)).Properties()) { if (!ValidKey(property.Name)) { throw Invalid(); } overrides.Add(property.Name, Value(property.Value)); } } // Validate the old shape before converting; new format rules must not accept a corrupt old file. if (result.Revision < 1 || result.NextSequence < 1 || (result.Active != null && result.Unknown != null) || (!result.HasBaseline && (result.Baseline.Count != 0 || localOnly.Count != 0 || pending.Count != 0 || result.Active != null || result.Unknown != null || overrides.Count != 0 || result.RemoteOnlyWrites.Count != 0))) { throw Invalid(); } var sequences = new HashSet(); foreach (var item in pending.Values.Concat(result.RemoteOnlyWrites.Values)) { if (item.Sequence < 1 || item.Sequence >= result.NextSequence || !sequences.Add(item.Sequence)) { throw Invalid(); } } if (result.Conflicts.Keys.Any(key => !pending.ContainsKey(key))) { throw Invalid(); } var oldBatch = result.Active ?? result.Unknown; if (oldBatch != null) { foreach (var item in oldBatch.Items) { var hasPending = pending.TryGetValue(item.Key, out var latest); var hasSource = result.RemoteOnlyWrites.Values.Any(source => source.Key == item.Key && source.Sequence > item.Sequence); var same = hasPending && latest.Sequence == item.Sequence && latest.BaseValue.Equals(item.BaseValue) && latest.Target.Equals(item.Target); if (item.Sequence < 1 || item.Sequence >= result.NextSequence || (sequences.Contains(item.Sequence) && !same) || (!hasPending && !hasSource) || (hasPending && (latest.Sequence < item.Sequence || !latest.BaseValue.Equals(item.BaseValue) || (latest.Sequence == item.Sequence && !same)))) { throw Invalid(); } sequences.Add(item.Sequence); } } result.LegacyPending = pending; result.LocalData = new Dictionary(result.Baseline, StringComparer.Ordinal); foreach (var item in pending.Values) { ApplyValue(result.LocalData, item.Key, item.Target); result.LocalOrigins[item.Key] = (false, item.Sequence); } foreach (var pair in localOnly) { result.LocalData[pair.Key] = pair.Value; // Old local-only writes had no stored sequence; zero is permitted only for this converted origin. result.LocalOrigins[pair.Key] = (true, 0); } foreach (var pair in overrides) { ApplyValue(result.LocalData, pair.Key, pair.Value); result.ImmediatePreservedKeys.Add(pair.Key); if (localOnly.TryGetValue(pair.Key, out var lower)) { result.LegacyLocalOnlyUnderImmediate[pair.Key] = lower; } } } private static void ApplyValue(Dictionary values, string key, KvValue value) { if (value.Exists) { values[key] = value.Value; } else { values.Remove(key); } } internal void Validate() { if (Revision < 1 || NextSequence < 1 || OrdinaryBuffers.Count != 5 || (Active != null && Unknown != null) || (Unknown != null && Unknown.IsOrdinary) || OrdinaryBuffers.Any(bucket => bucket.Count > 10 || bucket.Values.Any(item => !item.Target.Exists)) || ExplicitDeletes.Values.Any(item => item.Target.Exists) || (!HasBaseline && (Baseline.Count != 0 || LocalData.Count != 0 || Unsent().Any() || Active != null || Unknown != null || PreparedOrdinaryBatches.Count != 0 || OrdinaryUnknownBatches.Count != 0 || RemoteOnlyWrites.Count != 0 || LocalOrigins.Count != 0 || LocalUpdateTicks.Count != 0 || ImmediatePreservedKeys.Count != 0 || LegacyLocalOnlyUnderImmediate.Count != 0))) { throw Invalid(); } var sequences = new Dictionary(); void Add(KvChange item, bool sourceReference = false) { if (!ValidKey(item.Key) || item.Sequence < 1 || item.Sequence >= NextSequence) { throw Invalid(); } if (sequences.TryGetValue(item.Sequence, out var prior)) { if (!sourceReference || prior.Key != item.Key || !prior.BaseValue.Equals(item.BaseValue) || !prior.Target.Equals(item.Target)) { throw Invalid(); } } else { sequences.Add(item.Sequence, item); } } foreach (var item in Unsent().Concat(RemoteOnlyWrites.Values)) { Add(item); } var ids = new HashSet(StringComparer.Ordinal); foreach (var batch in PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Concat(new[] { Active, Unknown }).Where(batch => batch != null)) { if (!Guid.TryParseExact(batch.Id, "N", out _) || !ids.Add(batch.Id) || batch.Items.Count < 1 || batch.Items.Count > 10 || batch.Items.Select(item => item.Key).Distinct(StringComparer.Ordinal).Count() != batch.Items.Count || batch.Items.Any(item => item.Target.Exists != batch.Items[0].Target.Exists) || (batch.IsOrdinary && batch.IsDelete) || !batch.Items.Select(item => item.Sequence).SequenceEqual(batch.Items.Select(item => item.Sequence).OrderBy(value => value))) { throw Invalid(); } foreach (var item in batch.Items) { var reference = !batch.IsOrdinary && (LegacyPending.Values.Concat(ExplicitDeletes.Values) .Any(source => source.Sequence == item.Sequence)); Add(item, reference); } } if (RemoteOnlyWrites.Any(pair => pair.Key != pair.Value.Sequence || !pair.Value.Target.Exists) || PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Any(batch => !batch.IsOrdinary) || LocalOrigins.Any(pair => !ValidKey(pair.Key) || pair.Value.Sequence < 0 || pair.Value.Sequence >= NextSequence || (!pair.Value.LocalOnly && pair.Value.Sequence == 0)) || LocalUpdateTicks.Any(pair => !ValidKey(pair.Key) || pair.Value < 0 || pair.Value > DateTime.MaxValue.Ticks) || ImmediatePreservedKeys.Any(key => !ValidKey(key)) || LegacyLocalOnlyUnderImmediate.Any(pair => !ImmediatePreservedKeys.Contains(pair.Key) || !LocalOrigins.TryGetValue(pair.Key, out var origin) || !origin.LocalOnly) || Conflicts.Keys.Any(key => !Unsent().Any(item => item.Key == key) && !PreparedOrdinaryBatches.Any(batch => batch.Items.Any(item => item.Key == key)) && Unknown == null && OrdinaryUnknownBatches.Count == 0)) { throw Invalid(); } } private static Dictionary Strings(JToken value) { var result = new Dictionary(StringComparer.Ordinal); foreach (var property in ((JObject)value).Properties()) { if (!ValidKey(property.Name) || property.Value.Type != JTokenType.String) { throw Invalid(); } result.Add(property.Name, (string)property.Value); } return result; } private static KvChange Change(JToken item) { var key = String(item, "key"); if (!ValidKey(key)) { throw Invalid(); } return new KvChange(key, Integer(item, "sequence"), Value(Field(item, "base", JTokenType.Object)), Value(Field(item, "target", JTokenType.Object))); } private static KvValue Value(JToken item) { var exists = Boolean(item, "exists"); var value = Field(item, "value", exists ? JTokenType.String : JTokenType.Null); return exists ? KvValue.Present((string)value) : KvValue.Missing; } private static KvBatch Batch(JToken parent, string name, bool withKind = false) { var node = parent[name]; if (node == null) { throw Invalid(); } if (node.Type == JTokenType.Null) { return null; } var id = String(node, "id"); if (!Guid.TryParseExact(id, "N", out _)) { throw Invalid(); } var items = Field(node, "items", JTokenType.Array).Select(Change).ToArray(); if (items.Length == 0 || items.Length > 10 || items.Select(i => i.Key).Distinct(StringComparer.Ordinal).Count() != items.Length || items.Select(i => i.Sequence).Distinct().Count() != items.Length || items.Any(i => i.Target.Exists != items[0].Target.Exists) || !items.Select(i => i.Sequence).SequenceEqual(items.Select(i => i.Sequence).OrderBy(s => s))) { throw Invalid(); } return new KvBatch(id, items, withKind && Boolean(node, "ordinary")); } private static JToken Field(JToken parent, string name, JTokenType type) { if (!(parent is JObject obj) || obj[name] == null || obj[name].Type != type) { throw Invalid(); } return obj[name]; } private static string String(JToken parent, string name) => (string)Field(parent, name, JTokenType.String); private static long Integer(JToken parent, string name) => (long)Field(parent, name, JTokenType.Integer); private static bool Boolean(JToken parent, string name) => (bool)Field(parent, name, JTokenType.Boolean); internal static JsonException Invalid() => new JsonException("Invalid KV cache snapshot."); } }