using XFEEliteDangerous.Client; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Xml; namespace XFEEliteDangerous.Core; public sealed class SharedGalaxyState { public int Schema{get;set;}=1; public long Revision{get;set;} public Dictionary Definitions{get;set;}=new(StringComparer.Ordinal); public Dictionary Systems{get;set;}=[]; } /// Server-wide public world data lives outside commander state.json, so /// a legacy offline runtime cannot erase it when committing its own saved game. public sealed class SharedGalaxyStore { public static IReadOnlyList DefinitionOperations{get;}=Array.AsReadOnly(new[]{ "/2.0/elite/resources/dictionary","/2.0/elite/resources/finance","/2.0/elite/resources/loadingscreentips", "/2.0/elite/resources/microresources","/2.0/elite/resources/permits","/2.0/elite/resources/commodities", "/2.0/elite/resources/ussregion/list","/2.0/elite/synthesis/recipes","/2.0/elite/powerplay2/powers/list", "/2.0/elite/colonisation/resources/optionsfulllist","/2.0/elite/colonisation/resources/marketlink/weighting"}); private readonly string root,path; public SharedGalaxyStore(string workspace){root=Path.GetFullPath(workspace);path=Path.Combine(root,"galaxy.json");} public SharedGalaxyState Snapshot()=>WithLease(Load); public NativeDataReply? Definition(string operation) { if(!DefinitionOperations.Contains(operation,StringComparer.Ordinal))return null; return WithLease(()=>Load().Definitions.TryGetValue(operation,out var payload)?new NativeDataReply(200,payload):null); } public NativeDataReply System(ulong address,NativeSystemMetadata? seed) { return WithLease(()=> { var state=Load();if(state.Systems.TryGetValue(address,out var payload))return new NativeDataReply(200,payload); if(seed is null||seed.Address!=address)return new(404,"{\"error\":\"UnknownSharedSystem\"}"); payload=seed.Serialize(address);ValidateSystem(address,payload);state.Systems.Add(address,payload);state.Revision=checked(state.Revision+1);Save(state,false); return new(200,payload); }); } public SharedGalaxyState Update(string version,string kind,string key,string payload) { return WithLease(()=> { var state=Load();if(version!=state.Revision.ToString(CultureInfo.InvariantCulture))throw new SaveConflictException(); try { if(kind=="definition"){ValidateDefinition(key,payload);state.Definitions[key]=payload;} else if(kind=="system") { if(!ulong.TryParse(key,NumberStyles.None,CultureInfo.InvariantCulture,out var address))throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text365")); ValidateSystem(address,payload);if(!state.Systems.ContainsKey(address)&&state.Systems.Count>=4096)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text366"));state.Systems[address]=payload; } else throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text367")); } catch(Exception error)when(error is JsonException or XmlException or KeyNotFoundException or InvalidOperationException or FormatException) {throw new InvalidDataException(AppText.Get("SharedGalaxyStore.Text368"),error);} state.Revision=checked(state.Revision+1);Save(state,true);return state; }); } private SharedGalaxyState Load() { if(!File.Exists(path))return new(); if(new FileInfo(path).Length>64*1024*1024)throw new InvalidDataException(AppText.Get("SharedGalaxyStore.Text369")); var state=JsonSerializer.Deserialize(File.ReadAllBytes(path),CoreJsonContext.Default.SharedGalaxyState)??throw new InvalidDataException(AppText.Get("SharedGalaxyStore.Text370")); if(state.Schema!=1||state.Revision<0||state.Definitions is null||state.Systems is null||state.Definitions.Count>DefinitionOperations.Count||state.Systems.Count>4096)throw new InvalidDataException(AppText.Get("SharedGalaxyStore.Text371")); foreach(var item in state.Definitions)ValidateDefinition(item.Key,item.Value);foreach(var item in state.Systems)ValidateSystem(item.Key,item.Value); return state; } private void Save(SharedGalaxyState state,bool backup) { Directory.CreateDirectory(root); if(backup&&File.Exists(path)) { var directory=Path.Combine(root,"galaxy-backups");Directory.CreateDirectory(directory);File.Copy(path,Path.Combine(directory,DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss",CultureInfo.InvariantCulture)+"-"+Guid.NewGuid().ToString("N")+".json")); } var temporary=Path.Combine(root,"galaxy-"+Guid.NewGuid().ToString("N")+".tmp"); try { using(var file=new FileStream(temporary,FileMode.CreateNew,FileAccess.Write,FileShare.None)){JsonSerializer.Serialize(file,state,CoreJsonContext.Default.SharedGalaxyState);file.Flush(true);} if(File.Exists(path))File.Replace(temporary,path,path+".bak");else File.Move(temporary,path); } finally{if(File.Exists(temporary))File.Delete(temporary);} } private T WithLease(Func action) { Directory.CreateDirectory(root);var deadline=DateTimeOffset.UtcNow.AddSeconds(10);FileStream? lease=null; while(lease is null) { try{lease=new FileStream(Path.Combine(root,"galaxy.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None);} catch(IOException){if(DateTimeOffset.UtcNow>deadline)throw;Thread.Sleep(5);} } using(lease)return action(); } internal static void ValidateDefinition(string operation,string payload) { Bound(payload);if(!DefinitionOperations.Contains(operation,StringComparer.Ordinal))throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text372")); if(operation is "/2.0/elite/synthesis/recipes" or "/2.0/elite/resources/ussregion/list") { using var reader=XmlReader.Create(new StringReader(payload),new(){DtdProcessing=DtdProcessing.Prohibit,XmlResolver=null,MaxCharactersInDocument=2*1024*1024});while(reader.Read()){} } else { using var document=JsonDocument.Parse(payload,new(){MaxDepth=64});var value=document.RootElement; if(value.ValueKind!=JsonValueKind.Object)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text373")); if(operation=="/2.0/elite/powerplay2/powers/list"&&(!value.TryGetProperty("powers",out var powers)||powers.ValueKind!=JsonValueKind.Array||powers.GetArrayLength() is 0 or >1024||powers.EnumerateArray().Any(item=>item.ValueKind!=JsonValueKind.Object||!item.EnumerateObject().Any())))throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text374")); if(operation=="/2.0/elite/resources/commodities"&&(!value.TryGetProperty("commodities",out var commodities)||commodities.ValueKind is not(JsonValueKind.Array or JsonValueKind.Object)))throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text375")); if(operation=="/2.0/elite/resources/permits")NativeReferenceLibrary.ValidatePermits(value); } } internal static void ValidateSystem(ulong address,string payload) { Bound(payload);if(address==0||address>0x007F_FFFF_FFFF_FFFFUL)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text376")); using var document=JsonDocument.Parse(payload,new(){MaxDepth=64});var outer=document.RootElement.GetProperty("starsystem");var system=outer.GetProperty("starsystem"); if(system.GetProperty("address").GetUInt64()!=address||system.GetProperty("id").GetUInt64()==0||string.IsNullOrWhiteSpace(system.GetProperty("name").GetString()) ||outer.GetProperty("polities").ValueKind!=JsonValueKind.Object||outer.GetProperty("unownedSettlements").ValueKind!=JsonValueKind.Object)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text377")); foreach(var name in new[]{"wealth","security","standardofliving","developmentlevel"}) {var value=system.GetProperty(name).GetDouble();if(!double.IsFinite(value)||value<0||value>float.MaxValue)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text378"));} // Preserve nonempty polity documents exactly. Their detailed native // schema is not guessed or projected onto a smaller model. } private static void Bound(string payload){if(string.IsNullOrWhiteSpace(payload)||Encoding.UTF8.GetByteCount(payload)>2*1024*1024)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text379"));} }