diff --git a/src/Files.Platform.Linux/Volumes/UDisks2Model.cs b/src/Files.Platform.Linux/Volumes/UDisks2Model.cs index aba444bdb63f..7bc361b0253d 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2Model.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2Model.cs @@ -183,7 +183,7 @@ public static IReadOnlyList BuildVolumes(UDisks2Objects objects) var optical = false; var canEject = false; var canPowerOff = false; - if (block.TryGetValue("Drive", out var driveValue) && driveValue is string drivePath && drivePath != "/" && + if (DrivePathOf(objects, block) is { } drivePath && objects.TryGetValue(drivePath, out var driveInterfaces) && driveInterfaces.TryGetValue(DriveInterface, out var drive)) { driveId = drivePath; @@ -246,6 +246,30 @@ public static IReadOnlyList BuildVolumes(UDisks2Objects objects) return null; } + /// The drive of a block device; a cleartext device has none of its own, so it uses its container's. + private static string? DrivePathOf(UDisks2Objects objects, Dictionary block) + { + if (block.TryGetValue("Drive", out var value) && value is string drive && drive != "/") + return drive; + + if (String(block, "CryptoBackingDevice") is { } backing && objects.TryGetValue(backing, out var backingInterfaces) && + backingInterfaces.TryGetValue(BlockInterface, out var backingBlock) && + backingBlock.TryGetValue("Drive", out value) && value is string backingDrive && backingDrive != "/") + return backingDrive; + + return null; + } + + /// Finds the encrypted container behind the cleartext device , or null if it is not a cleartext device. + public static string? FindCryptoBacking(UDisks2Objects objects, string cleartext) + { + if (objects.TryGetValue(cleartext, out var interfaces) && interfaces.TryGetValue(BlockInterface, out var block) && + String(block, "CryptoBackingDevice") is { } backing && IsBlockPath(backing) && objects.ContainsKey(backing)) + return backing; + + return null; + } + /// Whether belongs to the system rather than being a user-visible drive. public static bool IsPlumbingMountPoint(string mountPoint) { @@ -295,4 +319,26 @@ private static ulong UInt64(Dictionary properties, string key) private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; } + + /// + /// Remembers the cleartext devices the app unlocked itself, so only those are locked again; volumes that were already unlocked are left alone. + /// + public sealed class UDisks2UnlockTracker + { + private readonly Dictionary unlocked = new(StringComparer.Ordinal); + + /// Records that the app unlocked as . + public void Track(string cleartextPath, string containerPath) + { + lock (unlocked) + unlocked[cleartextPath] = containerPath; + } + + /// Forgets and returns the container the app unlocked behind it, or null if it did not. + public string? TryForget(string cleartextPath) + { + lock (unlocked) + return unlocked.Remove(cleartextPath, out var container) ? container : null; + } + } } diff --git a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs index 5606c7c49e9f..298993922ecc 100644 --- a/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs +++ b/src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs @@ -5,6 +5,7 @@ using Files.Platform.Linux.DBus; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Threading; @@ -33,6 +34,7 @@ public sealed class UDisks2VolumeService : IVolumeService private const int MaxPassphraseAttempts = 5; private readonly IVolumePassphrasePrompt? passphrasePrompt; + private readonly UDisks2UnlockTracker unlockTracker = new(); private readonly SemaphoreSlim connectGate = new(1, 1); private readonly object watchGate = new(); private DBusConnection? connection; @@ -166,9 +168,11 @@ public async Task UnmountAsync(string volumeId, CancellationToken cancellationTo try { await CallAsync(volumeId, UDisks2Parser.FilesystemInterface, "Unmount", static (Message _, object? _) => null, cancellationToken).ConfigureAwait(false); + await RelockAsync(volumeId, cancellationToken).ConfigureAwait(false); } catch (DBusErrorReplyException ex) when (ex.ErrorName == "org.freedesktop.UDisks2.Error.NotMounted") { + await RelockAsync(volumeId, cancellationToken).ConfigureAwait(false); } catch (DBusErrorReplyException ex) { @@ -176,6 +180,27 @@ public async Task UnmountAsync(string volumeId, CancellationToken cancellationTo } } + /// Locks the container behind if the app unlocked it; a failure is logged and never fails the unmount. + private async Task RelockAsync(string cleartextId, CancellationToken cancellationToken) + { + if (unlockTracker.TryForget(cleartextId) is not { } tracked) + return; + + try + { + var objects = await GetObjectsAsync(cancellationToken).ConfigureAwait(false); + // A reused device name may now belong to a volume unlocked elsewhere + if (UDisks2Parser.FindCryptoBacking(objects, cleartextId) is not { } container || container != tracked) + return; + + await CallAsync(container, UDisks2Parser.EncryptedInterface, "Lock", static (Message _, object? _) => null, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException && (IsBackendFailure(ex) || ex is VolumeOperationException)) + { + Debug.WriteLine($"Could not lock {cleartextId} after unmounting: {ex.Message}"); + } + } + /// public async Task EjectAsync(string volumeId, CancellationToken cancellationToken = default) { @@ -280,6 +305,7 @@ private async Task UnlockAsync(string volumeId, byte[] passphrase, Cance var result = await bus.CallTracedAsync("dbus.volumes", message, static (Message m, object? _) => m.GetBodyReader().ReadObjectPathAsString(), null) .WaitAsync(CallTimeout, cancellationToken).ConfigureAwait(false); await Task.Yield(); + unlockTracker.Track(result, volumeId); return result; } catch (DBusErrorReplyException ex) when (IsAlreadyUnlocked(ex)) @@ -311,8 +337,13 @@ private static bool IsAlreadyUnlocked(DBusErrorReplyException ex) var siblings = volume.DriveId is null ? [volume] : volumes.Where(v => v.DriveId == volume.DriveId).ToList(); - foreach (var sibling in siblings.Where(v => v.IsMounted)) - await UnmountAsync(sibling.Id, cancellationToken).ConfigureAwait(false); + foreach (var sibling in siblings) + { + if (sibling.IsMounted) + await UnmountAsync(sibling.Id, cancellationToken).ConfigureAwait(false); + else + await RelockAsync(sibling.Id, cancellationToken).ConfigureAwait(false); + } return (volume.DriveId, volume.CanEject, volume.CanPowerOff); } diff --git a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs index 251408626015..ea6b9fc314ee 100644 --- a/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs +++ b/tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs @@ -33,7 +33,7 @@ internal sealed class FakeUDisks2 : IPathMethodHandler, IDisposable public List Calls { get; } = new(); - /// Errors to answer with, keyed by Interface.Member. + /// Errors to answer with, keyed by Interface.Member (org.freedesktop.UDisks2.Encrypted.Lock makes Lock fail). public Dictionary Errors { get; } = new(); /// Messages for , keyed the same way (default "fake failure"). @@ -275,6 +275,15 @@ public ValueTask HandleMethodAsync(MethodContext context) if (iface == UDisks2Parser.FilesystemInterface && member == "Unmount" && block is not null) SetMounts(block); + if (iface == UDisks2Parser.EncryptedInterface && member == "Lock" && block is not null) + { + lock (gate) + { + Objects.Remove(Root + "/block_devices/dm_" + block); + Objects[path2][UDisks2Parser.EncryptedInterface]["CleartextDevice"] = new ObjPath("/"); + } + } + using var empty = context.CreateReplyWriter(""); context.Reply(empty.CreateMessage()); return ValueTask.CompletedTask; diff --git a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs index 10c8510cf2be..fa77681c2cff 100644 --- a/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs +++ b/tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs @@ -222,6 +222,163 @@ public async Task WrongPassphraseStopsPromptingAfterTheRetryLimit() } } + private const string LockMember = "org.freedesktop.UDisks2.Encrypted.Lock"; + + private static List Members(FakeUDisks2 fake) => fake.CallsSnapshot().Select(c => c.Interface.Split('.').Last() + "." + c.Member).ToList(); + + [TestMethod] + public async Task UnmountLocksTheContainerTheAppUnlocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + await service.UnmountAsync(Cleartext); + + CollectionAssert.AreEqual(new[] { "Encrypted.Unlock", "Filesystem.Mount", "Filesystem.Unmount", "Encrypted.Lock" }, Members(fake).ToArray()); + Assert.AreEqual(Locked, fake.CallsSnapshot()[3].Path); + Assert.IsTrue((await service.GetVolumesAsync()).Single().IsLocked); + + // The entry is forgotten: after an external unlock, unmounting does not lock again + fake.AddCleartext("sde1"); + await service.UnmountAsync(Cleartext); + Assert.AreEqual(1, Members(fake).Count(m => m == "Encrypted.Lock")); + } + } + + [TestMethod] + public async Task EjectLocksTheContainerTheAppUnlocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + await service.EjectAsync(Cleartext); + + var members = Members(fake); + Assert.IsTrue(members.IndexOf("Filesystem.Unmount") < members.IndexOf("Encrypted.Lock")); + Assert.AreEqual(Locked, fake.CallsSnapshot().Single(c => c.Member == "Lock").Path); + + // The drive is ejected after the container is locked, though the cleartext device has no drive of its own + Assert.IsTrue(members.IndexOf("Encrypted.Lock") < members.IndexOf("Drive.Eject")); + } + } + + [TestMethod] + public async Task EjectLocksAnUnmountedContainerTheAppUnlockedBeforeEjecting() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + fake.SetMounts("dm_sde1"); + await service.EjectAsync(Cleartext); + + var members = Members(fake); + Assert.IsFalse(members.Contains("Filesystem.Unmount")); + Assert.IsTrue(members.IndexOf("Encrypted.Lock") < members.IndexOf("Drive.Eject")); + } + } + + [TestMethod] + public async Task UnmountOfAnAlreadyUnmountedVolumeStillLocksWhatTheAppUnlocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + fake.Errors["org.freedesktop.UDisks2.Filesystem.Unmount"] = "org.freedesktop.UDisks2.Error.NotMounted"; + await service.UnmountAsync(Cleartext); + + Assert.AreEqual(1, Members(fake).Count(m => m == "Encrypted.Lock")); + } + } + + [TestMethod] + public async Task CleartextDeviceUsesTheDriveOfItsContainer() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address)) + { + fake.AddCleartext("sde1"); + var volume = (await service.GetVolumesAsync()).Single(); + + Assert.AreEqual("/org/freedesktop/UDisks2/drives/Generic_Flash", volume.DriveId); + Assert.IsTrue(volume.CanEject); + } + } + + [TestMethod] + public void UnlockTrackerOnlyForgetsTheContainerItTracked() + { + var tracker = new UDisks2UnlockTracker(); + Assert.IsNull(tracker.TryForget(Cleartext)); + + tracker.Track(Cleartext, Locked); + Assert.AreEqual(Locked, tracker.TryForget(Cleartext)); + Assert.IsNull(tracker.TryForget(Cleartext)); + } + + [TestMethod] + public async Task ReusedCleartextNameForAnotherContainerIsNotLocked() + { + var (bus, fake) = await StartAsync(); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + fake.AddEncrypted("sdf1", "Generic_Flash", "other"); + fake.SetMounts("dm_sde1"); + fake.Objects["/org/freedesktop/UDisks2/block_devices/dm_sde1"][UDisks2Parser.BlockInterface]["CryptoBackingDevice"] = new ObjPath("/org/freedesktop/UDisks2/block_devices/sdf1"); + await service.UnmountAsync(Cleartext); + + Assert.IsFalse(Members(fake).Contains("Encrypted.Lock")); + } + } + + [TestMethod] + public async Task UnmountDoesNotLockAContainerThatWasAlreadyUnlocked() + { + var (bus, fake) = await StartAsync(); + fake.AddCleartext("sde1"); + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + await service.UnmountAsync(Cleartext); + + CollectionAssert.AreEqual(new[] { "Filesystem.Mount", "Filesystem.Unmount" }, Members(fake).ToArray()); + } + } + + [TestMethod] + public async Task LockFailureDoesNotFailTheUnmount() + { + var (bus, fake) = await StartAsync(); + fake.Errors[LockMember] = "org.freedesktop.UDisks2.Error.DeviceBusy"; + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + await service.UnmountAsync(Cleartext); + + Assert.AreEqual(1, Members(fake).Count(m => m == "Encrypted.Lock")); + Assert.IsFalse((await service.GetVolumesAsync()).Single().IsMounted); + } + } + + [TestMethod] + public async Task FailedUnmountDoesNotLock() + { + var (bus, fake) = await StartAsync(); + fake.Errors["org.freedesktop.UDisks2.Filesystem.Unmount"] = "org.freedesktop.UDisks2.Error.DeviceBusy"; + using (bus) using (fake) using (var service = new UDisks2VolumeService(bus.Address, new ScriptedPrompt("correct horse"))) + { + await service.MountAsync(Locked); + var ex = await Assert.ThrowsExactlyAsync(() => service.UnmountAsync(Cleartext)); + + Assert.AreEqual(VolumeError.Busy, ex.Error); + Assert.IsFalse(Members(fake).Contains("Encrypted.Lock")); + } + } + [TestMethod] public void TranslateMapsOnlyPassphraseFailuresToIncorrectPassphrase() {