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
48 changes: 47 additions & 1 deletion src/Files.Platform.Linux/Volumes/UDisks2Model.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public static IReadOnlyList<VolumeInfo> 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;
Expand Down Expand Up @@ -246,6 +246,30 @@ public static IReadOnlyList<VolumeInfo> BuildVolumes(UDisks2Objects objects)
return null;
}

/// <summary>The drive of a block device; a cleartext device has none of its own, so it uses its container's.</summary>
private static string? DrivePathOf(UDisks2Objects objects, Dictionary<string, object?> 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;
}

/// <summary>Finds the encrypted container behind the cleartext device <paramref name="cleartext"/>, or null if it is not a cleartext device.</summary>
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;
}

/// <summary>Whether <paramref name="mountPoint"/> belongs to the system rather than being a user-visible drive.</summary>
public static bool IsPlumbingMountPoint(string mountPoint)
{
Expand Down Expand Up @@ -295,4 +319,26 @@ private static ulong UInt64(Dictionary<string, object?> properties, string key)

private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
}

/// <summary>
/// Remembers the cleartext devices the app unlocked itself, so only those are locked again; volumes that were already unlocked are left alone.
/// </summary>
public sealed class UDisks2UnlockTracker
{
private readonly Dictionary<string, string> unlocked = new(StringComparer.Ordinal);

/// <summary>Records that the app unlocked <paramref name="containerPath"/> as <paramref name="cleartextPath"/>.</summary>
public void Track(string cleartextPath, string containerPath)
{
lock (unlocked)
unlocked[cleartextPath] = containerPath;
}

/// <summary>Forgets <paramref name="cleartextPath"/> and returns the container the app unlocked behind it, or null if it did not.</summary>
public string? TryForget(string cleartextPath)
{
lock (unlocked)
return unlocked.Remove(cleartextPath, out var container) ? container : null;
}
}
}
35 changes: 33 additions & 2 deletions src/Files.Platform.Linux/Volumes/UDisks2VolumeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -166,16 +168,39 @@ public async Task UnmountAsync(string volumeId, CancellationToken cancellationTo
try
{
await CallAsync<object?>(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)
{
throw Translate(ex);
}
}

/// <summary>Locks the container behind <paramref name="cleartextId"/> if the app unlocked it; a failure is logged and never fails the unmount.</summary>
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<object?>(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}");
}
}

/// <inheritdoc/>
public async Task EjectAsync(string volumeId, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -280,6 +305,7 @@ private async Task<string> 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))
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 10 additions & 1 deletion tests/Files.Platform.Tests/Volumes/FakeUDisks2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal sealed class FakeUDisks2 : IPathMethodHandler, IDisposable

public List<FakeCall> Calls { get; } = new();

/// <summary>Errors to answer with, keyed by <c>Interface.Member</c>.</summary>
/// <summary>Errors to answer with, keyed by <c>Interface.Member</c> (<c>org.freedesktop.UDisks2.Encrypted.Lock</c> makes <c>Lock</c> fail).</summary>
public Dictionary<string, string> Errors { get; } = new();

/// <summary>Messages for <see cref="Errors"/>, keyed the same way (default "fake failure").</summary>
Expand Down Expand Up @@ -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;
Expand Down
157 changes: 157 additions & 0 deletions tests/Files.Platform.Tests/Volumes/UDisks2LuksTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,163 @@ public async Task WrongPassphraseStopsPromptingAfterTheRetryLimit()
}
}

private const string LockMember = "org.freedesktop.UDisks2.Encrypted.Lock";

private static List<string> 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<VolumeOperationException>(() => service.UnmountAsync(Cleartext));

Assert.AreEqual(VolumeError.Busy, ex.Error);
Assert.IsFalse(Members(fake).Contains("Encrypted.Lock"));
}
}

[TestMethod]
public void TranslateMapsOnlyPassphraseFailuresToIncorrectPassphrase()
{
Expand Down
Loading