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