diff --git a/Source/NETworkManager.Profiles/ProfileManager.cs b/Source/NETworkManager.Profiles/ProfileManager.cs index 19784d812b..87ad34f87d 100644 --- a/Source/NETworkManager.Profiles/ProfileManager.cs +++ b/Source/NETworkManager.Profiles/ProfileManager.cs @@ -1,5 +1,4 @@ using log4net; -using NETworkManager.Models.Network; using NETworkManager.Settings; using NETworkManager.Utilities; using System; @@ -9,6 +8,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; @@ -28,16 +29,43 @@ public static class ProfileManager /// private const string ProfilesDefaultFileName = "Default"; + /// + /// Settings backups directory name. + /// + private static string BackupFolderName => "Backups"; + /// /// Profile file extension. /// - private const string ProfileFileExtension = ".xml"; + private const string ProfileFileExtension = ".json"; + + /// + /// Legacy XML profile file extension. + /// + [Obsolete("Legacy XML profiles are no longer used, but the extension is kept for migration purposes.")] + private static 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. /// @@ -106,6 +134,36 @@ private static void LoadedProfileFileChanged(ProfileFileInfo profileFileInfo, bo OnLoadedProfileFileChangedEvent?.Invoke(null, new ProfileFileInfoArgs(profileFileInfo, profileFileUpdating)); } + /// + /// Occurs when the profile migration process begins. + /// + [Obsolete("Will be removed after some time, as profile migration from legacy XML files is a one-time process.")] + public static event EventHandler OnProfileMigrationStarted; + + /// + /// Raises the event indicating that the profile migration process from legacy XML files has started. + /// + [Obsolete("Will be removed after some time, as profile migration from legacy XML files is a one-time process.")] + private static void ProfileMigrationStarted() + { + OnProfileMigrationStarted?.Invoke(null, EventArgs.Empty); + } + + /// + /// Occurs when the profile migration from legacy XML files has completed. + /// + [Obsolete("Will be removed after some time, as profile migration from legacy XML files is a one-time process.")] + public static event EventHandler OnProfileMigrationCompleted; + + /// + /// Raises the event indicating that the profile migration from legacy XML files has completed. + /// + [Obsolete("Will be removed after some time, as profile migration from legacy XML files is a one-time process.")] + private static void ProfileMigrationCompleted() + { + OnProfileMigrationCompleted?.Invoke(null, EventArgs.Empty); + } + /// /// Event is fired if the profiles have changed. /// @@ -137,6 +195,15 @@ public static string GetProfilesFolderLocation() AssemblyManager.Current.Name, ProfilesFolderName); } + /// + /// Method to get the path of the profiles backup folder. + /// + /// Path to the profiles backup folder. + public static string GetSettingsBackupFolderLocation() + { + return Path.Combine(GetProfilesFolderLocation(), BackupFolderName); + } + /// /// Method to get the default profile file name. /// @@ -168,7 +235,9 @@ private static string GetProfilesDefaultFilePath() 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) == ProfileFileExtensionEncrypted || + Path.GetExtension(x) == LegacyProfileFileExtension); } /// @@ -180,10 +249,14 @@ private static void LoadProfileFiles() // Folder exists if (Directory.Exists(location)) + { foreach (var file in GetProfileFiles(location)) + { // Gets the filename, path and if the file is encrypted. ProfileFiles.Add(new ProfileFileInfo(Path.GetFileNameWithoutExtension(file), file, Path.GetFileName(file).EndsWith(ProfileFileExtensionEncrypted))); + } + } // Create default profile if no profile file exists. if (ProfileFiles.Count == 0) @@ -288,9 +361,9 @@ public static void EnableEncryption(ProfileFileInfo profileFileInfo, SecureStrin IsPasswordValid = true }; - // Load the profiles from the profile file - var profiles = DeserializeFromFile(profileFileInfo.Path); - + List profiles = Path.GetExtension(profileFileInfo.Path) == LegacyProfileFileExtension + ? DeserializeFromXmlFile(profileFileInfo.Path) + : DeserializeFromFile(profileFileInfo.Path); // Save the encrypted file var decryptedBytes = SerializeToByteArray(profiles); var encryptedBytes = CryptoHelper.Encrypt(decryptedBytes, @@ -348,7 +421,12 @@ 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); + + List profiles; + + profiles = IsXmlContent(decryptedBytes) + ? DeserializeFromXmlByteArray(decryptedBytes) + : DeserializeFromByteArray(decryptedBytes); // Save the encrypted file decryptedBytes = SerializeToByteArray(profiles); @@ -362,7 +440,6 @@ public static void ChangeMasterPassword(ProfileFileInfo profileFileInfo, SecureS // Add the new profile ProfileFiles.Add(newProfileFileInfo); - // Switch profile, if it was previously loaded if (switchProfile) { @@ -399,8 +476,10 @@ public static void DisableEncryption(ProfileFileInfo profileFileInfo, SecureStri var decryptedBytes = CryptoHelper.Decrypt(encryptedBytes, SecureStringHelper.ConvertToString(password), GlobalStaticConfiguration.Profile_EncryptionKeySize, GlobalStaticConfiguration.Profile_EncryptionIterations); - var profiles = DeserializeFromByteArray(decryptedBytes); + List profiles = IsXmlContent(decryptedBytes) + ? DeserializeFromXmlByteArray(decryptedBytes) + : DeserializeFromByteArray(decryptedBytes); // Save the decrypted profiles to the profile file SerializeToFile(newProfileFileInfo.Path, profiles); @@ -431,8 +510,11 @@ private static void Load(ProfileFileInfo profileFileInfo) { var loadedProfileUpdated = false; + Log.Info($"Load profile file: {profileFileInfo.Path}"); + if (File.Exists(profileFileInfo.Path)) { + // Encrypted profile file if (profileFileInfo.IsEncrypted) { var encryptedBytes = File.ReadAllBytes(profileFileInfo.Path); @@ -441,16 +523,105 @@ private static void Load(ProfileFileInfo profileFileInfo) GlobalStaticConfiguration.Profile_EncryptionKeySize, GlobalStaticConfiguration.Profile_EncryptionIterations); - AddGroups(DeserializeFromByteArray(decryptedBytes)); + List groups; + + if (IsXmlContent(decryptedBytes)) + { + // + // MIGRATION FROM LEGACY XML PROFILE FILE + // + + Log.Info($"Legacy XML profile file detected inside encrypted profile: {profileFileInfo.Path}. Migration in progress..."); + + // Load from legacy XML byte array + groups = DeserializeFromXmlByteArray(decryptedBytes); + + // Create a backup of the legacy XML file + Backup(profileFileInfo.Path, + GetSettingsBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(Path.GetFileName(profileFileInfo.Path))); + + // Save encrypted profile file with new JSON format + var newDecryptedBytes = SerializeToByteArray([.. groups]); + var newEncryptedBytes = CryptoHelper.Encrypt(newDecryptedBytes, + SecureStringHelper.ConvertToString(profileFileInfo.Password), + GlobalStaticConfiguration.Profile_EncryptionKeySize, + GlobalStaticConfiguration.Profile_EncryptionIterations); + + File.WriteAllBytes(profileFileInfo.Path, newEncryptedBytes); + + Log.Info($"Legacy XML profile file migration completed inside encrypted profile: {profileFileInfo.Path}."); + } + else + { + groups = DeserializeFromByteArray(decryptedBytes); + } + + AddGroups(groups); // Password is valid ProfileFiles.FirstOrDefault(x => x.Equals(profileFileInfo))!.IsPasswordValid = true; profileFileInfo.IsPasswordValid = true; loadedProfileUpdated = true; } + // Unencrypted profile file else { - AddGroups(DeserializeFromFile(profileFileInfo.Path)); + List groups; + + if (Path.GetExtension(profileFileInfo.Path) == LegacyProfileFileExtension) + { + // + // MIGRATION FROM LEGACY XML PROFILE FILE + // + Log.Info($"Legacy XML profile file detected: {profileFileInfo.Path}. Migration in progress..."); + + // Load from legacy XML file + groups = DeserializeFromXmlFile(profileFileInfo.Path); + + ProfilesChanged = false; + + LoadedProfileFile = profileFileInfo; + + // Create a backup of the legacy XML file and delete the original + Backup(profileFileInfo.Path, + GetSettingsBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(Path.GetFileName(profileFileInfo.Path))); + + // Create new profile file info with JSON extension + var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name, + Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtension)); + + // Save new JSON file + SerializeToFile(newProfileFileInfo.Path, groups); + + // Notify migration started + ProfileMigrationStarted(); + + // Add the new profile + ProfileFiles.Add(newProfileFileInfo); + + // Switch profile + Log.Info($"Switching to migrated profile file: {newProfileFileInfo.Path}."); + Switch(newProfileFileInfo, false); + LoadedProfileFileChanged(LoadedProfileFile, true); + + // Remove the old profile file + File.Delete(profileFileInfo.Path); + ProfileFiles.Remove(profileFileInfo); + + // Notify migration completed + ProfileMigrationCompleted(); + + Log.Info($"Legacy XML profile file migration completed: {profileFileInfo.Path}."); + return; + } + else + { + groups = DeserializeFromFile(profileFileInfo.Path); + } + + AddGroups(groups); } } else @@ -480,7 +651,7 @@ public static void Save() return; } - + // Ensure the profiles directory exists. Directory.CreateDirectory(GetProfilesFolderLocation()); // Write to an xml file. @@ -539,17 +710,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)); + var jsonString = JsonSerializer.Serialize(SerializeGroup(groups), JsonOptions); - using var fileStream = new FileStream(filePath, FileMode.Create); - - xmlSerializer.Serialize(fileStream, SerializeGroup(groups)); + File.WriteAllText(filePath, jsonString); } /// @@ -559,15 +728,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(); - - using var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8); - - xmlSerializer.Serialize(streamWriter, SerializeGroup(groups)); + var jsonString = JsonSerializer.Serialize(SerializeGroup(groups), JsonOptions); - return memoryStream.ToArray(); + return Encoding.UTF8.GetBytes(jsonString); } /// @@ -577,7 +740,7 @@ private static byte[] SerializeToByteArray(List groups) /// Serialized list of groups as . private static List SerializeGroup(List groups) { - List groupsSerializable = new(); + List groupsSerializable = []; foreach (var group in groups) { @@ -629,40 +792,125 @@ 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 check if the byte array content is XML. + /// + /// Byte array to check. + /// True if the content is XML. + [Obsolete("Legacy XML profile files are no longer used, but the method is kept for migration purposes.")] + 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 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 @@ -713,7 +961,7 @@ private static List DeserializeGroup(Stream stream) SNMP_Priv = !string.IsNullOrEmpty(groupSerializable.SNMP_Priv) ? SecureStringHelper.ConvertToSecureString(groupSerializable.SNMP_Priv) : null - }).ToList(); + }]; } #endregion @@ -867,4 +1115,28 @@ public static void RemoveProfiles(IEnumerable profiles) } #endregion -} \ No newline at end of file + + #region Backup + + /// + /// Creates a backup of the specified profile file in the given backup folder with the provided backup file name. + /// + /// The full path to the profile file to back up. Cannot be null or empty. + /// The directory path where the backup file will be stored. If the directory does not exist, it will be created. + /// The name to use for the backup file within the backup folder. Cannot be null or empty. + private static void Backup(string filePath, string backupFolderPath, string backupFileName) + { + // Create the backup directory if it does not exist + Directory.CreateDirectory(backupFolderPath); + + // Create the backup file path + var backupFilePath = Path.Combine(backupFolderPath, backupFileName); + + // Copy the current profile file to the backup location + File.Copy(filePath, backupFilePath, true); + + Log.Info($"Backup created: {backupFilePath}"); + } + + #endregion +} diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 61f95802ac..7c0041f063 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -327,10 +327,10 @@ private static void CleanupBackups(string backupFolderPath, string settingsFileN /// /// Creates a backup of the specified settings file in the given backup folder with the provided backup file name. /// - /// The full path to the settings file to back up. Cannot be null or empty. + /// The full path to the settings file to back up. Cannot be null or empty. /// The directory path where the backup file will be stored. If the directory does not exist, it will be created. /// The name to use for the backup file within the backup folder. Cannot be null or empty. - private static void Backup(string settingsFilePath, string backupFolderPath, string backupFileName) + private static void Backup(string filePath, string backupFolderPath, string backupFileName) { // Create the backup directory if it does not exist Directory.CreateDirectory(backupFolderPath); @@ -339,7 +339,7 @@ private static void Backup(string settingsFilePath, string backupFolderPath, str var backupFilePath = Path.Combine(backupFolderPath, backupFileName); // Copy the current settings file to the backup location - File.Copy(settingsFilePath, backupFilePath, true); + File.Copy(filePath, backupFilePath, true); Log.Info($"Backup created: {backupFilePath}"); } diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index 75f86b6cc8..6546c727be 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -33,7 +33,6 @@ using System.Windows.Interop; using System.Windows.Markup; using System.Windows.Threading; -using static System.Runtime.InteropServices.JavaScript.JSType; using Application = System.Windows.Application; using ContextMenu = System.Windows.Controls.ContextMenu; using MouseEventArgs = System.Windows.Forms.MouseEventArgs; @@ -1376,6 +1375,8 @@ private void LoadProfiles() _isProfileFilesLoading = false; ProfileManager.OnLoadedProfileFileChangedEvent += ProfileManager_OnLoadedProfileFileChangedEvent; + ProfileManager.OnProfileMigrationStarted += ProfileManager_OnProfileMigrationStarted; + ProfileManager.OnProfileMigrationCompleted += ProfileManager_OnProfileMigrationCompleted; SelectedProfileFile = ProfileFiles.SourceCollection.Cast() .FirstOrDefault(x => x.Name == SettingsManager.Current.Profiles_LastSelected); @@ -1481,6 +1482,16 @@ private void ProfileManager_OnLoadedProfileFileChangedEvent(object sender, Profi _isProfileFileUpdating = false; } + private void ProfileManager_OnProfileMigrationCompleted(object sender, EventArgs e) + { + _isProfileFileUpdating = false; + } + + private void ProfileManager_OnProfileMigrationStarted(object sender, EventArgs e) + { + _isProfileFileUpdating = true; + } + #endregion #region Update check diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index db4f8873e5..28bae845be 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -29,6 +29,16 @@ Release date: **xx.xx.2025** ::: +- Profile and settings files have been migrated from `XML` to `JSON`. Existing files will be automatically converted to `JSON` on first load after the update. [#3282](https://github.com/BornToBeRoot/NETworkManager/pull/3282) [#3299](https://github.com/BornToBeRoot/NETworkManager/pull/3299) + + :::info + + Starting with this release, new profile and settings files are created in `JSON` format. Existing `XML` files will be converted automatically on first load after upgrading. Automatic support for the migration will be provided until at least `2027`; after that only `JSON` files will be supported and very old installations may require an interim update. + + The migration process creates a backup of the original files in the `Backups` subfolder of the settings and profiles directories. You can restore the originals from that folder if needed, but it's recommended to make a separate backup of your profile and settings files before updating. + + ::: + ## What's new? - New language Ukrainian (`uk-UA`) has been added. Thanks to [@vadickkt](https://github.com/vadickkt) [#3240](https://github.com/BornToBeRoot/NETworkManager/pull/3240) @@ -47,13 +57,14 @@ Release date: **xx.xx.2025** **Profiles** +- Profile file format migrated from `XML` to `JSON`. The profile file will be automatically converted on first load after the update. [#3299](https://github.com/BornToBeRoot/NETworkManager/pull/3299) - Profile file creation flow improved — when adding a new profile you are now prompted to enable profile-file encryption to protect stored credentials and settings. [#3227](https://github.com/BornToBeRoot/NETworkManager/pull/3227) - Profile file dialog migrated to a child window to improve usability. [#3227](https://github.com/BornToBeRoot/NETworkManager/pull/3227) - Credential dialogs migrated to child windows to improve usability. [#3231](https://github.com/BornToBeRoot/NETworkManager/pull/3231) **Settings** -- Settings format migrated from `XML` to `JSON`. The settings file will be automatically converted on first start after the update. [#3282](https://github.com/BornToBeRoot/NETworkManager/pull/3282) +- Settings file format migrated from `XML` to `JSON`. The settings file will be automatically converted on first load after the update. [#3282](https://github.com/BornToBeRoot/NETworkManager/pull/3282) - Create a daily backup of the settings file before saving changes. Up to `10` backup files are kept in the `Backups` subfolder of the settings directory. [#3283](https://github.com/BornToBeRoot/NETworkManager/pull/3283) **Dashboard**