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
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

_Nothing yet._
### Fixed

- Resolved every open CodeQL code-scanning alert from the `security-and-quality`
pack with a genuine code change rather than a suppression (no consumer-visible
behaviour changes):
- `cs/path-combine`: switched every `Path.Combine` to `Path.Join` (one in
`ServiceCollectionExtensions`, the rest in test infrastructure). `Path.Join`
concatenates unconditionally, so it cannot silently discard the base
directory if a later segment ever looks rooted.
- `cs/catch-of-all-exceptions`: the two best-effort cleanup catches
(`AtomicFile.TryDelete`, `SettingsStore.TryBackupCorruptFile`) and the test
`TempDir.Dispose` now catch only the `IOException`/`UnauthorizedAccessException`
they expect, so an unexpected exception surfaces instead of being swallowed.
The three intentionally broad catches — the fire-and-forget persistence
safety net (`SettingsBase`) and the two `settings` command boundaries — keep
routing all operational failures to their handler but now let a process-fatal
`OutOfMemoryException` propagate rather than mislabel it.
- `cs/linq/missed-where`: `SettingsStore.ResetInstanceToDefaults` filters the
settable properties with `.Where(...)` instead of an `if` inside the loop.
- `cs/missed-using-statement`: the debounce task now scopes its
`CancellationTokenSource` with a `using` block instead of a manual
`finally`-dispose, preserving the existing (idempotent) disposal semantics.
- `cs/local-not-disposed`: the test CLI harness now disposes its `TestConsole`.

