Skip to content

Commit bd54305

Browse files
authored
Merge pull request #258 from edgiardina/fix/cache-db-corruption-recovery
Self-heal corrupt SQLite cache; fix CVE-2025-6965 (SQLite bump)
2 parents 59bcf0d + 471ddd0 commit bd54305

4 files changed

Lines changed: 177 additions & 54 deletions

File tree

Caching/CachingPinballRankingApi.cs

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,29 @@ public class CachingPinballRankingApi : IPinballRankingApi
2626
{
2727
private readonly IPinballRankingApi onlineApi;
2828
private readonly ILogger<CachingPinballRankingApi> logger;
29+
private readonly ILoggerFactory loggerFactory;
2930

30-
public CachingPinballRankingApi(IPinballRankingApi onlineApi, ILogger<CachingPinballRankingApi> logger)
31+
public CachingPinballRankingApi(IPinballRankingApi onlineApi, ILogger<CachingPinballRankingApi> logger, ILoggerFactory loggerFactory)
3132
{
3233
this.onlineApi = onlineApi ?? throw new ArgumentNullException(nameof(onlineApi));
3334
this.logger = logger;
35+
this.loggerFactory = loggerFactory;
3436
}
3537

3638
private async Task<T> ExecuteWithCache<T>(string cacheKey, Func<Task<T>> fetch)
3739
{
38-
var cache = new SQLiteCacheProvider<T>(Settings.CacheDatabasePath);
40+
// Build the cache provider defensively. A corrupt cache self-heals inside the provider,
41+
// but if it still can't be initialized (e.g. disk full) we degrade to a live-only fetch
42+
// rather than letting the failure escape the pipeline and break every cached call.
43+
SQLiteCacheProvider<T> cache = null;
44+
try
45+
{
46+
cache = new SQLiteCacheProvider<T>(Settings.CacheDatabasePath, loggerFactory);
47+
}
48+
catch (Exception ex)
49+
{
50+
logger.LogError(ex, "Failed to initialize cache database; serving {Key} without cache", cacheKey);
51+
}
3952

4053
// Retry on non network‐unavailable errors
4154
var retry = Policy<T>
@@ -48,31 +61,35 @@ private async Task<T> ExecuteWithCache<T>(string cacheKey, Func<Task<T>> fetch)
4861
.FallbackAsync(
4962
async (outcome, ctx, ct) =>
5063
{
51-
var (hit, val) = await cache.TryGetAsync(
52-
ctx.OperationKey!,
53-
ct,
54-
continueOnCapturedContext: false);
55-
56-
if (!hit)
64+
if (cache != null)
5765
{
58-
logger.LogWarning(outcome.Exception,
59-
"Cache miss for {Key}", ctx.OperationKey);
66+
var (hit, val) = await cache.TryGetAsync(
67+
ctx.OperationKey!,
68+
ct,
69+
continueOnCapturedContext: false);
6070

61-
MainThread.BeginInvokeOnMainThread(() =>
71+
if (hit)
6272
{
63-
try { Toast.Make(Strings.Toast_Offline_NoCache, ToastDuration.Long).Show(); }
64-
catch (Exception toastEx) { logger.LogWarning(toastEx, "Could not show offline toast"); }
65-
});
66-
67-
throw outcome.Exception!; // no cache -> real error
73+
MainThread.BeginInvokeOnMainThread(() =>
74+
{
75+
try { Toast.Make(Strings.Toast_Offline_Cache, ToastDuration.Long).Show(); }
76+
catch (Exception toastEx) { logger.LogWarning(toastEx, "Could not show offline toast"); }
77+
});
78+
return (T)val!;
79+
}
6880
}
6981

82+
// No cache (unavailable or miss) -> surface the real error.
83+
logger.LogWarning(outcome.Exception,
84+
"Cache miss for {Key}", ctx.OperationKey);
85+
7086
MainThread.BeginInvokeOnMainThread(() =>
7187
{
72-
try { Toast.Make(Strings.Toast_Offline_Cache, ToastDuration.Long).Show(); }
88+
try { Toast.Make(Strings.Toast_Offline_NoCache, ToastDuration.Long).Show(); }
7389
catch (Exception toastEx) { logger.LogWarning(toastEx, "Could not show offline toast"); }
7490
});
75-
return (T)val!;
91+
92+
throw outcome.Exception!;
7693
},
7794
onFallbackAsync: async (outcome, ctx) =>
7895
{
@@ -85,8 +102,10 @@ private async Task<T> ExecuteWithCache<T>(string cacheKey, Func<Task<T>> fetch)
85102
var pipeline = Policy.WrapAsync(fallback, retry);
86103

87104
// execute: if offline, retry will see NetworkUnavailableException
88-
// and skip directly to fallback; if online, fetch runs, then we cache it
89-
var q = await pipeline.ExecuteAndCaptureAsync(async (ctx) =>
105+
// and skip directly to fallback; if online, fetch runs, then we cache it.
106+
// ExecuteAsync (not ExecuteAndCaptureAsync) so a fallback that rethrows the real
107+
// error propagates to the caller instead of being swallowed into a null result.
108+
return await pipeline.ExecuteAsync(async (ctx) =>
90109
{
91110
// network‐unavailable check
92111
var access = Connectivity.Current.NetworkAccess;
@@ -96,17 +115,28 @@ private async Task<T> ExecuteWithCache<T>(string cacheKey, Func<Task<T>> fetch)
96115
// real network call
97116
var result = await fetch().ConfigureAwait(false);
98117

99-
// write‐through cache
100-
await cache.PutAsync(ctx.OperationKey!,
101-
result,
102-
new Ttl(90.Days()),
103-
CancellationToken.None,
104-
continueOnCapturedContext: false);
118+
// write‐through cache (skipped if the cache could not be initialized).
119+
// A cache-write failure (e.g. disk full) must never discard a successful live
120+
// fetch, so swallow it here rather than let it escape and re-trigger the pipeline.
121+
if (cache != null)
122+
{
123+
try
124+
{
125+
await cache.PutAsync(ctx.OperationKey!,
126+
result,
127+
new Ttl(90.Days()),
128+
CancellationToken.None,
129+
continueOnCapturedContext: false);
130+
}
131+
catch (Exception cacheEx)
132+
{
133+
logger.LogWarning(cacheEx, "Failed to write cache for {Key}; returning live result", ctx.OperationKey);
134+
}
135+
}
105136

106137
return result;
107138
},
108139
new Context(cacheKey));
109-
return q.Result;
110140
}
111141

112142
public Task<List<CountryDetail>> GetCountriesList() =>

Caching/SQLiteCacheProvider.cs

Lines changed: 111 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Ifpa.Models;
1+
using Ifpa.Models;
2+
using Microsoft.Extensions.Logging;
23
using Polly.Caching;
34
using SQLite;
45
using System.Text.Json;
@@ -7,41 +8,57 @@ namespace Ifpa.Caching
78
{
89
public class SQLiteCacheProvider<T> : IAsyncCacheProvider<T>, IAsyncDisposable
910
{
10-
private readonly SQLiteAsyncConnection _db;
11+
private readonly string _dbPath;
12+
private readonly ILogger<SQLiteCacheProvider<T>> _logger;
13+
private SQLiteAsyncConnection _db;
1114

12-
public SQLiteCacheProvider(string dbPath)
15+
public SQLiteCacheProvider(string dbPath, ILoggerFactory loggerFactory = null)
1316
{
17+
_dbPath = dbPath;
18+
_logger = loggerFactory?.CreateLogger<SQLiteCacheProvider<T>>();
1419
_db = new SQLiteAsyncConnection(dbPath);
15-
Task.Run(() => _db.CreateTableAsync<CacheItem>()).Wait();
16-
Task.Run(CleanupExpiredItems).Wait(); // Remove expired items on initialization
20+
// Initialize synchronously so a usable connection is guaranteed before first use.
21+
// A corrupt cache file self-heals here rather than throwing and bricking every cached call.
22+
Task.Run(InitializeAsync).GetAwaiter().GetResult();
1723
}
1824

25+
private Task InitializeAsync() =>
26+
RunWithRecovery(async () =>
27+
{
28+
await _db.CreateTableAsync<CacheItem>();
29+
await CleanupExpiredItems(); // Remove expired items on initialization
30+
});
31+
1932
private async Task CleanupExpiredItems()
2033
{
2134
await _db.Table<CacheItem>().Where(item => item.Expiration <= DateTime.UtcNow).DeleteAsync();
2235
}
2336

2437
public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
2538
{
26-
await _db.DeleteAsync<CacheItem>(key);
39+
await RunWithRecovery(() => _db.DeleteAsync<CacheItem>(key));
2740
}
2841

29-
public async Task<(bool, T)> TryGetAsync(string key, CancellationToken cancellationToken, bool continueOnCapturedContext)
42+
public Task<(bool, T)> TryGetAsync(string key, CancellationToken cancellationToken, bool continueOnCapturedContext)
3043
{
31-
await CleanupExpiredItems(); // Clean expired items before fetching
32-
var item = await _db.FindAsync<CacheItem>(key);
33-
34-
if (item != null && item.Expiration > DateTime.UtcNow)
44+
return RunWithRecovery(async () =>
3545
{
36-
// Deserialize to an object since type is not known at compile time
37-
var value = JsonSerializer.Deserialize<T>(item.Value);
38-
return (true, value);
39-
}
46+
// Expired rows are purged once per provider construction (InitializeAsync); the
47+
// expiration check below still guarantees an expired entry is never returned.
48+
var item = await _db.FindAsync<CacheItem>(key);
4049

41-
return (false, default(T)); // Return false and null if no valid cache item is found
50+
if (item != null && item.Expiration > DateTime.UtcNow)
51+
{
52+
// Deserialize to an object since type is not known at compile time
53+
var value = JsonSerializer.Deserialize<T>(item.Value);
54+
return (true, value);
55+
}
56+
57+
return (false, default(T)); // Return false and null if no valid cache item is found
58+
});
4259
}
4360

44-
public async Task PutAsync(string key, T value, Ttl ttl, CancellationToken cancellationToken, bool continueOnCapturedContext)
61+
public Task PutAsync(string key, T value, Ttl ttl, CancellationToken cancellationToken, bool continueOnCapturedContext)
4562
{
4663
if (ttl.Timespan > Settings.CacheDuration)
4764
ttl.Timespan = Settings.CacheDuration;
@@ -53,22 +70,92 @@ public async Task PutAsync(string key, T value, Ttl ttl, CancellationToken cance
5370
Expiration = DateTime.UtcNow.Add(ttl.Timespan)
5471
};
5572

56-
await _db.InsertOrReplaceAsync(item);
57-
await CleanupExpiredItems(); // Enforce cleanup after insertion
73+
return RunWithRecovery(() => _db.InsertOrReplaceAsync(item));
5874
}
5975

60-
public async Task ClearCache()
76+
public Task ClearCache()
6177
{
62-
// Delete all entries
63-
await _db.DeleteAllAsync<CacheItem>();
64-
// Run VACUUM to reclaim space and optimize the database
65-
await _db.ExecuteAsync("VACUUM");
78+
return RunWithRecovery(async () =>
79+
{
80+
// Delete all entries
81+
await _db.DeleteAllAsync<CacheItem>();
82+
// Run VACUUM to reclaim space and optimize the database
83+
await _db.ExecuteAsync("VACUUM");
84+
});
6685
}
6786

6887
public async ValueTask DisposeAsync()
6988
{
7089
await _db.CloseAsync();
7190
}
91+
92+
// Runs a cache operation, transparently rebuilding the database once if it is found to be
93+
// corrupt. The cache holds only re-fetchable API responses, so discarding it is always safe.
94+
private Task RunWithRecovery(Func<Task> operation) =>
95+
RunWithRecovery<object>(async () => { await operation(); return null; });
96+
97+
private async Task<TResult> RunWithRecovery<TResult>(Func<Task<TResult>> operation)
98+
{
99+
try
100+
{
101+
return await operation();
102+
}
103+
catch (Exception ex) when (IsCorruptionException(ex))
104+
{
105+
_logger?.LogWarning(ex,
106+
"Cache database at {Path} is corrupt; rebuilding and retrying. Cached API data will be re-fetched; no user data is affected.",
107+
_dbPath);
108+
await RecreateDatabaseAsync();
109+
return await operation();
110+
}
111+
}
112+
113+
private async Task RecreateDatabaseAsync()
114+
{
115+
// Release our handle so the underlying file can be deleted.
116+
try { await _db.CloseAsync(); }
117+
catch (Exception ex) { _logger?.LogDebug(ex, "Error closing corrupt cache connection (ignored)"); }
118+
119+
DeleteDatabaseFiles();
120+
121+
// Fresh, empty database with the schema in place.
122+
_db = new SQLiteAsyncConnection(_dbPath);
123+
await _db.CreateTableAsync<CacheItem>();
124+
}
125+
126+
private void DeleteDatabaseFiles()
127+
{
128+
// SQLite may keep sidecar files (WAL / shared-memory / rollback journal); remove them all.
129+
foreach (var suffix in new[] { string.Empty, "-wal", "-shm", "-journal" })
130+
{
131+
var path = _dbPath + suffix;
132+
try
133+
{
134+
if (File.Exists(path))
135+
File.Delete(path);
136+
}
137+
catch (Exception ex)
138+
{
139+
_logger?.LogWarning(ex, "Could not delete corrupt cache file {Path}", path);
140+
}
141+
}
142+
}
143+
144+
private static bool IsCorruptionException(Exception ex)
145+
{
146+
switch (ex)
147+
{
148+
case SQLiteException sqlite:
149+
// SQLITE_CORRUPT ("database disk image is malformed") or SQLITE_NOTADB.
150+
return sqlite.Result == SQLite3.Result.Corrupt
151+
|| sqlite.Result == SQLite3.Result.NonDBFile;
152+
case AggregateException aggregate:
153+
// .Wait()/.GetResult() wrap the SQLiteException; unwrap and inspect.
154+
return aggregate.Flatten().InnerExceptions.Any(IsCorruptionException);
155+
default:
156+
return ex.InnerException != null && IsCorruptionException(ex.InnerException);
157+
}
158+
}
72159
}
73160

74161
public class CacheItem

IfpaMaui.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@
8484
<PackageReference Include="Shiny.Extensions.Configuration" Version="4.0.1" />
8585
<PackageReference Include="Shiny.Jobs" Version="4.0.1" />
8686
<PackageReference Include="Shiny.Notifications" Version="4.0.1" />
87-
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
87+
<PackageReference Include="sqlite-net-base" Version="1.9.172" />
8888
<PackageReference Include="SQLitePCLRaw.core" Version="3.0.3" />
89-
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" />
89+
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
9090
<PackageReference Include="SQLitePCLRaw.provider.dynamic_cdecl" Version="3.0.3" />
9191
<PackageReference Include="SQLitePCLRaw.provider.sqlite3" Version="3.0.3" />
9292
<PackageReference Include="Syncfusion.Maui.Toolkit" Version="1.0.10" />

MauiProgram.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ public static class MauiProgram
3636
public static MauiApp CreateMauiApp()
3737
{
3838
#if ANDROID
39+
// Initialize the SQLitePCLRaw e_sqlite3 provider. This was previously handled implicitly by
40+
// bundle_green's auto-init; after moving to sqlite-net-base + bundle_e_sqlite3 (patched SQLite,
41+
// CVE-2025-6965) Android must init explicitly. iOS sets its own provider in AppDelegate.CreateMauiApp.
42+
SQLitePCL.Batteries_V2.Init();
43+
3944
FlurlHttp.Clients.WithDefaults(b => b.ConfigureInnerHandler(_ => new HttpClientHandler()));
4045
#endif
4146
var builder = MauiApp.CreateBuilder();
@@ -129,7 +134,8 @@ static MauiAppBuilder RegisterIfpaServices(this MauiAppBuilder builder)
129134
var settings = sp.GetRequiredService<AppSettings>();
130135
var online = new PinballRankingApi(settings.IfpaApiKey);
131136
var logger = sp.GetRequiredService<ILogger<CachingPinballRankingApi>>();
132-
return new CachingPinballRankingApi(online, logger);
137+
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
138+
return new CachingPinballRankingApi(online, logger, loggerFactory);
133139
});
134140

135141
s.AddSingleton(Geocoding.Default);

0 commit comments

Comments
 (0)