diff --git a/docs/PRODUCT-RENAME-EFFORT.md b/docs/PRODUCT-RENAME-EFFORT.md index d4ed6879..d8c619b0 100644 --- a/docs/PRODUCT-RENAME-EFFORT.md +++ b/docs/PRODUCT-RENAME-EFFORT.md @@ -58,8 +58,8 @@ Per PR feedback, proceed with **Option B (full technical rename)**. - [x] Rename solution/projects/folders from `IntuneManager*` to `Intune.Commander*`. *(Phase 1 — PR #46)* - [x] Rename namespaces (`IntuneManager.*` → `Intune.Commander.*`) and fix all compile references. *(Phase 1 — PR #46)* -- [x] Update CI/workflows/scripts/docs for renamed paths and project names. *(Phase 2 — this PR)* -- [ ] Implement runtime migration to preserve existing local profile/cache readability. *(Phase 3)* +- [x] Update CI/workflows/scripts/docs for renamed paths and project names. *(Phase 2 — PR #48)* +- [x] Implement runtime migration to preserve existing local profile/cache readability. *(Phase 3 — this PR)* - [ ] Validate with `dotnet build` and `dotnet test --filter "Category!=Integration"`. *(Phase 4)* ### Acceptance criteria for Option B diff --git a/src/Intune.Commander.Core/Extensions/ServiceCollectionExtensions.cs b/src/Intune.Commander.Core/Extensions/ServiceCollectionExtensions.cs index 3c7d7db9..61b56bf9 100644 --- a/src/Intune.Commander.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Intune.Commander.Core/Extensions/ServiceCollectionExtensions.cs @@ -9,13 +9,45 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddIntuneManagerCore(this IServiceCollection services) { - // DataProtection — keys stored in the user's local app data folder - var keysPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "IntuneManager", "keys"); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + // DataProtection — keys stored in the user's local app data folder. + // NOTE: SetApplicationName("IntuneManager") is intentionally preserved as a + // read-only compatibility constant (Phase 3). Changing it would make all + // existing encrypted profile data permanently unreadable. It will be + // re-evaluated in Phase 4 once all legacy data has been migrated. + var legacyKeysPath = Path.Combine(appData, "IntuneManager", "keys"); + var keysPath = Path.Combine(appData, "Intune.Commander", "keys"); + + // One-time migration: copy DataProtection key XML files from the legacy + // location to the new one so existing encrypted profiles remain readable. + var legacyKeyFiles = Directory.Exists(legacyKeysPath) + ? Directory.GetFiles(legacyKeysPath, "*.xml") + : Array.Empty(); + + var newKeyDirectoryHasKeys = Directory.Exists(keysPath) + && Directory.GetFiles(keysPath, "*.xml").Length > 0; + + if (legacyKeyFiles.Length > 0 && !newKeyDirectoryHasKeys) + { + Directory.CreateDirectory(keysPath); + + foreach (var file in legacyKeyFiles) + { + var destinationPath = Path.Combine(keysPath, Path.GetFileName(file)); + if (File.Exists(destinationPath)) + continue; + + try + { + File.Copy(file, destinationPath, overwrite: false); + } + catch { /* best-effort — continue copying remaining keys */ } + } + } services.AddDataProtection() - .SetApplicationName("IntuneManager") + .SetApplicationName("IntuneManager") // Compatibility constant — see note above .PersistKeysToFileSystem(new DirectoryInfo(keysPath)); services.AddSingleton(); diff --git a/src/Intune.Commander.Core/Services/CacheService.cs b/src/Intune.Commander.Core/Services/CacheService.cs index 1934b6c6..61e9cc8f 100644 --- a/src/Intune.Commander.Core/Services/CacheService.cs +++ b/src/Intune.Commander.Core/Services/CacheService.cs @@ -30,7 +30,7 @@ public CacheService(IDataProtectionProvider dataProtectionProvider, string? base { var appDataPath = basePath ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "IntuneManager"); + "Intune.Commander"); Directory.CreateDirectory(appDataPath); var dbPath = Path.Combine(appDataPath, "cache.db"); diff --git a/src/Intune.Commander.Core/Services/ProfileEncryptionService.cs b/src/Intune.Commander.Core/Services/ProfileEncryptionService.cs index 6f7a83af..c7c5ed80 100644 --- a/src/Intune.Commander.Core/Services/ProfileEncryptionService.cs +++ b/src/Intune.Commander.Core/Services/ProfileEncryptionService.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using Microsoft.AspNetCore.DataProtection; namespace Intune.Commander.Core.Services; @@ -9,11 +10,19 @@ namespace Intune.Commander.Core.Services; public class ProfileEncryptionService : IProfileEncryptionService { private const string Purpose = "Intune.Commander.Profiles.v1"; + + // Legacy purpose string used before the Intune.Commander rename (Phase 1). + // Kept as a read-only compatibility constant so profiles encrypted under the + // old name can still be decrypted and migrated during Phase 3 runtime migration. + private const string LegacyPurpose = "IntuneManager.Profiles.v1"; + private readonly IDataProtector _protector; + private readonly IDataProtector _legacyProtector; public ProfileEncryptionService(IDataProtectionProvider provider) { _protector = provider.CreateProtector(Purpose); + _legacyProtector = provider.CreateProtector(LegacyPurpose); } public string Encrypt(string plainText) @@ -21,8 +30,21 @@ public string Encrypt(string plainText) return _protector.Protect(plainText); } + /// + /// Decrypts cipherText. Tries the current purpose first; if that fails, falls + /// back to the legacy purpose string so profiles written before the rename can + /// still be read and migrated. + /// public string Decrypt(string cipherText) { - return _protector.Unprotect(cipherText); + try + { + return _protector.Unprotect(cipherText); + } + catch (CryptographicException) + { + // Fall back to legacy purpose — will throw if both fail + return _legacyProtector.Unprotect(cipherText); + } } } diff --git a/src/Intune.Commander.Core/Services/ProfileService.cs b/src/Intune.Commander.Core/Services/ProfileService.cs index d5a73754..d69375d4 100644 --- a/src/Intune.Commander.Core/Services/ProfileService.cs +++ b/src/Intune.Commander.Core/Services/ProfileService.cs @@ -6,10 +6,13 @@ namespace Intune.Commander.Core.Services; public class ProfileService { private readonly string _profilePath; + private readonly string? _legacyProfilePath; private readonly IProfileEncryptionService? _encryption; private ProfileStore _store; - // Marker prefix to detect encrypted files + // Marker prefix to detect encrypted files. + // Preserved as a compatibility constant — new saves also use this marker so + // the format is stable across the rename and existing tooling keeps working. private const string EncryptedMarker = "INTUNEMANAGER_ENC:"; private static readonly JsonSerializerOptions JsonOptions = new() @@ -18,9 +21,29 @@ public class ProfileService PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - public ProfileService(string? profilePath = null, IProfileEncryptionService? encryption = null) + /// + /// Explicit path for profiles.json. Defaults to the current app-data location. + /// Pass null to use the default (enables legacy migration on first load). + /// + /// Optional encryption service. + /// + /// Override for the legacy profiles.json path used during migration testing. + /// Only effective when is also specified. + /// When is null the real legacy default is used. + /// + public ProfileService(string? profilePath = null, IProfileEncryptionService? encryption = null, + string? legacyProfilePath = null) { - _profilePath = profilePath ?? GetDefaultProfilePath(); + if (profilePath == null) + { + _profilePath = GetDefaultProfilePath(); + _legacyProfilePath = GetLegacyProfilePath(); + } + else + { + _profilePath = profilePath; + _legacyProfilePath = legacyProfilePath; + } _encryption = encryption; _store = new ProfileStore(); } @@ -72,18 +95,33 @@ public void RemoveProfile(string profileId) public async Task LoadAsync(CancellationToken cancellationToken = default) { - if (!File.Exists(_profilePath)) + // Phase 3 runtime migration: if the primary path doesn't exist but a legacy + // path does, load from the legacy location. After a successful load we + // re-save to the primary path so subsequent launches use the new location. + var pathToLoad = _profilePath; + var migratingFromLegacy = false; + + if (!File.Exists(pathToLoad) && _legacyProfilePath != null && File.Exists(_legacyProfilePath)) + { + pathToLoad = _legacyProfilePath; + migratingFromLegacy = true; + } + + if (!File.Exists(pathToLoad)) { _store = new ProfileStore(); return; } - var raw = await File.ReadAllTextAsync(_profilePath, cancellationToken); + var raw = await File.ReadAllTextAsync(pathToLoad, cancellationToken); string json; if (_encryption is not null && raw.StartsWith(EncryptedMarker)) { - // Encrypted file — decrypt the payload after the marker + // Encrypted file — decrypt the payload after the marker. + // ProfileEncryptionService.Decrypt tries the current purpose string first, + // then falls back to the legacy "IntuneManager.Profiles.v1" purpose so + // profiles written before the rename remain readable. try { json = _encryption.Decrypt(raw[EncryptedMarker.Length..]); @@ -107,6 +145,11 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) } _store = JsonSerializer.Deserialize(json, JsonOptions) ?? new ProfileStore(); + + // If we loaded from the legacy location, persist to the new location immediately + // so the next launch reads from the correct path. + if (migratingFromLegacy) + await SaveAsync(cancellationToken); } public async Task SaveAsync(CancellationToken cancellationToken = default) @@ -131,6 +174,14 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) } private static string GetDefaultProfilePath() + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(appData, "Intune.Commander", "profiles.json"); + } + + // Legacy path from before the product rename. Read-only compatibility constant — + // used only to detect and migrate existing user data during Phase 3 migration. + private static string GetLegacyProfilePath() { var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); return Path.Combine(appData, "IntuneManager", "profiles.json"); diff --git a/tests/Intune.Commander.Core.Tests/Services/ProfileEncryptionServiceTests.cs b/tests/Intune.Commander.Core.Tests/Services/ProfileEncryptionServiceTests.cs index 2401eb46..edd8a8d0 100644 --- a/tests/Intune.Commander.Core.Tests/Services/ProfileEncryptionServiceTests.cs +++ b/tests/Intune.Commander.Core.Tests/Services/ProfileEncryptionServiceTests.cs @@ -60,6 +60,30 @@ public void Decrypt_InvalidData_Throws() { Assert.ThrowsAny(() => _encryption.Decrypt("not-valid-encrypted-data")); } + + [Fact] + public void Decrypt_LegacyPurpose_SucceedsViaFallback() + { + // Simulate data that was encrypted before the rename using the legacy purpose string. + // The service must fall back to "IntuneManager.Profiles.v1" and decrypt it successfully. + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + services.AddDataProtection() + .SetApplicationName("IntuneManager-Tests") + .PersistKeysToFileSystem(new DirectoryInfo(_keysDir)); + using var sp = services.BuildServiceProvider(); + var provider = sp.GetRequiredService(); + + // Encrypt directly under the legacy purpose (bypassing ProfileEncryptionService.Encrypt) + var legacyProtector = provider.CreateProtector("IntuneManager.Profiles.v1"); + var plainText = "{\"profiles\":[],\"activeProfileId\":null}"; + var legacyEncrypted = legacyProtector.Protect(plainText); + + // The service (which normally uses "Intune.Commander.Profiles.v1") must fall back + // and successfully decrypt the legacy-purpose ciphertext. + var decrypted = _encryption.Decrypt(legacyEncrypted); + + Assert.Equal(plainText, decrypted); + } } public class EncryptedProfileServiceTests : IDisposable diff --git a/tests/Intune.Commander.Core.Tests/Services/ProfileServiceTests.cs b/tests/Intune.Commander.Core.Tests/Services/ProfileServiceTests.cs index 649d8041..e09998eb 100644 --- a/tests/Intune.Commander.Core.Tests/Services/ProfileServiceTests.cs +++ b/tests/Intune.Commander.Core.Tests/Services/ProfileServiceTests.cs @@ -167,6 +167,57 @@ public void Constructor_DefaultPath_HasNoProfiles() Assert.Empty(service.Profiles); } + [Fact] + public async Task Load_LegacyPath_MigratesToPrimaryPath() + { + // Arrange — write a profile to the "legacy" location only + var legacyDir = Path.Combine(Path.GetTempPath(), $"intunemanager-legacy-{Guid.NewGuid():N}"); + var legacyPath = Path.Combine(legacyDir, "profiles.json"); + var newPath = Path.Combine(Path.GetTempPath(), $"intunemanager-new-{Guid.NewGuid():N}", "profiles.json"); + try + { + Directory.CreateDirectory(legacyDir); + var legacyService = new ProfileService(legacyPath); + legacyService.AddProfile(CreateTestProfile("LegacyUser")); + await legacyService.SaveAsync(); + + // Act — create service pointing at new path with legacy fallback + var service = new ProfileService(newPath, legacyProfilePath: legacyPath); + await service.LoadAsync(); + + // Profiles loaded from legacy location + Assert.Single(service.Profiles); + Assert.Equal("LegacyUser", service.Profiles[0].Name); + + // New path was written (migration happened) + Assert.True(File.Exists(newPath)); + + // Legacy file still present (we don't delete it) + Assert.True(File.Exists(legacyPath)); + + // Subsequent load from new path works without legacy + var reloaded = new ProfileService(newPath); + await reloaded.LoadAsync(); + Assert.Single(reloaded.Profiles); + Assert.Equal("LegacyUser", reloaded.Profiles[0].Name); + } + finally + { + try { Directory.Delete(legacyDir, true); } catch { } + var newDir = Path.GetDirectoryName(newPath); + if (newDir != null) try { Directory.Delete(newDir, true); } catch { } + } + } + + [Fact] + public async Task Load_NoLegacyPath_ReturnsEmpty() + { + // When neither new nor legacy path exists, should get empty store + var service = new ProfileService(_tempPath, legacyProfilePath: null); + await service.LoadAsync(); + Assert.Empty(service.Profiles); + } + private static TenantProfile CreateTestProfile(string name) => new() { Name = name,