Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/PRODUCT-RENAME-EFFORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

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<IProfileEncryptionService, ProfileEncryptionService>();
Expand Down
2 changes: 1 addition & 1 deletion src/Intune.Commander.Core/Services/CacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
24 changes: 23 additions & 1 deletion src/Intune.Commander.Core/Services/ProfileEncryptionService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;

namespace Intune.Commander.Core.Services;
Expand All @@ -9,20 +10,41 @@ 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)
{
return _protector.Protect(plainText);
}

/// <summary>
/// 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.
/// </summary>
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);
}
Comment on lines +40 to +48

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decrypt() currently catches all exceptions from Unprotect() and falls back to the legacy protector. It would be safer to only fall back on the expected cryptographic failure (e.g., CryptographicException) so unrelated issues (like argument/DI errors) aren’t accidentally masked by the fallback path.

Copilot uses AI. Check for mistakes.
}
}
63 changes: 57 additions & 6 deletions src/Intune.Commander.Core/Services/ProfileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -18,9 +21,29 @@ public class ProfileService
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

public ProfileService(string? profilePath = null, IProfileEncryptionService? encryption = null)
/// <param name="profilePath">
/// Explicit path for profiles.json. Defaults to the current app-data location.
/// Pass null to use the default (enables legacy migration on first load).
/// </param>
/// <param name="encryption">Optional encryption service.</param>
/// <param name="legacyProfilePath">
/// Override for the legacy profiles.json path used during migration testing.
/// Only effective when <paramref name="profilePath"/> is also specified.
/// When <paramref name="profilePath"/> is null the real legacy default is used.
/// </param>
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();
}
Expand Down Expand Up @@ -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..]);
Expand All @@ -107,6 +145,11 @@ public async Task LoadAsync(CancellationToken cancellationToken = default)
}

_store = JsonSerializer.Deserialize<ProfileStore>(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)
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,30 @@ public void Decrypt_InvalidData_Throws()
{
Assert.ThrowsAny<Exception>(() => _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<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>();

// 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);
}
Comment on lines +69 to +86

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ServiceProvider is disposed at the end of the test, but if an assertion throws before that line it won’t be disposed. Prefer using var sp = services.BuildServiceProvider(); (or try/finally) so resources are always cleaned up even on test failure.

Copilot uses AI. Check for mistakes.
}

public class EncryptedProfileServiceTests : IDisposable
Expand Down
51 changes: 51 additions & 0 deletions tests/Intune.Commander.Core.Tests/Services/ProfileServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down