mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-09 20:27:09 -04:00
Fail fast on invalid Settings.json enums and broken Serilog structure: reject unknown enum values at startup with clear errors, accept case-insensitive names, migrate ZipFile sinks, and validate Serilog shape without blocking hand-edited custom sinks
This commit is contained in:
1 parent
8ea95ee812
commit
065118cf6c
15 files changed
+640
-202
No files matched your search
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.7.5</Version>
|
||||
<Version>13.7.6</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -94,6 +94,7 @@ public static class LibationScaffolding
|
||||
{
|
||||
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
|
||||
}
|
||||
config.ValidateEnumSettings();
|
||||
DeleteOpenSqliteFiles(config);
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
IdentityTokenStorageWiring.Apply(config);
|
||||
@@ -102,9 +103,6 @@ public static class LibationScaffolding
|
||||
// migrations go below here
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v6_6_9(config);
|
||||
Migrations.migrate_to_v11_5_0(config);
|
||||
Migrations.migrate_to_v11_6_5(config);
|
||||
Migrations.migrate_to_v12_0_1(config);
|
||||
}
|
||||
|
||||
@@ -163,65 +161,7 @@ public static class LibationScaffolding
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
{
|
||||
if (config.GetObject("Serilog") is JObject serilog)
|
||||
{
|
||||
bool fileChanged = false;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
|
||||
{
|
||||
zipFileSink["Name"] = "File";
|
||||
fileChanged = true;
|
||||
}
|
||||
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
|
||||
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
|
||||
{
|
||||
fileSinkArgs["hooks"] = hooks;
|
||||
fileChanged = true;
|
||||
}
|
||||
|
||||
if (fileChanged)
|
||||
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||
return;
|
||||
}
|
||||
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
// ABOUT SINKS
|
||||
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||
|
||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "File" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
// {Properties:j} needed for expanded exception logging
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
|
||||
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
|
||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||
};
|
||||
config.SetNonString(serilogObj, "Serilog");
|
||||
}
|
||||
=> config.EnsureSerilogConfig();
|
||||
|
||||
// to restore original: Console.SetOut(origOut);
|
||||
private static TextWriter origOut { get; } = Console.Out;
|
||||
@@ -463,59 +403,6 @@ public static class LibationScaffolding
|
||||
|
||||
internal static class Migrations
|
||||
{
|
||||
public static void migrate_to_v6_6_9(Configuration config)
|
||||
{
|
||||
var writeToPath = $"Serilog.WriteTo";
|
||||
|
||||
// remove WriteTo[].Name == Console
|
||||
{
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
|
||||
{
|
||||
for (var i = length1 - 1; i >= 0; i--)
|
||||
{
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
|
||||
|
||||
if (exists && value == "Console")
|
||||
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add Serilog.Exceptions -- WithExceptionDetails
|
||||
{
|
||||
// outputTemplate should contain "{Properties:j}"
|
||||
{
|
||||
// re-calculate. previous loop may have changed the length
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
|
||||
{
|
||||
var propertyName = "outputTemplate";
|
||||
for (var i = 0; i < length2; i++)
|
||||
{
|
||||
var jsonPath = $"{writeToPath}[{i}].Args";
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
|
||||
|
||||
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
|
||||
|
||||
if (exists && value != newValue)
|
||||
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serilog.Using must include "Serilog.Exceptions"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
|
||||
|
||||
// Serilog.Enrich must include "WithExceptionDetails"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||
}
|
||||
}
|
||||
|
||||
class FilterState_6_6_9
|
||||
{
|
||||
public bool UseDefault { get; set; }
|
||||
public List<string> Filters { get; set; } = [];
|
||||
}
|
||||
|
||||
public static void migrate_to_v12_0_1(Configuration config)
|
||||
{
|
||||
//Migrate from version 1 file cache to the dictionary-based version 2 cache
|
||||
@@ -588,59 +475,4 @@ internal static class Migrations
|
||||
catch { /* eat */ }
|
||||
}
|
||||
}
|
||||
|
||||
public static void migrate_to_v11_6_5(Configuration config)
|
||||
{
|
||||
//Settings migration for unsupported sample rates (#1116)
|
||||
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
|
||||
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
|
||||
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
|
||||
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
|
||||
}
|
||||
|
||||
public static void migrate_to_v11_5_0(Configuration config)
|
||||
{
|
||||
// Read file, but convert old format to new (with Name field) as necessary.
|
||||
if (!File.Exists(QuickFilters.JsonFile))
|
||||
{
|
||||
QuickFilters.InMemoryState = new();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonConvert.DeserializeObject<QuickFilters.FilterState>(File.ReadAllText(QuickFilters.JsonFile))
|
||||
is QuickFilters.FilterState inMemState)
|
||||
{
|
||||
QuickFilters.InMemoryState = inMemState;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eat
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonConvert.DeserializeObject<FilterState_6_6_9>(File.ReadAllText(QuickFilters.JsonFile))
|
||||
is FilterState_6_6_9 inMemState)
|
||||
{
|
||||
// Copy old structure to new.
|
||||
QuickFilters.InMemoryState = new()
|
||||
{
|
||||
UseDefault = inMemState.UseDefault
|
||||
};
|
||||
foreach (var oldFilter in inMemState.Filters)
|
||||
QuickFilters.InMemoryState.Filters.Add(new QuickFilters.NamedFilter(oldFilter, null));
|
||||
|
||||
return;
|
||||
}
|
||||
Debug.Assert(false, "Should not get here, QuickFilters.json deserialization issue");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eat
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="10.3.4.1" />
|
||||
<PackageReference Include="AudibleApi" Version="10.3.5.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager;
|
||||
|
||||
@@ -20,21 +19,66 @@ public interface IJsonBackedDictionary
|
||||
string? GetStringFromJsonPath(string jsonPath, string propertyName)
|
||||
=> GetStringFromJsonPath($"{jsonPath}.{propertyName}");
|
||||
|
||||
static T? UpCast<T>(object obj)
|
||||
static T? UpCast<T>(object obj, string? propertyName = null)
|
||||
{
|
||||
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
|
||||
if (obj is JObject jObject) return jObject.ToObject<T>();
|
||||
if (obj is JValue jValue)
|
||||
{
|
||||
if (typeof(T).IsAssignableTo(typeof(Enum)))
|
||||
{
|
||||
return
|
||||
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
|
||||
? (T)enumVal
|
||||
: Enum.GetValues(typeof(T)).Cast<T>().First();
|
||||
}
|
||||
return ParseEnum<T>(jValue, propertyName ?? typeof(T).Name);
|
||||
|
||||
return jValue.Value<T>();
|
||||
}
|
||||
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(JValue jValue, string propertyPath)
|
||||
{
|
||||
var enumType = typeof(T);
|
||||
|
||||
if (TryGetEnumFromNumber(jValue, enumType, propertyPath, out var fromNumber))
|
||||
return (T)fromNumber!;
|
||||
|
||||
var text = jValue.Type == JTokenType.String
|
||||
? jValue.Value<string>()
|
||||
: jValue.Value?.ToString();
|
||||
|
||||
if (text is not null
|
||||
&& Enum.TryParse(enumType, text, ignoreCase: true, out var parsed)
|
||||
&& parsed is not null
|
||||
&& Enum.IsDefined(enumType, parsed))
|
||||
{
|
||||
return (T)parsed;
|
||||
}
|
||||
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, text ?? jValue.ToString(), enumType);
|
||||
}
|
||||
|
||||
private static bool TryGetEnumFromNumber(JValue jValue, Type enumType, string propertyPath, out object? value)
|
||||
{
|
||||
value = null;
|
||||
if (jValue.Type is not (JTokenType.Integer or JTokenType.Float))
|
||||
return false;
|
||||
|
||||
object? raw = jValue.Value;
|
||||
if (raw is null)
|
||||
return false;
|
||||
|
||||
object converted;
|
||||
try
|
||||
{
|
||||
converted = Convert.ChangeType(raw, Enum.GetUnderlyingType(enumType));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType);
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(enumType, converted))
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType);
|
||||
|
||||
value = Enum.ToObject(enumType, converted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace FileManager;
|
||||
|
||||
/// <summary>
|
||||
/// Settings.json (or other JSON-backed config) contained a value that cannot be mapped to the expected type.
|
||||
/// </summary>
|
||||
public sealed class InvalidConfigurationValueException : Exception
|
||||
{
|
||||
public string PropertyPath { get; }
|
||||
public string? InvalidValue { get; }
|
||||
public Type? ExpectedType { get; }
|
||||
|
||||
public InvalidConfigurationValueException(string propertyPath, string? invalidValue, Type? expectedType, string message)
|
||||
: base(message)
|
||||
{
|
||||
PropertyPath = propertyPath;
|
||||
InvalidValue = invalidValue;
|
||||
ExpectedType = expectedType;
|
||||
}
|
||||
|
||||
public static InvalidConfigurationValueException ForEnum(string propertyPath, string? invalidValue, Type enumType)
|
||||
{
|
||||
var allowed = string.Join(", ", Enum.GetNames(enumType));
|
||||
var display = FormatValue(invalidValue);
|
||||
var message =
|
||||
$"Invalid value for '{propertyPath}': {display}. " +
|
||||
$"Expected one of: {allowed}.";
|
||||
return new InvalidConfigurationValueException(propertyPath, invalidValue, enumType, message);
|
||||
}
|
||||
|
||||
public static InvalidConfigurationValueException ForPath(string propertyPath, string? invalidValue, string message)
|
||||
=> new(propertyPath, invalidValue, expectedType: null, message);
|
||||
|
||||
public static string FormatValue(string? value)
|
||||
=> value is null ? "[null]"
|
||||
: value.Length == 0 ? "[empty]"
|
||||
: $"\"{value}\"";
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
objectCache[propertyName] = defaultValue;
|
||||
return defaultValue;
|
||||
}
|
||||
return IJsonBackedDictionary.UpCast<T>(obj);
|
||||
return IJsonBackedDictionary.UpCast<T>(obj, propertyName);
|
||||
}
|
||||
|
||||
public object? GetObject(string propertyName)
|
||||
|
||||
@@ -33,7 +33,20 @@ public abstract class OptionsBase
|
||||
// do not use Configuration before this line //
|
||||
// //
|
||||
//***********************************************//
|
||||
Setup.Initialize();
|
||||
try
|
||||
{
|
||||
Setup.Initialize();
|
||||
}
|
||||
catch (Exception ex) when (StartupAssemblyBootstrap.TryFindInvalidConfigurationValue(ex, out var configEx) && configEx is not null)
|
||||
{
|
||||
Environment.ExitCode = (int)ExitCode.RunTimeError;
|
||||
Console.Error.WriteLine("Invalid configuration");
|
||||
Console.Error.WriteLine("=====================");
|
||||
Console.Error.WriteLine(configEx.Message);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Edit Settings.json to use a valid value, then retry.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SettingOverrides is not null)
|
||||
ProcessSettingsOverrides();
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using Dinah.Core.Logging;
|
||||
using FileManager;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Exceptions;
|
||||
using Serilog.Settings.Configuration;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace LibationFileManager;
|
||||
|
||||
@@ -16,16 +21,81 @@ public partial class Configuration
|
||||
|
||||
public bool SerilogInitialized { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create default Serilog config if missing, and migrate legacy ZipFile sink to File.
|
||||
/// Must run before <see cref="ValidateSerilogConfiguration"/> / <see cref="ConfigureLogging"/>.
|
||||
/// </summary>
|
||||
public void EnsureSerilogConfig()
|
||||
{
|
||||
if (GetObject("Serilog") is JObject serilog)
|
||||
{
|
||||
bool fileChanged = false;
|
||||
foreach (var zipFileSink in serilog.SelectTokens("$.WriteTo[?(@.Name == 'ZipFile')]", false).OfType<JObject>())
|
||||
{
|
||||
zipFileSink["Name"] = "File";
|
||||
fileChanged = true;
|
||||
}
|
||||
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
|
||||
foreach (var fileSinkArgs in serilog.SelectTokens("$.WriteTo[?(@.Name == 'File')].Args", false).OfType<JObject>())
|
||||
{
|
||||
if (fileSinkArgs["hooks"]?.Value<string>() != hooks)
|
||||
{
|
||||
fileSinkArgs["hooks"] = hooks;
|
||||
fileChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileChanged)
|
||||
SetNonString(serilog.DeepClone(), "Serilog");
|
||||
return;
|
||||
}
|
||||
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
// ABOUT SINKS
|
||||
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||
|
||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "File" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(LibationFiles.Location, "Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
// {Properties:j} needed for expanded exception logging
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
|
||||
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
|
||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||
};
|
||||
SetNonString(serilogObj, "Serilog");
|
||||
}
|
||||
|
||||
public void ConfigureLogging()
|
||||
{
|
||||
//pass explicit assemblies to the ConfigurationReaderOptions
|
||||
//This is a workaround for the issue where serilog will try to load all
|
||||
//Assemblies starting with "serilog" from the app folder, but it will fail
|
||||
//if those assemblies are unreferenced.
|
||||
//This was a problem when migrating from the ZipFile sink to the File sink.
|
||||
//Upgrading users would still have the ZipFile sink dll in the program
|
||||
//folder and serilog would try to load it, unsuccessfully.
|
||||
//https://github.com/serilog/serilog-settings-configuration/issues/406
|
||||
ValidateSerilogConfiguration();
|
||||
|
||||
// Pass explicit assemblies to ConfigurationReaderOptions.
|
||||
// Workaround: Serilog otherwise loads all "Serilog*" assemblies from the app folder and fails
|
||||
// on unreferenced leftovers (e.g. ZipFile sink after migration).
|
||||
// https://github.com/serilog/serilog-settings-configuration/issues/406
|
||||
var readerOptions = new ConfigurationReaderOptions(
|
||||
typeof(ILogger).Assembly, // Serilog
|
||||
typeof(LoggerCallerEnrichmentConfiguration).Assembly, // Dinah.Core
|
||||
@@ -33,9 +103,9 @@ public partial class Configuration
|
||||
typeof(ConsoleLoggerConfigurationExtensions).Assembly, // Serilog.Sinks.Console
|
||||
typeof(FileLoggerConfigurationExtensions).Assembly); // Serilog.Sinks.File
|
||||
|
||||
configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(Instance.LibationFiles.SettingsFilePath, optional: false, reloadOnChange: true)
|
||||
.Build();
|
||||
// Build from the in-memory settings store so ZipFile->File migration (and CLI ephemeral
|
||||
// settings) are what Serilog sees, not a stale disk copy.
|
||||
configuration = CreateLoggingConfigurationRoot();
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(configuration, readerOptions)
|
||||
.Destructure.ByTransforming<LongPath>(lp => lp.Path)
|
||||
@@ -44,6 +114,152 @@ public partial class Configuration
|
||||
SerilogInitialized = true;
|
||||
}
|
||||
|
||||
private IConfigurationRoot CreateLoggingConfigurationRoot()
|
||||
{
|
||||
var json = Settings.GetJObject().ToString(Formatting.None);
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
return new ConfigurationBuilder()
|
||||
.AddJsonStream(stream)
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail fast on structurally broken Serilog config (missing/empty WriteTo, bad MinimumLevel).
|
||||
/// Hand-edited custom sink names are allowed; legacy ZipFile is migrated by <see cref="EnsureSerilogConfig"/>.
|
||||
/// Call after ZipFile->File migration in scaffolding.
|
||||
/// </summary>
|
||||
public void ValidateSerilogConfiguration()
|
||||
{
|
||||
var settingsPath = LibationFiles.SettingsFilePath;
|
||||
if (GetObject("Serilog") is not JObject serilog)
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForPath(
|
||||
"Serilog",
|
||||
null,
|
||||
$"Settings.json ({settingsPath}) is missing a Serilog section. " +
|
||||
"Add a Serilog configuration with at least one WriteTo sink (Libation defaults to File).");
|
||||
}
|
||||
|
||||
ValidateSerilogMinimumLevel(serilog, settingsPath);
|
||||
ValidateSerilogWriteTo(serilog, settingsPath);
|
||||
}
|
||||
|
||||
private static void ValidateSerilogMinimumLevel(JObject serilog, string settingsPath)
|
||||
{
|
||||
var minLevelToken = serilog["MinimumLevel"];
|
||||
if (minLevelToken is null)
|
||||
return;
|
||||
|
||||
string? levelText = minLevelToken.Type switch
|
||||
{
|
||||
JTokenType.String => minLevelToken.Value<string>(),
|
||||
JTokenType.Object => minLevelToken["Default"]?.Value<string>(),
|
||||
_ => minLevelToken.ToString()
|
||||
};
|
||||
|
||||
if (levelText is null)
|
||||
return;
|
||||
|
||||
if (!Enum.TryParse<LogEventLevel>(levelText, ignoreCase: true, out _))
|
||||
{
|
||||
var allowed = string.Join(", ", Enum.GetNames<LogEventLevel>());
|
||||
throw InvalidConfigurationValueException.ForPath(
|
||||
"Serilog.MinimumLevel",
|
||||
levelText,
|
||||
$"Invalid value for 'Serilog.MinimumLevel' in Settings.json ({settingsPath}): " +
|
||||
$"{InvalidConfigurationValueException.FormatValue(levelText)}. Expected one of: {allowed}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSerilogWriteTo(JObject serilog, string settingsPath)
|
||||
{
|
||||
var writeToToken = serilog["WriteTo"];
|
||||
if (writeToToken is null)
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForPath(
|
||||
"Serilog.WriteTo",
|
||||
null,
|
||||
$"Settings.json ({settingsPath}) Serilog section has no WriteTo sinks. " +
|
||||
"Add at least one WriteTo sink (Libation defaults to File).");
|
||||
}
|
||||
|
||||
if (writeToToken is not JArray writeTo)
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForPath(
|
||||
"Serilog.WriteTo",
|
||||
writeToToken.Type.ToString(),
|
||||
$"Settings.json ({settingsPath}) 'Serilog.WriteTo' must be a JSON array of sink objects.");
|
||||
}
|
||||
|
||||
if (writeTo.Count == 0)
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForPath(
|
||||
"Serilog.WriteTo",
|
||||
"[]",
|
||||
$"Settings.json ({settingsPath}) Serilog.WriteTo is empty. " +
|
||||
"Add at least one WriteTo sink (Libation defaults to File).");
|
||||
}
|
||||
|
||||
for (var i = 0; i < writeTo.Count; i++)
|
||||
{
|
||||
var path = $"Serilog.WriteTo[{i}]";
|
||||
if (writeTo[i] is not JObject sink)
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForPath(
|
||||
path,
|
||||
writeTo[i]?.Type.ToString(),
|
||||
$"Settings.json ({settingsPath}) '{path}' must be a JSON object with a Name property.");
|
||||
}
|
||||
|
||||
var name = sink["Name"]?.Value<string>();
|
||||
var namePath = $"{path}.Name";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForPath(
|
||||
namePath,
|
||||
name,
|
||||
$"Settings.json ({settingsPath}) '{namePath}' is missing or empty.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force-read enum-backed settings so invalid values fail at startup instead of later.
|
||||
/// </summary>
|
||||
public void ValidateEnumSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = ThemeVariant;
|
||||
_ = MaxSampleRate;
|
||||
_ = LameEncoderQuality;
|
||||
_ = ClipsBookmarksFileFormat;
|
||||
_ = TokenStorageMethod;
|
||||
_ = SpatialAudioCodec;
|
||||
_ = FileDownloadQuality;
|
||||
_ = CreationTime;
|
||||
_ = LastWriteTime;
|
||||
_ = BadBook;
|
||||
}
|
||||
catch (InvalidConfigurationValueException ex)
|
||||
{
|
||||
throw EnhanceWithSettingsPath(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private InvalidConfigurationValueException EnhanceWithSettingsPath(InvalidConfigurationValueException ex)
|
||||
{
|
||||
var path = LibationFiles.SettingsFilePath;
|
||||
if (ex.Message.Contains(path, StringComparison.OrdinalIgnoreCase))
|
||||
return ex;
|
||||
|
||||
return new InvalidConfigurationValueException(
|
||||
ex.PropertyPath,
|
||||
ex.InvalidValue,
|
||||
ex.ExpectedType,
|
||||
$"Settings.json ({path}): {ex.Message}");
|
||||
}
|
||||
|
||||
[Description("The importance of a log event")]
|
||||
public LogEventLevel LogLevel
|
||||
{
|
||||
@@ -62,7 +278,11 @@ public partial class Configuration
|
||||
return;
|
||||
}
|
||||
|
||||
configuration?.Reload();
|
||||
if (SerilogInitialized)
|
||||
{
|
||||
// Rebuild from current settings (in-memory or disk) so MinimumLevel applies.
|
||||
ConfigureLogging();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(LogLevel), value);
|
||||
|
||||
|
||||
@@ -37,7 +37,18 @@ public partial class Configuration
|
||||
|
||||
[return: NotNullIfNotNull(nameof(defaultValue))]
|
||||
public T? GetNonString<T>(T defaultValue, [CallerMemberName] string propertyName = "")
|
||||
=> Settings is null ? default : Settings.GetNonString(propertyName, defaultValue);
|
||||
{
|
||||
if (Settings is null)
|
||||
return default;
|
||||
try
|
||||
{
|
||||
return Settings.GetNonString(propertyName, defaultValue);
|
||||
}
|
||||
catch (InvalidConfigurationValueException ex)
|
||||
{
|
||||
throw EnhanceWithSettingsPath(ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[return: NotNullIfNotNull(nameof(defaultValue))]
|
||||
|
||||
@@ -22,7 +22,7 @@ internal class EphemeralDictionary : IJsonBackedDictionary
|
||||
public string? GetString(string propertyName, string? defaultValue = null)
|
||||
=> JsonObject[propertyName]?.Value<string>() ?? defaultValue;
|
||||
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
|
||||
=> GetObject(propertyName) is object obj ? IJsonBackedDictionary.UpCast<T>(obj) : defaultValue;
|
||||
=> GetObject(propertyName) is object obj ? IJsonBackedDictionary.UpCast<T>(obj, propertyName) : defaultValue;
|
||||
public object? GetObject(string propertyName)
|
||||
=> JsonObject[propertyName]?.Value<object>();
|
||||
public void SetString(string propertyName, string? newValue)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="10.3.4.1" />
|
||||
<PackageReference Include="AudibleApi" Version="10.3.5.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
|
||||
<PackageReference Include="NameParserSharp" Version="1.5.0" />
|
||||
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using FileManager;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -158,6 +159,16 @@ public static class StartupAssemblyBootstrap
|
||||
|
||||
public static FatalStartupMessage? GetStartupFailureMessage(Exception ex)
|
||||
{
|
||||
if (TryFindInvalidConfigurationValue(ex, out var configEx) && configEx is not null)
|
||||
{
|
||||
return new FatalStartupMessage(
|
||||
"Invalid Settings.json",
|
||||
configEx.Message
|
||||
+ Environment.NewLine
|
||||
+ Environment.NewLine
|
||||
+ "Edit Settings.json to use a valid value, then restart Libation.");
|
||||
}
|
||||
|
||||
if (IsApplicationControlBlockedAssembly(ex))
|
||||
{
|
||||
return new FatalStartupMessage(
|
||||
@@ -182,6 +193,30 @@ public static class StartupAssemblyBootstrap
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool TryFindInvalidConfigurationValue(Exception? ex, out InvalidConfigurationValueException? configEx)
|
||||
{
|
||||
configEx = null;
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is InvalidConfigurationValueException found)
|
||||
{
|
||||
configEx = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
{
|
||||
if (TryFindInvalidConfigurationValue(inner, out configEx))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a user-facing title and body for a fatal startup or crash, including emergency rollback when needed.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using AssertionHelper;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace SerilogConfigurationTests;
|
||||
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class SerilogConfigurationTests
|
||||
{
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
Configuration.RestoreSingletonInstance();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_accepts_File_sink()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString(CreateSerilog("File"), "Serilog");
|
||||
|
||||
config.ValidateSerilogConfiguration();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_accepts_Console_sink()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString(CreateSerilog("Console"), "Serilog");
|
||||
|
||||
config.ValidateSerilogConfiguration();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_accepts_hand_edited_custom_sink_name()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString(CreateSerilog("Seq"), "Serilog");
|
||||
|
||||
config.ValidateSerilogConfiguration();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EnsureSerilogConfig_migrates_ZipFile_to_File()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString(CreateSerilog("ZipFile"), "Serilog");
|
||||
|
||||
config.EnsureSerilogConfig();
|
||||
|
||||
var serilog = (JObject)config.GetObject("Serilog")!;
|
||||
var name = serilog.SelectToken("$.WriteTo[0].Name")?.Value<string>();
|
||||
Assert.AreEqual("File", name);
|
||||
|
||||
config.ValidateSerilogConfiguration();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EnsureSerilogConfig_migrates_all_ZipFile_sinks()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString(new JObject
|
||||
{
|
||||
["MinimumLevel"] = "Information",
|
||||
["WriteTo"] = new JArray
|
||||
{
|
||||
new JObject { ["Name"] = "ZipFile", ["Args"] = new JObject { ["path"] = "a.log" } },
|
||||
new JObject { ["Name"] = "Console" },
|
||||
new JObject { ["Name"] = "ZipFile", ["Args"] = new JObject { ["path"] = "b.log" } },
|
||||
}
|
||||
}, "Serilog");
|
||||
|
||||
config.EnsureSerilogConfig();
|
||||
|
||||
var serilog = (JObject)config.GetObject("Serilog")!;
|
||||
var names = serilog.SelectTokens("$.WriteTo[*].Name").Select(t => t.Value<string>()).ToList();
|
||||
CollectionAssert.AreEqual(new[] { "File", "Console", "File" }, names);
|
||||
|
||||
config.ValidateSerilogConfiguration();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_rejects_invalid_MinimumLevel()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
var serilog = CreateSerilog("File");
|
||||
serilog["MinimumLevel"] = "Loud";
|
||||
config.SetNonString(serilog, "Serilog");
|
||||
|
||||
var ex = Assert.ThrowsExactly<InvalidConfigurationValueException>(config.ValidateSerilogConfiguration);
|
||||
StringAssert.Contains(ex.Message, "MinimumLevel");
|
||||
StringAssert.Contains(ex.Message, "Loud");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_rejects_malformed_WriteTo()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString(new JObject
|
||||
{
|
||||
["MinimumLevel"] = "Information",
|
||||
["WriteTo"] = "File"
|
||||
}, "Serilog");
|
||||
|
||||
var ex = Assert.ThrowsExactly<InvalidConfigurationValueException>(config.ValidateSerilogConfiguration);
|
||||
StringAssert.Contains(ex.Message, "WriteTo");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_rejects_empty_WriteTo()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString(new JObject
|
||||
{
|
||||
["MinimumLevel"] = "Information",
|
||||
["WriteTo"] = new JArray()
|
||||
}, "Serilog");
|
||||
|
||||
var ex = Assert.ThrowsExactly<InvalidConfigurationValueException>(config.ValidateSerilogConfiguration);
|
||||
StringAssert.Contains(ex.Message, "empty");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_rejects_missing_sink_Name()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString(new JObject
|
||||
{
|
||||
["MinimumLevel"] = "Information",
|
||||
["WriteTo"] = new JArray { new JObject { ["Args"] = new JObject() } }
|
||||
}, "Serilog");
|
||||
|
||||
var ex = Assert.ThrowsExactly<InvalidConfigurationValueException>(config.ValidateSerilogConfiguration);
|
||||
StringAssert.Contains(ex.Message, "Name");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Ephemeral_ZipFile_migration_is_visible_to_Validate_without_disk_write()
|
||||
{
|
||||
// Mirrors CLI ephemeral startup: migrate in memory, then validate from the same store ConfigureLogging reads.
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.IsEphemeralInstance.Should().BeTrue();
|
||||
config.SetNonString(CreateSerilog("ZipFile"), "Serilog");
|
||||
|
||||
config.EnsureSerilogConfig();
|
||||
config.ValidateSerilogConfiguration();
|
||||
|
||||
var serilog = (JObject)config.GetObject("Serilog")!;
|
||||
Assert.AreEqual("File", serilog.SelectToken("$.WriteTo[0].Name")?.Value<string>());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Fatal_startup_message_uses_invalid_configuration_body()
|
||||
{
|
||||
var ex = InvalidConfigurationValueException.ForEnum(
|
||||
"TokenStorageMethod",
|
||||
"Nope",
|
||||
typeof(AudibleApi.Authorization.TokenStorageMethod));
|
||||
|
||||
var message = StartupAssemblyBootstrap.GetStartupFailureMessage(ex);
|
||||
Assert.IsNotNull(message);
|
||||
Assert.AreEqual("Invalid Settings.json", message!.Title);
|
||||
StringAssert.Contains(message.Body, "TokenStorageMethod");
|
||||
StringAssert.Contains(message.Body, "Nope");
|
||||
}
|
||||
|
||||
private static JObject CreateSerilog(string sinkName) => new()
|
||||
{
|
||||
["MinimumLevel"] = "Information",
|
||||
["WriteTo"] = new JArray
|
||||
{
|
||||
new JObject
|
||||
{
|
||||
["Name"] = sinkName,
|
||||
["Args"] = new JObject
|
||||
{
|
||||
["path"] = Path.Combine(Path.GetTempPath(), "LibationTestLog.log"),
|
||||
["rollingInterval"] = "Month"
|
||||
}
|
||||
}
|
||||
},
|
||||
["Using"] = new JArray { "Dinah.Core", "Serilog.Exceptions" },
|
||||
["Enrich"] = new JArray { "WithCaller", "WithExceptionDetails" }
|
||||
};
|
||||
}
|
||||
@@ -41,13 +41,56 @@ public class TokenStorageMethodConfigurationTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Unknown_enum_value_resolves_to_Encrypted_not_Plaintext()
|
||||
public void Unknown_enum_value_throws_InvalidConfigurationValueException()
|
||||
{
|
||||
// Same UpCast path used by Settings.json deserialization for enum settings.
|
||||
var parsed = IJsonBackedDictionary.UpCast<TokenStorageMethod>(new JValue("NotARealMethod"));
|
||||
var ex = Assert.ThrowsExactly<InvalidConfigurationValueException>(
|
||||
() => IJsonBackedDictionary.UpCast<TokenStorageMethod>(new JValue("NotARealMethod"), nameof(Configuration.TokenStorageMethod)));
|
||||
|
||||
Assert.AreEqual(TokenStorageMethod.Encrypted, parsed);
|
||||
Assert.AreNotEqual(TokenStorageMethod.Plaintext, parsed);
|
||||
StringAssert.Contains(ex.Message, "TokenStorageMethod");
|
||||
StringAssert.Contains(ex.Message, "NotARealMethod");
|
||||
StringAssert.Contains(ex.Message, "Plaintext");
|
||||
StringAssert.Contains(ex.Message, "Encrypted");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PlainText_casing_resolves_to_Plaintext()
|
||||
{
|
||||
Assert.AreEqual(
|
||||
TokenStorageMethod.Plaintext,
|
||||
IJsonBackedDictionary.UpCast<TokenStorageMethod>(new JValue("PlainText")));
|
||||
Assert.AreEqual(
|
||||
TokenStorageMethod.Plaintext,
|
||||
IJsonBackedDictionary.UpCast<TokenStorageMethod>(new JValue("plaintext")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Undefined_numeric_enum_value_throws()
|
||||
{
|
||||
var ex = Assert.ThrowsExactly<InvalidConfigurationValueException>(
|
||||
() => IJsonBackedDictionary.UpCast<TokenStorageMethod>(new JValue(99), nameof(Configuration.TokenStorageMethod)));
|
||||
|
||||
StringAssert.Contains(ex.Message, "TokenStorageMethod");
|
||||
StringAssert.Contains(ex.Message, "99");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Config_property_throws_for_invalid_TokenStorageMethod()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString("PlainTextTypo", nameof(Configuration.TokenStorageMethod));
|
||||
|
||||
var ex = Assert.ThrowsExactly<InvalidConfigurationValueException>(() => _ = config.TokenStorageMethod);
|
||||
StringAssert.Contains(ex.Message, "TokenStorageMethod");
|
||||
StringAssert.Contains(ex.Message, "PlainTextTypo");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidateEnumSettings_throws_for_invalid_TokenStorageMethod()
|
||||
{
|
||||
var config = Configuration.CreateMockInstance();
|
||||
config.SetNonString("NotARealMethod", nameof(Configuration.TokenStorageMethod));
|
||||
|
||||
Assert.ThrowsExactly<InvalidConfigurationValueException>(config.ValidateEnumSettings);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Troubleshooting Common Libation Errors
|
||||
|
||||
## Invalid Settings.json value (TokenStorageMethod, Serilog, etc.)
|
||||
|
||||
**Symptoms:** Libation or LibationCli refuses to start with **Invalid Settings.json** (GUI) or **Invalid configuration** (CLI). The message names the setting, the bad value, and (for enums) the allowed values.
|
||||
|
||||
**Common causes:**
|
||||
- `TokenStorageMethod` mistyped (canonical values are `Encrypted` and `Plaintext` - casing variants like `PlainText` are accepted, but unknown spellings are not)
|
||||
- `Serilog.WriteTo` missing, empty, or malformed (not an array of objects with `Name`). Hand-edited custom sink names are allowed; legacy `ZipFile` is migrated to `File` automatically
|
||||
- `Serilog.MinimumLevel` set to a value that is not a Serilog level
|
||||
|
||||
**Fix:** Edit `Settings.json` in your Libation Files directory to a valid value and restart. Do not delete the whole file unless it is corrupt JSON.
|
||||
|
||||
## Invalid filenames or mangled paths (NTFS / Windows)
|
||||
|
||||
NTFS filesystems (Windows, and NTFS-formatted external drives on Linux or Mac) do not allow colons (`:`) in filenames. Libation chooses filename replacement rules from the **OS it is running on**, not from the filesystem where books are saved. On Linux or in Docker, that often means colons are left in names even when `LIBATION_BOOKS_DIR` points at an NTFS volume, which can produce invalid paths, failed moves, or mangled folder names.
|
||||
|
||||
Reference in new issue
Block a user