mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-13 06:07:30 -04:00
This is the reported leak. AuthenticationRequiredException held the live
Account, and Serilog.Exceptions writes every public property of a logged
exception into the log file - following nested objects as it goes - so
pausing auto-scan wrote the reporter's real address into a file we ask
people to attach to public issues. Their DecryptKey happened to be empty;
with activation bytes set it would have published those too.
The exception now carries an AccountSummary: masked entry and a
credentials flag, both safe to log, plus the owner-facing label behind a
method rather than a property, because reflection reads properties and
never calls methods. The constructor still takes an Account, so callers
and tests are unchanged.
The thrown message named the account too, and it reaches the log twice -
once as {Exception}, once as ExceptionDetail.Message - so it is masked
now. The GUI dialog still shows the full name and address, since that is
the owner's own screen. For the CLI, stderr is not teed into Serilog, so
that is where a headless user is told which account in full.
Two tests, one for the bug and one for the class of bug: the first logs a
real exception through the same WithExceptionDetails enricher Libation
configures and asserts no address, activation bytes, tokens, or cookies
come out. The second walks the public property graph of every exception
type in these assemblies and fails if one can reach an Account or an
Identity. Restoring the old property makes all of it fail, naming
"jade@example.com" and the path AuthenticationRequiredException.Account.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
280 lines
8.9 KiB
C#
280 lines
8.9 KiB
C#
using CommandLine;
|
|
using Dinah.Core;
|
|
using FileManager;
|
|
using AudibleUtilities;
|
|
using LibationFileManager;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace LibationCli;
|
|
|
|
public abstract class OptionsBase
|
|
{
|
|
[Option(longName: "libationFiles", HelpText = "Path to Libation Files directory")]
|
|
public DirectoryInfo? LibationFiles { get; set; }
|
|
|
|
[Option('o', "override", HelpText = "Configuration setting override [SettingName]=\"Setting_Value\"")]
|
|
public IEnumerable<OptionOverride>? SettingOverrides { get; set; }
|
|
|
|
public async Task Run()
|
|
{
|
|
if (LibationFiles?.Exists is true)
|
|
{
|
|
Environment.SetEnvironmentVariable(LibationFileManager.LibationFiles.LIBATION_FILES_DIR, LibationFiles.FullName);
|
|
}
|
|
|
|
//***********************************************//
|
|
// //
|
|
// do not use Configuration before this line //
|
|
// //
|
|
//***********************************************//
|
|
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();
|
|
|
|
try
|
|
{
|
|
await ProcessAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Environment.ExitCode = (int)ExitCode.RunTimeError;
|
|
|
|
if (NonJsonResponseExceptionExtensions.TryFindInTree(ex, out var htmlEx) && htmlEx is not null)
|
|
{
|
|
foreach (var line in htmlEx.GetExplainerLines())
|
|
Console.Error.WriteLine(line);
|
|
Serilog.Log.Logger.Error(htmlEx, "Audible returned HTML instead of JSON");
|
|
return;
|
|
}
|
|
|
|
if (AccountSettingsDecryptFailure.TryFindInTree(ex, out var decryptEx) && decryptEx is not null)
|
|
{
|
|
Console.Error.WriteLine("ERROR");
|
|
Console.Error.WriteLine("=====");
|
|
foreach (var line in AccountSettingsDecryptFailure.GetExplainerLines(ex))
|
|
Console.Error.WriteLine(line);
|
|
Serilog.Log.Logger.Error(decryptEx, "Failed to decrypt account settings tokens");
|
|
return;
|
|
}
|
|
|
|
// the exception message names the account masked, because it gets logged. stderr is not teed into
|
|
// Serilog, so this is the one place the owner can be told which account in full.
|
|
if (AuthenticationExceptionHelper.FindAuthenticationRequired(ex)?.AccountInfo is { } accountInfo)
|
|
Console.Error.WriteLine($"Audible login is required for {accountInfo.RevealOwnerFacingLabel()}.");
|
|
|
|
PrintVerbUsage(
|
|
"ERROR",
|
|
"=====",
|
|
ex.Message,
|
|
"",
|
|
// Include inner exceptions (e.g. real token failure behind AuthenticationRequiredException).
|
|
ex.ToString());
|
|
}
|
|
}
|
|
|
|
private static bool TryParseEnum(Type enumType, string? value, out object? result)
|
|
{
|
|
var values = Enum.GetNames(enumType);
|
|
|
|
if (values.Select(n => n.ToLowerInvariant()).Distinct().Count() != values.Length)
|
|
{
|
|
//Enum names must be case sensitive.
|
|
return Enum.TryParse(enumType, value, out result);
|
|
}
|
|
|
|
for (int i = 0; i < values.Length; i++)
|
|
{
|
|
if (values[i].Equals(value, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Enum.TryParse(enumType, values[i], out result);
|
|
}
|
|
}
|
|
result = null;
|
|
return false;
|
|
}
|
|
|
|
protected void PrintVerbUsage(params string?[] linesBeforeUsage)
|
|
{
|
|
var verb = GetType().GetCustomAttribute<VerbAttribute>()?.Name;
|
|
var helpText = new HelpVerb { HelpType = verb }.GetHelpText();
|
|
helpText.AddPreOptionsLines(linesBeforeUsage);
|
|
helpText.AddPreOptionsLine("");
|
|
helpText.AddPreOptionsLine($"{verb} Usage:");
|
|
Console.Error.WriteLine(helpText);
|
|
}
|
|
|
|
protected static void ReplaceConsoleText(TextWriter writer, int previousLength, string newText)
|
|
{
|
|
writer.Write(new string('\b', previousLength));
|
|
writer.Write(newText);
|
|
writer.Write(new string(' ', int.Max(0, previousLength - newText.Length)));
|
|
}
|
|
|
|
protected abstract Task ProcessAsync();
|
|
|
|
protected IOrderedEnumerable<PropertyInfo> GetConfigurationProperties()
|
|
=> typeof(Configuration).GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
|
.Where(p => p.CustomAttributes.Any(a => a.AttributeType == typeof(DescriptionAttribute)))
|
|
.Where(p => !p.Name.In(ExcludedSettings))
|
|
.OrderBy(p => p.PropertyType.IsEnum)
|
|
.ThenBy(p => p.PropertyType.Name)
|
|
.ThenBy(p => p.Name);
|
|
|
|
private readonly string[] ExcludedSettings = [
|
|
nameof(Configuration.LibationFiles),
|
|
nameof(Configuration.GridScaleFactor),
|
|
nameof(Configuration.GridFontScaleFactor),
|
|
nameof(Configuration.GridColumnsVisibilities),
|
|
nameof(Configuration.GridColumnsDisplayIndices),
|
|
nameof(Configuration.GridColumnsWidths)];
|
|
|
|
private void ProcessSettingsOverrides()
|
|
{
|
|
var configProperties = GetConfigurationProperties().ToArray();
|
|
foreach (var option in SettingOverrides?.Where(p => p.Property is not null && p.Value is not null) ?? [])
|
|
{
|
|
if (option.Property?.StartsWithInsensitive(ReplacePrefix) is true)
|
|
{
|
|
OverrideReplacement(option);
|
|
}
|
|
else if (configProperties.FirstOrDefault(p => p.Name.EqualsInsensitive(option.Property)) is not PropertyInfo property)
|
|
{
|
|
Console.Error.WriteLine($"Unknown configuration property '{option.Property}'");
|
|
}
|
|
else if (property.PropertyType == typeof(string))
|
|
{
|
|
property.SetValue(Configuration.Instance, option.Value?.Trim());
|
|
}
|
|
else if (property.PropertyType == typeof(bool) && bool.TryParse(option.Value?.Trim(), out var bVal))
|
|
{
|
|
property.SetValue(Configuration.Instance, bVal);
|
|
}
|
|
else if (property.PropertyType == typeof(int) && int.TryParse(option.Value?.Trim(), out var intVal))
|
|
{
|
|
property.SetValue(Configuration.Instance, intVal);
|
|
}
|
|
else if (property.PropertyType == typeof(long) && long.TryParse(option.Value?.Trim(), out var longVal))
|
|
{
|
|
property.SetValue(Configuration.Instance, longVal);
|
|
}
|
|
else if (property.PropertyType == typeof(LongPath))
|
|
{
|
|
var value = option.Value is null ? null : (LongPath)option.Value.Trim();
|
|
property.SetValue(Configuration.Instance, value);
|
|
}
|
|
else if (property.PropertyType.IsEnum && TryParseEnum(property.PropertyType, option.Value?.Trim(), out var enumVal))
|
|
{
|
|
property.SetValue(Configuration.Instance, enumVal);
|
|
}
|
|
else
|
|
{
|
|
Console.Error.WriteLine($"Cannot set configuration property '{property.Name}' of type '{property.PropertyType}' with value '{option.Value}'");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void OverrideReplacement(OptionOverride option)
|
|
{
|
|
List<Replacement> newReplacements = [];
|
|
|
|
bool addedToList = false;
|
|
foreach (var r in Configuration.Instance.ReplacementCharacters.Replacements)
|
|
{
|
|
if (GetReplacementName(r).EqualsInsensitive(option.Property))
|
|
{
|
|
var newReplacement = new Replacement(r.CharacterToReplace, option.Value ?? string.Empty, r.Description)
|
|
{
|
|
Mandatory = r.Mandatory
|
|
};
|
|
newReplacements.Add(newReplacement);
|
|
addedToList = true;
|
|
}
|
|
else
|
|
{
|
|
newReplacements.Add(r);
|
|
}
|
|
}
|
|
|
|
if (!addedToList)
|
|
{
|
|
var charToReplace = option.Property!.Substring(ReplacePrefix.Length);
|
|
if (charToReplace.Length != 1)
|
|
{
|
|
Console.Error.WriteLine($"Invalid character to replace: '{charToReplace}'");
|
|
}
|
|
else
|
|
{
|
|
newReplacements.Add(new(charToReplace[0], option.Value ?? string.Empty, ""));
|
|
}
|
|
}
|
|
Configuration.Instance.ReplacementCharacters = new ReplacementCharacters { Replacements = newReplacements };
|
|
}
|
|
|
|
const string ReplacePrefix = "Replace_";
|
|
protected static string GetReplacementName(Replacement r)
|
|
=> !r.Mandatory ? ReplacePrefix + r.CharacterToReplace
|
|
: r.CharacterToReplace == '\0' ? ReplacePrefix + "OtherInvalid"
|
|
: r.CharacterToReplace == '/' ? ReplacePrefix + "Slash"
|
|
: r.CharacterToReplace == '\\' ? ReplacePrefix + "BackSlash"
|
|
: r.Description == "Open Quote" ? ReplacePrefix + "OpenQuote"
|
|
: r.Description == "Close Quote" ? ReplacePrefix + "CloseQuote"
|
|
: r.Description == "Other Quote" ? ReplacePrefix + "OtherQuote"
|
|
: ReplacePrefix + r.Description.Replace(" ", "");
|
|
|
|
public class OptionOverride
|
|
{
|
|
public string? Property { get; }
|
|
public string? Value { get; }
|
|
|
|
public OptionOverride(string value)
|
|
{
|
|
if (value is null)
|
|
return;
|
|
|
|
//Special case of Replace_= settings
|
|
var start
|
|
= value.StartsWithInsensitive(ReplacePrefix + "=")
|
|
? value.IndexOf('=', ReplacePrefix.Length + 1)
|
|
: value.IndexOf('=');
|
|
|
|
if (start < 1)
|
|
return;
|
|
Property = value[..start];
|
|
|
|
//Don't trim here. Trim before parsing the value if needed, otherwise
|
|
//preserve for settings which utilize white space (e.g. Replacements)
|
|
Value = value[(start + 1)..];
|
|
|
|
if (Value.StartsWith('"') && Value.EndsWith('"'))
|
|
{
|
|
Value = Value[1..];
|
|
}
|
|
|
|
if (Value.EndsWith('"'))
|
|
{
|
|
Value = Value[..^1];
|
|
}
|
|
}
|
|
}
|
|
}
|