diff --git a/Source/NETworkManager.Profiles/ProfileManager.cs b/Source/NETworkManager.Profiles/ProfileManager.cs index 19784d812b..9de74f1087 100644 --- a/Source/NETworkManager.Profiles/ProfileManager.cs +++ b/Source/NETworkManager.Profiles/ProfileManager.cs @@ -9,6 +9,8 @@ using System.Linq; using System.Security; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Xml.Serialization; namespace NETworkManager.Profiles; @@ -31,13 +33,35 @@ public static class ProfileManager /// /// Profile file extension. /// - private const string ProfileFileExtension = ".xml"; + private const string ProfileFileExtension = ".json"; + + /// + /// Legacy XML profile file extension. + /// + [Obsolete("Legacy XML profile files are no longer used, but the extension is kept for migration purposes.")] + private const string LegacyProfileFileExtension = ".xml"; /// /// Profile file extension for encrypted files. /// private const string ProfileFileExtensionEncrypted = ".encrypted"; + /// + /// JSON serializer options for consistent serialization/deserialization. + /// + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Converters = { new JsonStringEnumConverter() } + }; + + /// + /// Maximum number of bytes to check for XML content detection. + /// + private const int XmlDetectionBufferSize = 200; + /// /// ObservableCollection of all profile files. /// @@ -137,6 +161,22 @@ public static string GetProfilesFolderLocation() AssemblyManager.Current.Name, ProfilesFolderName); } + /// + /// Method to get a JSON profile file path with optional encryption extension. + /// + /// Original file path. + /// Whether the file should have the encrypted extension. + /// JSON profile file path. + private static string GetJsonProfilePath(string originalPath, bool encrypted) + { + var basePath = Path.ChangeExtension(originalPath, null); + + if (encrypted) + return $"{basePath}{ProfileFileExtension}{ProfileFileExtensionEncrypted}"; + + return $"{basePath}{ProfileFileExtension}"; + } + /// /// Method to get the default profile file name. /// @@ -160,15 +200,17 @@ private static string GetProfilesDefaultFilePath() #region Get and load profile files /// - /// Get all files in the folder with the extension or - /// . + /// Get all files in the folder with the extension , + /// or . /// /// Path of the profile folder. /// List of profile files. private static IEnumerable GetProfileFiles(string location) { return Directory.GetFiles(location).Where(x => - Path.GetExtension(x) == ProfileFileExtension || Path.GetExtension(x) == ProfileFileExtensionEncrypted); + Path.GetExtension(x) == ProfileFileExtension || + Path.GetExtension(x) == LegacyProfileFileExtension || + Path.GetExtension(x) == ProfileFileExtensionEncrypted); } /// @@ -282,16 +324,24 @@ public static void EnableEncryption(ProfileFileInfo profileFileInfo, SecureStrin // Create a new profile info with the encryption infos var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, - Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtensionEncrypted), true) + GetJsonProfilePath(profileFileInfo.Path, true), true) { Password = password, IsPasswordValid = true }; - // Load the profiles from the profile file - var profiles = DeserializeFromFile(profileFileInfo.Path); + // Load the profiles from the profile file (handle both XML and JSON) + List profiles; + if (Path.GetExtension(profileFileInfo.Path) == LegacyProfileFileExtension) + { + profiles = DeserializeFromXmlFile(profileFileInfo.Path); + } + else + { + profiles = DeserializeFromFile(profileFileInfo.Path); + } - // Save the encrypted file + // Save the encrypted file in JSON format var decryptedBytes = SerializeToByteArray(profiles); var encryptedBytes = CryptoHelper.Encrypt(decryptedBytes, SecureStringHelper.ConvertToString(newProfileFileInfo.Password), @@ -337,7 +387,7 @@ public static void ChangeMasterPassword(ProfileFileInfo profileFileInfo, SecureS // Create a new profile info with the encryption infos var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, - Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtensionEncrypted), true) + GetJsonProfilePath(profileFileInfo.Path, true), true) { Password = newPassword, IsPasswordValid = true @@ -348,9 +398,19 @@ public static void ChangeMasterPassword(ProfileFileInfo profileFileInfo, SecureS var decryptedBytes = CryptoHelper.Decrypt(encryptedBytes, SecureStringHelper.ConvertToString(password), GlobalStaticConfiguration.Profile_EncryptionKeySize, GlobalStaticConfiguration.Profile_EncryptionIterations); - var profiles = DeserializeFromByteArray(decryptedBytes); - // Save the encrypted file + // Check if decrypted content is XML or JSON and deserialize accordingly + List profiles; + if (IsXmlContent(decryptedBytes)) + { + profiles = DeserializeFromXmlByteArray(decryptedBytes); + } + else + { + profiles = DeserializeFromByteArray(decryptedBytes); + } + + // Save the encrypted file in JSON format decryptedBytes = SerializeToByteArray(profiles); encryptedBytes = CryptoHelper.Encrypt(decryptedBytes, SecureStringHelper.ConvertToString(newProfileFileInfo.Password), @@ -392,16 +452,26 @@ public static void DisableEncryption(ProfileFileInfo profileFileInfo, SecureStri // Create a new profile info var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, - Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtension)); + GetJsonProfilePath(profileFileInfo.Path, false)); // Load and decrypt the profiles from the profile file var encryptedBytes = File.ReadAllBytes(profileFileInfo.Path); var decryptedBytes = CryptoHelper.Decrypt(encryptedBytes, SecureStringHelper.ConvertToString(password), GlobalStaticConfiguration.Profile_EncryptionKeySize, GlobalStaticConfiguration.Profile_EncryptionIterations); - var profiles = DeserializeFromByteArray(decryptedBytes); - // Save the decrypted profiles to the profile file + // Check if decrypted content is XML or JSON and deserialize accordingly + List profiles; + if (IsXmlContent(decryptedBytes)) + { + profiles = DeserializeFromXmlByteArray(decryptedBytes); + } + else + { + profiles = DeserializeFromByteArray(decryptedBytes); + } + + // Save the decrypted profiles to the profile file in JSON format SerializeToFile(newProfileFileInfo.Path, profiles); // Add the new profile @@ -433,6 +503,9 @@ private static void Load(ProfileFileInfo profileFileInfo) if (File.Exists(profileFileInfo.Path)) { + // Detect if the file is a legacy XML file and needs migration + var isLegacyXmlFile = Path.GetExtension(profileFileInfo.Path) == LegacyProfileFileExtension; + if (profileFileInfo.IsEncrypted) { var encryptedBytes = File.ReadAllBytes(profileFileInfo.Path); @@ -441,7 +514,58 @@ private static void Load(ProfileFileInfo profileFileInfo) GlobalStaticConfiguration.Profile_EncryptionKeySize, GlobalStaticConfiguration.Profile_EncryptionIterations); - AddGroups(DeserializeFromByteArray(decryptedBytes)); + List groups; + + // Check if decrypted content is XML or JSON + if (IsXmlContent(decryptedBytes)) + { + Log.Info($"Legacy XML profile file found (encrypted): {profileFileInfo.Path}. Migrating to JSON format..."); + groups = DeserializeFromXmlByteArray(decryptedBytes); + + // Store the original path before updating + var originalPath = profileFileInfo.Path; + + // Migrate to JSON format by saving the file + var newPath = GetJsonProfilePath(profileFileInfo.Path, true); + var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, newPath, true) + { + Password = profileFileInfo.Password, + IsPasswordValid = true + }; + + var jsonBytes = SerializeToByteArray(groups); + var encryptedJsonBytes = CryptoHelper.Encrypt(jsonBytes, + SecureStringHelper.ConvertToString(newProfileFileInfo.Password), + GlobalStaticConfiguration.Profile_EncryptionKeySize, + GlobalStaticConfiguration.Profile_EncryptionIterations); + + File.WriteAllBytes(newPath, encryptedJsonBytes); + + // Update ProfileFiles collection + ProfileFiles.Remove(profileFileInfo); + ProfileFiles.Add(newProfileFileInfo); + + // Update the reference + profileFileInfo = newProfileFileInfo; + + // Delete the old XML file + try + { + File.Delete(originalPath); + } + catch (Exception ex) + { + Log.Warn($"Failed to delete old XML profile file: {originalPath}. Error: {ex.Message}"); + } + + Log.Info($"Profile migration from XML to JSON completed successfully: {newPath}"); + } + else + { + groups = DeserializeFromByteArray(decryptedBytes); + } + + AddGroups(groups); // Password is valid ProfileFiles.FirstOrDefault(x => x.Equals(profileFileInfo))!.IsPasswordValid = true; @@ -450,7 +574,46 @@ private static void Load(ProfileFileInfo profileFileInfo) } else { - AddGroups(DeserializeFromFile(profileFileInfo.Path)); + List groups; + + if (isLegacyXmlFile) + { + Log.Info($"Legacy XML profile file found: {profileFileInfo.Path}. Migrating to JSON format..."); + groups = DeserializeFromXmlFile(profileFileInfo.Path); + + // Migrate to JSON format by saving the file + var newPath = Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtension); + SerializeToFile(newPath, groups); + + var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, newPath); + + // Update ProfileFiles collection + ProfileFiles.Remove(profileFileInfo); + ProfileFiles.Add(newProfileFileInfo); + + // Delete the old XML file + try + { + File.Delete(profileFileInfo.Path); + } + catch (Exception ex) + { + Log.Warn($"Failed to delete old XML profile file: {profileFileInfo.Path}. Error: {ex.Message}"); + } + + // Update the reference + profileFileInfo = newProfileFileInfo; + + Log.Info($"Profile migration from XML to JSON completed successfully: {newPath}"); + + loadedProfileUpdated = true; + } + else + { + groups = DeserializeFromFile(profileFileInfo.Path); + } + + AddGroups(groups); } } else @@ -468,6 +631,30 @@ private static void Load(ProfileFileInfo profileFileInfo) LoadedProfileFileChanged(LoadedProfileFile, true); } + /// + /// Method to check if the byte array content is XML. + /// + /// Byte array to check. + /// True if the content is XML. + private static bool IsXmlContent(byte[] data) + { + if (data == null || data.Length == 0) + return false; + + try + { + // Only check the first few bytes for performance + var bytesToCheck = Math.Min(XmlDetectionBufferSize, data.Length); + var text = Encoding.UTF8.GetString(data, 0, bytesToCheck).TrimStart(); + // Check for XML declaration or root element that matches profile structure + return text.StartsWith(" /// Method to save the currently loaded profiles based on the infos provided in the . /// @@ -483,7 +670,7 @@ public static void Save() Directory.CreateDirectory(GetProfilesFolderLocation()); - // Write to an xml file. + // Write to a JSON file (encrypted or unencrypted). if (LoadedProfileFile.IsEncrypted) { // Only if the password provided earlier was valid... @@ -539,17 +726,15 @@ public static void Switch(ProfileFileInfo info, bool saveLoadedProfiles = true) #region Serialize and deserialize /// - /// Method to serialize a list of groups as to an xml file. + /// Method to serialize a list of groups as to a JSON file. /// - /// Path to an xml file. + /// Path to a JSON file. /// List of the groups as to serialize. private static void SerializeToFile(string filePath, List groups) { - var xmlSerializer = new XmlSerializer(typeof(List)); - - using var fileStream = new FileStream(filePath, FileMode.Create); + var jsonString = JsonSerializer.Serialize(SerializeGroup(groups), JsonOptions); - xmlSerializer.Serialize(fileStream, SerializeGroup(groups)); + File.WriteAllText(filePath, jsonString); } /// @@ -559,15 +744,9 @@ private static void SerializeToFile(string filePath, List groups) /// Serialized list of groups as as byte array. private static byte[] SerializeToByteArray(List groups) { - var xmlSerializer = new XmlSerializer(typeof(List)); - - using var memoryStream = new MemoryStream(); + var jsonString = JsonSerializer.Serialize(SerializeGroup(groups), JsonOptions); - using var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8); - - xmlSerializer.Serialize(streamWriter, SerializeGroup(groups)); - - return memoryStream.ToArray(); + return Encoding.UTF8.GetBytes(jsonString); } /// @@ -629,40 +808,100 @@ private static List SerializeGroup(List groups } /// - /// Method to deserialize a list of groups as from an xml file. + /// Method to deserialize a list of groups as from a JSON file. /// - /// Path to an xml file. + /// Path to a JSON file. /// List of groups as . private static List DeserializeFromFile(string filePath) + { + var jsonString = File.ReadAllText(filePath); + + return DeserializeFromJson(jsonString); + } + + /// + /// Method to deserialize a list of groups as from a legacy XML file. + /// + /// Path to an XML file. + /// List of groups as . + [Obsolete("Legacy XML profile files are no longer used, but the method is kept for migration purposes.")] + private static List DeserializeFromXmlFile(string filePath) { using FileStream fileStream = new(filePath, FileMode.Open); - return DeserializeGroup(fileStream); + return DeserializeFromXmlStream(fileStream); } /// /// Method to deserialize a list of groups as from a byte array. /// - /// Serialized list of groups as as byte array. + /// Serialized list of groups as as byte array. /// List of groups as . - private static List DeserializeFromByteArray(byte[] xml) + private static List DeserializeFromByteArray(byte[] data) + { + var jsonString = Encoding.UTF8.GetString(data); + + return DeserializeFromJson(jsonString); + } + + /// + /// Method to deserialize a list of groups as from a legacy XML byte array. + /// + /// Serialized list of groups as as XML byte array. + /// List of groups as . + [Obsolete("Legacy XML profile files are no longer used, but the method is kept for migration purposes.")] + private static List DeserializeFromXmlByteArray(byte[] xml) { using MemoryStream memoryStream = new(xml); - return DeserializeGroup(memoryStream); + return DeserializeFromXmlStream(memoryStream); } /// - /// Method to deserialize a list of groups as . + /// Method to deserialize a list of groups as from JSON string. + /// + /// JSON string to deserialize. + /// List of groups as . + private static List DeserializeFromJson(string jsonString) + { + var groupsSerializable = JsonSerializer.Deserialize>(jsonString, JsonOptions); + + if (groupsSerializable == null) + throw new InvalidOperationException("Failed to deserialize JSON profile file."); + + return DeserializeGroup(groupsSerializable); + } + + /// + /// Method to deserialize a list of groups as from an XML stream. /// /// Stream to deserialize. /// List of groups as . - private static List DeserializeGroup(Stream stream) + [Obsolete("Legacy XML profile files are no longer used, but the method is kept for migration purposes.")] + private static List DeserializeFromXmlStream(Stream stream) { XmlSerializer xmlSerializer = new(typeof(List)); - return (from groupSerializable in ((List)xmlSerializer.Deserialize(stream))! - let profiles = groupSerializable.Profiles.Select(profileSerializable => new ProfileInfo(profileSerializable) + var groupsSerializable = xmlSerializer.Deserialize(stream) as List; + + if (groupsSerializable == null) + throw new InvalidOperationException("Failed to deserialize XML profile file."); + + return DeserializeGroup(groupsSerializable); + } + + /// + /// Method to deserialize a list of groups as . + /// + /// List of serializable groups to deserialize. + /// List of groups as . + private static List DeserializeGroup(List groupsSerializable) + { + if (groupsSerializable == null) + throw new ArgumentNullException(nameof(groupsSerializable)); + + return (from groupSerializable in groupsSerializable + let profiles = (groupSerializable.Profiles ?? new List()).Select(profileSerializable => new ProfileInfo(profileSerializable) { // Migrate old tags to new tags list // if TagsList is null or empty and Tags is not null or empty, split Tags by ';' and create a new ObservableSetCollection