feat(storage): add WalletLock to prevent concurrent wallet access

Introduces an exclusive file lock (FileShare.None on wallet.json.lock)
held for the duration of a wallet session. A second instance trying to
open the same file receives null from TryAcquire and sees a localized
error message; UnauthorizedAccessException (OS permission issues)
propagates separately so the UI can show a distinct message.
The .lock file is deleted on Dispose (best-effort); the real guard is
the open FileStream, which the OS releases automatically on crash.
This commit is contained in:
2026-06-13 20:33:59 +02:00
parent d66490b6be
commit 0f8a764a44
3 changed files with 77 additions and 0 deletions
+2
View File
@@ -281,6 +281,8 @@ public sealed class Loc
"As duas senhas não coincidem.", "As duas senhas não coincidem.",
"Die beiden Passwörter stimmen nicht überein."], "Die beiden Passwörter stimmen nicht überein."],
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."], ["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet."],
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen."],
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"], ["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"],
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"], ["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"],
["msg.synced.detail"] = [ ["msg.synced.detail"] = [
+27
View File
@@ -57,6 +57,7 @@ public partial class MainWindowViewModel : ViewModelBase
private HdAccount? _account; private HdAccount? _account;
private string? _walletPath; private string? _walletPath;
private string? _password; private string? _password;
private WalletLock? _walletLock;
private ElectrumClient? _client; private ElectrumClient? _client;
private WalletSynchronizer? _synchronizer; private WalletSynchronizer? _synchronizer;
private IReadOnlyDictionary<string, Transaction>? _lastTransactions; private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
@@ -896,6 +897,10 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
StatusMessage = Loc.Tr("msg.wrongpassword"); StatusMessage = Loc.Tr("msg.wrongpassword");
} }
catch (UnauthorizedAccessException)
{
StatusMessage = Loc.Tr("msg.wallet.noaccess");
}
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Errore: {ex.Message}"; StatusMessage = $"Errore: {ex.Message}";
@@ -923,6 +928,10 @@ public partial class MainWindowViewModel : ViewModelBase
SetupStep = StepOpen; SetupStep = StepOpen;
StatusMessage = ""; StatusMessage = "";
} }
catch (UnauthorizedAccessException)
{
StatusMessage = Loc.Tr("msg.wallet.noaccess");
}
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Errore: {ex.Message}"; StatusMessage = $"Errore: {ex.Message}";
@@ -939,8 +948,24 @@ public partial class MainWindowViewModel : ViewModelBase
StatusMessage = ""; StatusMessage = "";
} }
private bool TryAcquireWalletLock(string path)
{
var acquired = WalletLock.TryAcquire(path);
if (acquired is null)
{
StatusMessage = Loc.Tr("msg.wallet.locked");
return false;
}
_walletLock?.Dispose();
_walletLock = acquired;
return true;
}
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password) private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
{ {
if (!TryAcquireWalletLock(path))
return;
// La rete del wallet comanda (registry, pin TLS, indirizzi). // La rete del wallet comanda (registry, pin TLS, indirizzi).
SelectedNetwork = doc.Network; SelectedNetwork = doc.Network;
_doc = doc; _doc = doc;
@@ -1246,6 +1271,8 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void CloseWallet() private void CloseWallet()
{ {
_walletLock?.Dispose();
_walletLock = null;
_ = _client?.DisposeAsync().AsTask(); _ = _client?.DisposeAsync().AsTask();
_client = null; _client = null;
_synchronizer = null; _synchronizer = null;
+48
View File
@@ -0,0 +1,48 @@
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Exclusive lock on a wallet file held for the lifetime of the session.
/// The real lock is the open FileStream with FileShare.None — the .lock file
/// is just its vessel. The OS releases it automatically on process exit or crash.
/// </summary>
public sealed class WalletLock : IDisposable
{
private readonly string _lockPath;
private FileStream? _stream;
private WalletLock(string lockPath, FileStream stream)
{
_lockPath = lockPath;
_stream = stream;
}
/// <summary>
/// Tries to acquire an exclusive lock for <paramref name="walletPath"/>.
/// Returns null if another process already holds the lock (IOException).
/// Lets UnauthorizedAccessException propagate so callers can show a distinct message.
/// </summary>
public static WalletLock? TryAcquire(string walletPath)
{
var lockPath = walletPath + ".lock";
try
{
var stream = new FileStream(
lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None);
return new WalletLock(lockPath, stream);
}
catch (IOException)
{
return null;
}
}
public void Dispose()
{
_stream?.Dispose();
_stream = null;
try { File.Delete(_lockPath); } catch { }
}
}