using ApplicationServices; using AudibleApi; using AppScaffolding; using AudibleApi.Authorization; using AudibleApi.Cryptography; using AudibleUtilities; using DataLayer; using FileLiberator; using LibationFileManager; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; using System.Linq; using System.Reflection; namespace LibationUiBase.Tests; /// /// A guard against reintroducing the leak fixed in #1960: an AuthenticationRequiredException carried a live /// , and Serilog.Exceptions writes every public property of a logged exception - following /// nested objects to a depth of 10 - into the log file people attach to public issue reports. So no exception /// anywhere may be able to reach an account or an identity that way. /// /// This lives here rather than beside the exception it guards because a test only sees the assemblies its own /// project references, and this one reaches the most of them. /// /// [TestClass] public class ExceptionsCannotReachAnAccount { private static readonly Type[] Forbidden = [ typeof(Account), typeof(AccountsSettings), typeof(Identity), typeof(AccessToken), typeof(RefreshToken), typeof(AdpToken), typeof(PrivateKey) ]; /// /// One public type per assembly, used only to reach the assembly. The AudibleApi packages are in here /// deliberately: is theirs, so an upstream exception that started carrying one would /// leak through Libation, and this is where we would want to find that out - at the version bump, rather /// than in someone's log. /// private static readonly Assembly[] Assemblies = [ typeof(AutoScanRunner).Assembly, // LibationUiBase typeof(LibraryCommands).Assembly, // ApplicationServices typeof(LibationScaffolding).Assembly, // AppScaffolding typeof(Processable).Assembly, // FileLiberator typeof(Account).Assembly, // AudibleUtilities typeof(Configuration).Assembly, // LibationFileManager typeof(FileManager.LongPath).Assembly, // FileManager typeof(LibraryBook).Assembly, // DataLayer typeof(Identity).Assembly, // AudibleApi typeof(NonJsonResponseException).Assembly // AudibleApi.Common ]; [TestMethod] public void no_exception_type_exposes_one_through_its_public_properties() { var exceptionTypes = Assemblies .Distinct() .SelectMany(a => a.GetTypes()) .Where(t => typeof(Exception).IsAssignableFrom(t)) .ToArray(); // if a rename or a project split ever empties this, the test must fail rather than pass silently Assert.IsTrue(exceptionTypes.Length > 0, "found no exception types to check"); foreach (var exceptionType in exceptionTypes) { var path = FindForbidden(exceptionType, depth: 0, [], []); Assert.IsNull(path, $"{exceptionType.Name} can reach a secret-bearing type through public properties: {path}"); } } /// /// Walks public instance properties the way Serilog.Exceptions' ReflectionBasedDestructurer does, to the same /// default depth, and returns the path to the first forbidden type it can reach. /// private static string? FindForbidden(Type type, int depth, HashSet visited, List trail) { if (depth > 10 || !visited.Add(type)) return null; foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { if (property.GetIndexParameters().Length > 0) continue; var propertyType = Unwrap(property.PropertyType); var step = trail.Append($"{type.Name}.{property.Name}").ToList(); if (Forbidden.Contains(propertyType)) return string.Join(" -> ", step); // only follow types from the assemblies at issue: the framework's own graphs are vast and cannot // reach an Audible account if (Assemblies.Contains(propertyType.Assembly)) { var found = FindForbidden(propertyType, depth + 1, visited, step); if (found is not null) return found; } } return null; } /// Collections and nullables hide the interesting type one level down. private static Type Unwrap(Type type) { if (type.IsArray) return Unwrap(type.GetElementType()!); if (!type.IsGenericType) return type; var arguments = type.GetGenericArguments(); return arguments.Length == 1 ? Unwrap(arguments[0]) : type; } }