Skip to content

Commit fd2c5a0

Browse files
Tomer Vakninclaude
andcommitted
Fix 23 code review issues: security, reliability, thread safety, and quality improvements
High priority: - Add single-instance mutex to prevent concurrent database access - Configure SQLite WAL mode and busy_timeout for corruption prevention - Fix path traversal double-encoding bypass with iterative URL decoding - Handle FormatException in Uri.UnescapeDataString for malformed input - Add WebView2 runtime presence check with graceful error display - Add 5-second timeout to connection pool drain to prevent shutdown hangs Medium priority: - Unify SessionViewModel connecting host tracking into single ConcurrentDictionary - Add semaphore eviction to prevent unbounded memory growth in host locks - Fix ScrollViewer event handler leak in SessionTabStrip (add/remove in Loaded/Unloaded) - Fix double enumeration of tagIds IEnumerable in HostRepository.SearchAsync - Marshal IsLoading property changes to UI thread in HostManagementViewModel - Add IPv6 address validation support using IPAddress.TryParse - Add schema version tracking to DbMigrator to skip applied migrations - Copy ACL from original file to backup in KeyEncryptionService - Sort backup files by filename timestamp instead of unreliable CreationTimeUtc Low priority: - Replace Debug.WriteLine with ILogger in ConnectionHistoryViewModel - Add recursion guard in ApplyFilterAndPaging to prevent re-entrant calls - Add case-insensitive JsonSerializerOptions for FavoriteStorageModel - Fix legacy pipe format IPv6 hostname parsing with :/ separator - Batch QuickConnect ObservableCollection updates to reduce UI flicker - Forward CancellationToken in TryPingAsync via Task.WhenAny - Restrict POSIX env var validation to ASCII-only characters - Optimize ANSI escape stripping to process only tail of output batches - Add disk space pre-check (100MB minimum) for session recording Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c8bc381 commit fd2c5a0

36 files changed

Lines changed: 1807 additions & 538 deletions

CODE_REVIEW.md

Lines changed: 333 additions & 112 deletions
Large diffs are not rendered by default.

src/SshManager.App/App.xaml.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,29 @@ namespace SshManager.App;
2121