## [1.0.0] — 2026-08-21

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ protected override Task<int> ExecuteAsync(CommandContext context, Settings setti

return Task.FromResult(0);
}
catch (Exception ex)
catch (Exception ex) when (ex is not OutOfMemoryException)
{
// Top-level boundary: turn any operational failure into a clean
// message and a non-zero exit code rather than an unhandled
// stack trace. A process-fatal OutOfMemoryException is left to
// propagate.
CommandErrorReporter.Report(ex, "Error listing settings", settings.Verbose);
return Task.FromResult(1);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,12 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
AnsiConsole.MarkupLine($"[green]Reset '{Markup.Escape(registration.Name)}' to defaults.[/]");
return 0;
}
catch (Exception ex)
catch (Exception ex) when (ex is not OutOfMemoryException)
{
// Top-level boundary: turn any operational failure into a clean
// message and a non-zero exit code rather than an unhandled
// stack trace. A process-fatal OutOfMemoryException is left to
// propagate.
CommandErrorReporter.Report(ex, "Error resetting settings", settings.Verbose);
return 1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ private static void TryDelete(string path)
File.Delete(path);
}
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort cleanup; a stray ".tmp" file is harmless — it
// doesn't match the "{ClassName}.json" name the loader reads.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ private static void TryBackupCorruptFile(string filePath)
{
File.Copy(filePath, filePath + ".bak", overwrite: true);
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort: a failed backup must not prevent falling back to
// defaults. The original file is left untouched (the copy, not a
Expand All @@ -158,12 +158,13 @@ private static void ResetInstanceToDefaults(SettingsBase instance, SettingsTypeD
instance.SuspendNotifications();
try
{
foreach (var property in descriptor.SettingsType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
var settableProperties = descriptor.SettingsType
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(property => property.CanRead && property.CanWrite && property.GetIndexParameters().Length == 0);

foreach (var property in settableProperties)
{
if (property.CanRead && property.CanWrite && property.GetIndexParameters().Length == 0)
{
property.SetValue(instance, property.GetValue(defaults));
}
property.SetValue(instance, property.GetValue(defaults));
}
}
finally
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public static IServiceCollection AddSettings<T>(
{
SettingsType = typeof(T),
Name = name,
FilePath = Path.Combine(options.SettingsDirectory, name + ".json"),
FilePath = Path.Join(options.SettingsDirectory, name + ".json"),
PersistenceMode = options.PersistenceMode,
DebounceInterval = options.DebounceInterval,
ErrorHandler = options.ErrorHandler ?? SettingsSerialization.DefaultErrorHandler,
Expand Down
59 changes: 34 additions & 25 deletions src/NextIteration.SpectreConsole.Settings/SettingsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,39 +198,44 @@ private async Task DebounceAndPersistAsync(
TimeSpan interval,
CancellationTokenSource cts)
{
try
// This task owns the CTS for the rest of its life: the using
// disposes it on every exit path (cancelled or not). A superseding
// change or Save disposes its own reference too, but CTS.Dispose is
// idempotent, so the double-dispose is harmless.
using (cts)
{
if (interval > TimeSpan.Zero)
try
{
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
if (interval > TimeSpan.Zero)
{
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
else
{
// Let the current synchronous call stack unwind so a burst
// of setters all schedule-then-cancel and only the last
// survives to write — coalescing with a zero interval too.
await Task.Yield();
cts.Token.ThrowIfCancellationRequested();
}
}
else
catch (OperationCanceledException)
{
// Let the current synchronous call stack unwind so a burst
// of setters all schedule-then-cancel and only the last
// survives to write — coalescing with a zero interval too.
await Task.Yield();
cts.Token.ThrowIfCancellationRequested();
return; // superseded by a newer change or an explicit Save.
}
}
catch (OperationCanceledException)
{
return; // superseded by a newer change or an explicit Save.
}
finally
{
lock (_gate)
finally
{
if (ReferenceEquals(_debounceCts, cts))
lock (_gate)
{
_debounceCts = null;
if (ReferenceEquals(_debounceCts, cts))
{
_debounceCts = null;
}
}
}

cts.Dispose();
await PersistGuardedAsync(persister).ConfigureAwait(false);
}

await PersistGuardedAsync(persister).ConfigureAwait(false);
}

private async Task PersistGuardedAsync(ISettingsPersister persister)
Expand All @@ -239,10 +244,14 @@ private async Task PersistGuardedAsync(ISettingsPersister persister)
{
await persister.PersistAsync(this).ConfigureAwait(false);
}
catch (Exception ex)
catch (Exception ex) when (ex is not OutOfMemoryException)
{
// Never swallow: route to the configured handler (default
// writes to stderr). This is the fire-and-forget safety net.
// Route any write failure to the configured handler (default
// writes to stderr): this is the fire-and-forget safety net, so
// the persister's exception (whatever type it is) must not be
// lost. A process-fatal OutOfMemoryException is the exception —
// reporting it as a settings-write failure would be misleading,
// so it propagates instead.
_errorHandler(ex);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace NextIteration.SpectreConsole.Settings.Tests
public sealed class CommandFlowTests
{
private static string FileFor<T>(string directory) =>
Path.Combine(directory, typeof(T).Name + ".json");
Path.Join(directory, typeof(T).Name + ".json");

private static void Register(string directory, IServiceCollection services) =>
services.AddSettings<SampleSettings>(o => o.SettingsDirectory = directory);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
var app = new CommandApp(new TypeRegistrar(services));
app.Configure(config => config.AddSettingsBranch());

var console = new TestConsole().Interactive();
using var console = new TestConsole().Interactive();
foreach (var line in consoleInput)
{
console.Input.PushTextWithEnter(line);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure
internal sealed class TempDir : IDisposable
{
public string Path { get; } =
System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ni.scs.tests." + Guid.NewGuid().ToString("N"));
System.IO.Path.Join(System.IO.Path.GetTempPath(), "ni.scs.tests." + Guid.NewGuid().ToString("N"));

public TempDir()
{
Expand All @@ -25,7 +25,7 @@ public void Dispose()
Directory.Delete(Path, recursive: true);
}
}
catch
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort cleanup. Stray scratch dirs in %TEMP% aren't a
// problem — the OS reclaims temp eventually.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public sealed class AtomicFileTests
public async Task WriteAllTextAsync_WritesExpectedContent()
{
using var temp = new TempDir();
var target = Path.Combine(temp.Path, "file.txt");
var target = Path.Join(temp.Path, "file.txt");

await AtomicFile.WriteAllTextAsync(target, "hello", TestContext.Current.CancellationToken);

Expand All @@ -22,7 +22,7 @@ public async Task WriteAllTextAsync_WritesExpectedContent()
public async Task WriteAllTextAsync_NoTempFileLeftBehindAfterSuccess()
{
using var temp = new TempDir();
var target = Path.Combine(temp.Path, "file.txt");
var target = Path.Join(temp.Path, "file.txt");

await AtomicFile.WriteAllTextAsync(target, "hello", TestContext.Current.CancellationToken);

Expand All @@ -34,7 +34,7 @@ public async Task WriteAllTextAsync_NoTempFileLeftBehindAfterSuccess()
public async Task WriteAllTextAsync_OverwritesExisting()
{
using var temp = new TempDir();
var target = Path.Combine(temp.Path, "file.txt");
var target = Path.Join(temp.Path, "file.txt");
await File.WriteAllTextAsync(target, "original", TestContext.Current.CancellationToken);

await AtomicFile.WriteAllTextAsync(target, "replaced", TestContext.Current.CancellationToken);
Expand All @@ -46,7 +46,7 @@ public async Task WriteAllTextAsync_OverwritesExisting()
public async Task WriteAllTextAsync_ConcurrentWriters_OneWinsNoStragglers()
{
using var temp = new TempDir();
var target = Path.Combine(temp.Path, "file.txt");
var target = Path.Join(temp.Path, "file.txt");

await Task.WhenAll(
AtomicFile.WriteAllTextAsync(target, "writer-a", TestContext.Current.CancellationToken),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ private static ServiceProvider BuildProvider(string directory, PersistenceMode m
.BuildServiceProvider();

private static string FileFor<T>(string directory) =>
Path.Combine(directory, typeof(T).Name + ".json");
Path.Join(directory, typeof(T).Name + ".json");

[Fact]
public void AddSettings_WithoutSettingsDirectory_Throws()
Expand Down