2222
public partial class App : Application
2323
{
24+
private static readonly string MutexName = @"Global\SshManager_SingleInstance_B7E3F2A1";
25+
private Mutex? _singleInstanceMutex;
2426
private readonly IHost _host;
2527
#if DEBUG
2628
private ITestServer? _testServer;
2729
#endif
2830

2931
public App()
3032
{
33+
// Enforce single instance before any other initialization
34+
_singleInstanceMutex = new Mutex(true, MutexName, out var createdNew);
35+
if (!createdNew)
36+
{
37+
MessageBox.Show(
38+
"SshManager is already running.\n\nOnly one instance can run at a time.",
39+
"SshManager",
40+
MessageBoxButton.OK,
41+
MessageBoxImage.Information);
42+
Shutdown(0);
43+
_host = null!;
44+
return;
45+
}
46+
3147
// Configure Serilog early for bootstrap logging
3248
Bootstrapper.ConfigureSerilog();
3349

@@ -155,6 +171,14 @@ protected override async void OnExit(ExitEventArgs e)
155171
}
156172
finally
157173
{
174+
// Release single-instance mutex so another instance can start
175+
if (_singleInstanceMutex != null)
176+
{
177+
_singleInstanceMutex.ReleaseMutex();
178+
_singleInstanceMutex.Dispose();
179+
_singleInstanceMutex = null;
180+
}
181+
158182
Log.Information("Application exit complete");
159183
await Log.CloseAndFlushAsync();
160184
}

src/SshManager.App/AppConstants.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ public static class MigrationDefaults
8888
public const int ShowHostConnectionStats = 1;
8989
public const int PinFavoritesToTop = 1;
9090

91+
// Settings dialog UI
92+
public const int IsAdvancedMode = 0;
93+
9194
// Performance settings (Phase 1)
9295
public const int TerminalOutputFlushIntervalMs = 16;
9396
public const int TerminalOutputMaxBatchSize = 8192;

src/SshManager.App/Infrastructure/DataServiceExtensions.cs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.Data.Sqlite;
12
using Microsoft.EntityFrameworkCore;
23
using Microsoft.Extensions.DependencyInjection;
34
using SshManager.Data;
@@ -13,13 +14,45 @@ public static class DataServiceExtensions
1314
{
1415
public static IServiceCollection AddDataServices(this IServiceCollection services)
1516
{
16-
// Database context factory
17+
// Database context factory with WAL mode and busy timeout for concurrent access safety
1718
services.AddDbContextFactory<AppDbContext>(options =>
1819
{
1920
var dbPath = DbPaths.GetDbPath();
20-
options.UseSqlite($"Data Source={dbPath}");
21+
var connectionString = new SqliteConnectionStringBuilder
22+
{
23+
DataSource = dbPath,
24+
// busy_timeout prevents "database is locked" errors under concurrent access
25+
DefaultTimeout = 5
26+
}.ToString();
27+
28+
options.UseSqlite(connectionString, sqliteOptions =>
29+
{
30+
sqliteOptions.CommandTimeout(30);
31+
});
2132
});
2233

34+
// Execute WAL mode pragma once at startup via a shared connection.
35+
// WAL mode persists across connections once set, so we only need to do this once.
36+
{
37+
var dbPath = DbPaths.GetDbPath();
38+
var pragmaConnection = new SqliteConnection($"Data Source={dbPath}");
39+
try
40+
{
41+
pragmaConnection.Open();
42+
using var walCmd = pragmaConnection.CreateCommand();
43+
walCmd.CommandText = "PRAGMA journal_mode=WAL;";
44+
walCmd.ExecuteNonQuery();
45+
using var busyCmd = pragmaConnection.CreateCommand();
46+
busyCmd.CommandText = "PRAGMA busy_timeout=5000;";
47+
busyCmd.ExecuteNonQuery();
48+
}
49+
finally
50+
{
51+
pragmaConnection.Close();
52+
pragmaConnection.Dispose();
53+
}
54+
}
55+
2356
// Repositories
2457
services.AddSingleton<IHostRepository, HostRepository>();
2558
services.AddSingleton<IGroupRepository, GroupRepository>();

src/SshManager.App/Infrastructure/DbMigrator.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ public static async Task MigrateAsync(AppDbContext db, Serilog.ILogger logger)
8282
var connection = db.Database.GetDbConnection();
8383
await connection.OpenAsync();
8484

85+
// Ensure SchemaVersion table exists and read current version
86+
var currentVersion = await GetSchemaVersionAsync(connection);
87+
logger.Information("Current schema version: {Version}", currentVersion);
88+
89+
if (currentVersion < 1)
90+
{
8591
// First, create missing tables
8692
await CreateMissingTablesAsync(db, connection, logger);
8793

@@ -176,6 +182,8 @@ public static async Task MigrateAsync(AppDbContext db, Serilog.ILogger logger)
176182
["HostListViewMode"] = ("INTEGER", AppConstants.MigrationDefaults.HostListViewMode.ToString()), // Normal = 1
177183
["ShowHostConnectionStats"] = ("INTEGER", AppConstants.MigrationDefaults.ShowHostConnectionStats.ToString()),
178184
["PinFavoritesToTop"] = ("INTEGER", AppConstants.MigrationDefaults.PinFavoritesToTop.ToString()),
185+
// Settings dialog UI state
186+
["IsAdvancedMode"] = ("INTEGER", AppConstants.MigrationDefaults.IsAdvancedMode.ToString()),
179187
// Performance settings (Phase 1)
180188
["TerminalOutputFlushIntervalMs"] = ("INTEGER", AppConstants.MigrationDefaults.TerminalOutputFlushIntervalMs.ToString()),
181189
["TerminalOutputMaxBatchSize"] = ("INTEGER", AppConstants.MigrationDefaults.TerminalOutputMaxBatchSize.ToString()),
@@ -216,6 +224,56 @@ await db.Database.ExecuteSqlRawAsync(
216224
"ALTER TABLE Groups ADD COLUMN Color TEXT DEFAULT NULL");
217225
logger.Information("Added missing column Color to Groups table");
218226
}
227+
228+
await UpdateSchemaVersionAsync(connection, 1);
229+
logger.Information("Schema version updated to 1");
230+
} // end if (currentVersion < 1)
231+
232+
// Future migrations go here as: if (currentVersion < 2) { ... }
233+
}
234+
235+
/// <summary>
236+
/// Reads the current schema version from the SchemaVersion table.
237+
/// Creates the table if it does not exist.
238+
/// </summary>
239+
private static async Task<int> GetSchemaVersionAsync(DbConnection connection)
240+
{
241+
// Create SchemaVersion table if it doesn't exist
242+
await using (var cmd = connection.CreateCommand())
243+
{
244+
cmd.CommandText = "CREATE TABLE IF NOT EXISTS SchemaVersion (Version INTEGER NOT NULL DEFAULT 0)";
245+
await cmd.ExecuteNonQueryAsync();
246+
}
247+
248+
// Read current version
249+
await using (var cmd = connection.CreateCommand())
250+
{
251+
cmd.CommandText = "SELECT Version FROM SchemaVersion LIMIT 1";
252+
var result = await cmd.ExecuteScalarAsync();
253+
if (result is null or DBNull)
254+
{
255+
// No row yet, insert default version 0
256+
await using var insertCmd = connection.CreateCommand();
257+
insertCmd.CommandText = "INSERT INTO SchemaVersion (Version) VALUES (0)";
258+
await insertCmd.ExecuteNonQueryAsync();
259+
return 0;
260+
}
261+
return Convert.ToInt32(result);
262+
}
263+
}
264+
265+
/// <summary>
266+
/// Updates the schema version to the specified value.
267+
/// </summary>
268+
private static async Task UpdateSchemaVersionAsync(DbConnection connection, int version)
269+
{
270+
await using var cmd = connection.CreateCommand();
271+
cmd.CommandText = "UPDATE SchemaVersion SET Version = @version";
272+
var param = cmd.CreateParameter();
273+
param.ParameterName = "@version";
274+
param.Value = version;
275+
cmd.Parameters.Add(param);
276+
await cmd.ExecuteNonQueryAsync();
219277
}
220278

221279
/// <summary>

src/SshManager.App/Services/HostStatusService.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@ public async Task<HostStatus> CheckHostAsync(Guid hostId, string hostname, int p
5858
var pingTask = TryPingAsync(hostname, ct);
5959
var tcpTask = TryTcpAsync(hostname, port, ct);
6060

61-
await Task.WhenAll(pingTask, tcpTask);
61+
await Task.WhenAll(pingTask, tcpTask).ConfigureAwait(false);
6262

63-
var pingLatencyMs = pingTask.Result;
64-
var tcpLatencyMs = tcpTask.Result;
63+
var pingLatencyMs = await pingTask.ConfigureAwait(false);
64+
var tcpLatencyMs = await tcpTask.ConfigureAwait(false);
6565

6666
// Determine online status and build enhanced status
6767
var isOnline = pingLatencyMs.HasValue || tcpLatencyMs.HasValue;
@@ -137,7 +137,16 @@ public void ClearHosts()
137137
try
138138
{
139139
using var ping = new Ping();
140-
var reply = await ping.SendPingAsync(hostname, PingTimeoutMs);
140+
var pingTask = ping.SendPingAsync(hostname, PingTimeoutMs);
141+
var cancelTask = Task.Delay(Timeout.Infinite, ct);
142+
var completed = await Task.WhenAny(pingTask, cancelTask).ConfigureAwait(false);
143+
144+
if (completed == cancelTask)
145+
{
146+
ct.ThrowIfCancellationRequested();
147+
}
148+
149+
var reply = await pingTask.ConfigureAwait(false);
141150
return reply.Status == IPStatus.Success
142151
? (int)reply.RoundtripTime
143152
: null;

0 commit comments

Comments
 (0)