diff --git a/src/EventLogExpert.Eventing/Common/Channels/LogChannelNames.cs b/src/EventLogExpert.Eventing/Common/Channels/LogChannelNames.cs index 14129e96..0add70bf 100644 --- a/src/EventLogExpert.Eventing/Common/Channels/LogChannelNames.cs +++ b/src/EventLogExpert.Eventing/Common/Channels/LogChannelNames.cs @@ -10,10 +10,12 @@ public static class LogChannelNames public const string StateLog = "State"; public const string SystemLog = "System"; - /// Live event log channels that require process elevation to read. - public static IReadOnlySet AdminOnlyLiveChannels { get; } = new HashSet(StringComparer.OrdinalIgnoreCase) - { - SecurityLog, - StateLog, - }; + private static readonly string[] s_securityScopedChannels = [SecurityLog, StateLog]; + + /// Legacy protected live channels retained for catalog validation and export contracts. + public static IReadOnlySet AdminOnlyLiveChannels { get; } = + new HashSet(s_securityScopedChannels, StringComparer.OrdinalIgnoreCase); + + public static IReadOnlySet RegistrySkipChannels { get; } = + new HashSet(s_securityScopedChannels, StringComparer.OrdinalIgnoreCase); } diff --git a/src/EventLogExpert.Eventing/Interop/EvtEnums.cs b/src/EventLogExpert.Eventing/Interop/EvtEnums.cs index 2786e458..2b870086 100644 --- a/src/EventLogExpert.Eventing/Interop/EvtEnums.cs +++ b/src/EventLogExpert.Eventing/Interop/EvtEnums.cs @@ -133,6 +133,16 @@ internal enum EvtChannelConfigPropertyId EvtChannelConfigIsolation = 1, EvtChannelConfigType = 2, EvtChannelConfigOwningPublisher = 3, + EvtChannelConfigClassicEventlog = 4, + EvtChannelConfigAccess = 5, +} + +public enum EvtChannelType +{ + Admin = 0, + Operational = 1, + Analytic = 2, + Debug = 3, } internal enum EvtVariantType diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.Access.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.Access.cs new file mode 100644 index 00000000..5b69093e --- /dev/null +++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.Access.cs @@ -0,0 +1,117 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Microsoft.Win32.SafeHandles; +using System.Runtime.InteropServices; + +namespace EventLogExpert.Eventing.Interop; + +internal static partial class NativeMethods +{ + private const string Advapi32Api = "advapi32.dll"; + + [LibraryImport(Advapi32Api, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool AccessCheck( + IntPtr securityDescriptor, + SafeAccessTokenHandle clientToken, + uint desiredAccess, + ref GenericMapping genericMapping, + IntPtr privilegeSet, + ref uint privilegeSetLength, + out uint grantedAccess, + [MarshalAs(UnmanagedType.Bool)] out bool accessStatus); + + [LibraryImport(Advapi32Api, EntryPoint = "ConvertStringSecurityDescriptorToSecurityDescriptorW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ConvertStringSecurityDescriptorToSecurityDescriptor( + string stringSecurityDescriptor, + uint stringSecurityDescriptorRevision, + out IntPtr securityDescriptor, + out uint securityDescriptorSize); + + [LibraryImport(Advapi32Api, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool DuplicateTokenEx( + SafeAccessTokenHandle existingToken, + uint desiredAccess, + IntPtr tokenAttributes, + SecurityImpersonationLevel impersonationLevel, + TokenType tokenType, + out SafeAccessTokenHandle newToken); + + [LibraryImport(Kernel32Api, SetLastError = true)] + internal static partial IntPtr GetCurrentProcess(); + + [LibraryImport(Kernel32Api, SetLastError = true)] + internal static partial IntPtr LocalFree(IntPtr memory); + + [LibraryImport(Advapi32Api, EntryPoint = "LookupPrivilegeValueW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool LookupPrivilegeValue( + string? systemName, + string name, + out Luid luid); + + [LibraryImport(Advapi32Api)] + internal static partial void MapGenericMask(ref uint accessMask, ref GenericMapping genericMapping); + + [LibraryImport(Advapi32Api, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool OpenProcessToken( + IntPtr processHandle, + uint desiredAccess, + out SafeAccessTokenHandle tokenHandle); + + [LibraryImport(Advapi32Api, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool PrivilegeCheck( + SafeAccessTokenHandle clientToken, + ref PrivilegeSet requiredPrivileges, + [MarshalAs(UnmanagedType.Bool)] out bool result); +} + +[StructLayout(LayoutKind.Sequential)] +internal struct GenericMapping +{ + internal uint GenericRead; + internal uint GenericWrite; + internal uint GenericExecute; + internal uint GenericAll; +} + +internal enum SecurityImpersonationLevel +{ + SecurityAnonymous = 0, + SecurityIdentification = 1, + SecurityImpersonation = 2, + SecurityDelegation = 3 +} + +internal enum TokenType +{ + TokenPrimary = 1, + TokenImpersonation = 2 +} + +[StructLayout(LayoutKind.Sequential)] +internal struct Luid +{ + internal uint LowPart; + internal int HighPart; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct LuidAndAttributes +{ + internal Luid Luid; + internal uint Attributes; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct PrivilegeSet +{ + internal uint PrivilegeCount; + internal uint Control; + internal LuidAndAttributes Privilege; +} diff --git a/src/EventLogExpert.Eventing/ProviderMetadata/RegistryProvider.cs b/src/EventLogExpert.Eventing/ProviderMetadata/RegistryProvider.cs index 359ecf78..31bcdb23 100644 --- a/src/EventLogExpert.Eventing/ProviderMetadata/RegistryProvider.cs +++ b/src/EventLogExpert.Eventing/ProviderMetadata/RegistryProvider.cs @@ -27,7 +27,7 @@ public IReadOnlyList GetMessageFilesForLegacyProvider(string providerNam foreach (var logSubKeyName in eventLogKey.GetSubKeyNames()) { - if (LogChannelNames.AdminOnlyLiveChannels.Contains(logSubKeyName)) + if (LogChannelNames.RegistrySkipChannels.Contains(logSubKeyName)) { continue; } @@ -83,7 +83,5 @@ public IReadOnlyList GetMessageFilesForLegacyProvider(string providerNam return []; } - private class OpenEventLogRegistryKeyFailedException(string msg) : Exception(msg) - { - } + private class OpenEventLogRegistryKeyFailedException(string msg) : Exception(msg); } diff --git a/src/EventLogExpert.Eventing/Readers/ChannelAccess.cs b/src/EventLogExpert.Eventing/Readers/ChannelAccess.cs new file mode 100644 index 00000000..5fb8cd80 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/ChannelAccess.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Readers; + +public enum ChannelAccess +{ + Accessible, + RequiresElevation, + Unknown, + NotEvaluated +} diff --git a/src/EventLogExpert.Eventing/Readers/ChannelConfig.cs b/src/EventLogExpert.Eventing/Readers/ChannelConfig.cs new file mode 100644 index 00000000..89217d59 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/ChannelConfig.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; + +namespace EventLogExpert.Eventing.Readers; + +public sealed record ChannelConfig(bool? Enabled, ChannelAccess Access, EvtChannelType? Type) +{ + public static ChannelConfig Unknown { get; } = new(null, ChannelAccess.Unknown, null); +} diff --git a/src/EventLogExpert.Eventing/Readers/ChannelConfigPropertySnapshot.cs b/src/EventLogExpert.Eventing/Readers/ChannelConfigPropertySnapshot.cs new file mode 100644 index 00000000..70fcb977 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/ChannelConfigPropertySnapshot.cs @@ -0,0 +1,8 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; + +namespace EventLogExpert.Eventing.Readers; + +internal sealed record ChannelConfigPropertySnapshot(bool? Enabled, string? AccessSddl, EvtChannelType? Type); diff --git a/src/EventLogExpert.Eventing/Readers/EventLogChannelConfigPropertyReader.cs b/src/EventLogExpert.Eventing/Readers/EventLogChannelConfigPropertyReader.cs new file mode 100644 index 00000000..5ad5d990 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/EventLogChannelConfigPropertyReader.cs @@ -0,0 +1,127 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using System.Runtime.InteropServices; + +namespace EventLogExpert.Eventing.Readers; + +internal sealed class EventLogChannelConfigPropertyReader(ITraceLogger? logger = null) : IChannelConfigPropertyReader +{ + private readonly ITraceLogger? _logger = logger; + + public ChannelConfigPropertySnapshot ReadProperties(string channelName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(channelName); + + using var channelConfig = NativeMethods.EvtOpenChannelConfig( + EventLogSession.GlobalSession.Handle, + channelName, + 0); + + if (channelConfig.IsInvalid) + { + int openError = Marshal.GetLastWin32Error(); + + _logger?.Debug( + $"{nameof(ReadProperties)}: EvtOpenChannelConfig failed for {channelName}. Error: {openError} ({NativeMethods.FormatSystemMessage((uint)openError) ?? "unknown"})"); + + return new ChannelConfigPropertySnapshot(null, null, null); + } + + var type = ReadChannelType(channelConfig, channelName); + var enabled = ReadEnabled(channelConfig, channelName); + var accessSddl = type is EvtChannelType.Analytic or EvtChannelType.Debug + ? null + : ReadAccessSddl(channelConfig, channelName); + + return new ChannelConfigPropertySnapshot(enabled, accessSddl, type); + } + + // ConvertVariant boxes the channel type as uint; Enum.IsDefined(typeof(...), boxedUint) throws on this int-backed enum, so match the enum value via the generic overload. + internal static EvtChannelType? ConvertChannelType(object? value) => + value switch + { + uint raw when Enum.IsDefined((EvtChannelType)raw) => (EvtChannelType)raw, + int raw when Enum.IsDefined((EvtChannelType)raw) => (EvtChannelType)raw, + _ => null + }; + + private string? ReadAccessSddl(EvtHandle channelConfig, string channelName) => + ReadProperty(channelConfig, EvtChannelConfigPropertyId.EvtChannelConfigAccess, channelName) as string; + + private EvtChannelType? ReadChannelType(EvtHandle channelConfig, string channelName) => + ConvertChannelType(ReadProperty(channelConfig, EvtChannelConfigPropertyId.EvtChannelConfigType, channelName)); + + private bool? ReadEnabled(EvtHandle channelConfig, string channelName) => + ReadProperty(channelConfig, EvtChannelConfigPropertyId.EvtChannelConfigEnabled, channelName) as bool?; + + private unsafe object? ReadProperty( + EvtHandle channelConfig, + EvtChannelConfigPropertyId propertyId, + string channelName) + { + IntPtr buffer = IntPtr.Zero; + + try + { + bool success = NativeMethods.EvtGetChannelConfigProperty( + channelConfig, + propertyId, + 0, + 0, + IntPtr.Zero, + out int bufferSize); + + int sizeError = Marshal.GetLastWin32Error(); + + if (!success && sizeError != Win32ErrorCodes.ERROR_INSUFFICIENT_BUFFER) + { + _logger?.Debug( + $"{nameof(ReadProperty)}: size probe failed for {channelName} {propertyId}. Error: {sizeError}"); + + return null; + } + + buffer = Marshal.AllocHGlobal(bufferSize); + + success = NativeMethods.EvtGetChannelConfigProperty( + channelConfig, + propertyId, + 0, + bufferSize, + buffer, + out _); + + if (!success) + { + int readError = Marshal.GetLastWin32Error(); + + _logger?.Debug( + $"{nameof(ReadProperty)}: read failed for {channelName} {propertyId}. Error: {readError}"); + + return null; + } + + var variant = *(EvtVariant*)buffer; + return NativeMethods.ConvertVariant(variant); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + _logger?.Debug( + $"{nameof(ReadProperty)}: unexpected failure for {channelName} {propertyId}.\n{ex}"); + + return null; + } + finally + { + if (buffer != IntPtr.Zero) + { + Marshal.FreeHGlobal(buffer); + } + } + } +} diff --git a/src/EventLogExpert.Eventing/Readers/EventLogChannelConfigReader.cs b/src/EventLogExpert.Eventing/Readers/EventLogChannelConfigReader.cs new file mode 100644 index 00000000..09128280 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/EventLogChannelConfigReader.cs @@ -0,0 +1,55 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; + +namespace EventLogExpert.Eventing.Readers; + +public sealed class EventLogChannelConfigReader : IChannelConfigReader, IDisposable +{ + private readonly IChannelAccessEvaluator _accessEvaluator; + private readonly IChannelAccessNative? _ownedAccessNative; + private readonly IChannelConfigPropertyReader _propertyReader; + + public EventLogChannelConfigReader(ITraceLogger? logger = null) + { + _ownedAccessNative = new Win32ChannelAccessNative(); + _accessEvaluator = new NativeChannelAccessEvaluator(_ownedAccessNative); + _propertyReader = new EventLogChannelConfigPropertyReader(logger); + } + + internal EventLogChannelConfigReader( + IChannelConfigPropertyReader propertyReader, + IChannelAccessEvaluator accessEvaluator) + { + _propertyReader = propertyReader; + _accessEvaluator = accessEvaluator; + } + + public void Dispose() => _ownedAccessNative?.Dispose(); + + public ChannelConfig ReadConfig(string channelName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(channelName); + + var properties = _propertyReader.ReadProperties(channelName); + + if (properties.Type is not { } type) + { + return new ChannelConfig(properties.Enabled, ChannelAccess.Unknown, null); + } + + if (type is EvtChannelType.Analytic or EvtChannelType.Debug) + { + return new ChannelConfig(properties.Enabled, ChannelAccess.NotEvaluated, type); + } + + var access = _accessEvaluator.EvaluateAccess( + properties.AccessSddl, + string.Equals(channelName, LogChannelNames.SecurityLog, StringComparison.OrdinalIgnoreCase)); + + return new ChannelConfig(properties.Enabled, access, type); + } +} diff --git a/src/EventLogExpert.Eventing/Readers/IChannelAccessEvaluator.cs b/src/EventLogExpert.Eventing/Readers/IChannelAccessEvaluator.cs new file mode 100644 index 00000000..1b4ba494 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/IChannelAccessEvaluator.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Readers; + +internal interface IChannelAccessEvaluator +{ + ChannelAccess EvaluateAccess(string? sddl, bool isSecurityChannel); +} diff --git a/src/EventLogExpert.Eventing/Readers/IChannelAccessNative.cs b/src/EventLogExpert.Eventing/Readers/IChannelAccessNative.cs new file mode 100644 index 00000000..2f3c5a8b --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/IChannelAccessNative.cs @@ -0,0 +1,30 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using Microsoft.Win32.SafeHandles; + +namespace EventLogExpert.Eventing.Readers; + +internal interface IChannelAccessNative : IDisposable +{ + bool AccessCheck( + IntPtr securityDescriptor, + SafeAccessTokenHandle token, + uint desiredAccess, + ref GenericMapping genericMapping, + Span privilegeSet, + ref uint privilegeSetLength, + out uint grantedAccess, + out bool accessStatus); + + void FreeSecurityDescriptor(IntPtr securityDescriptor); + + void MapGenericMask(ref uint desiredAccess, ref GenericMapping genericMapping); + + bool PrivilegeCheck(SafeAccessTokenHandle token, string privilegeName, out bool result); + + bool TryConvertSecurityDescriptor(string sddl, out IntPtr securityDescriptor); + + bool TryGetImpersonationToken(out SafeAccessTokenHandle token); +} diff --git a/src/EventLogExpert.Eventing/Readers/IChannelConfigPropertyReader.cs b/src/EventLogExpert.Eventing/Readers/IChannelConfigPropertyReader.cs new file mode 100644 index 00000000..7f3cd45e --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/IChannelConfigPropertyReader.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Readers; + +internal interface IChannelConfigPropertyReader +{ + ChannelConfigPropertySnapshot ReadProperties(string channelName); +} diff --git a/src/EventLogExpert.Eventing/Readers/IChannelConfigReader.cs b/src/EventLogExpert.Eventing/Readers/IChannelConfigReader.cs new file mode 100644 index 00000000..a524b964 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/IChannelConfigReader.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Readers; + +public interface IChannelConfigReader +{ + ChannelConfig ReadConfig(string channelName); +} diff --git a/src/EventLogExpert.Eventing/Readers/NativeChannelAccessEvaluator.cs b/src/EventLogExpert.Eventing/Readers/NativeChannelAccessEvaluator.cs new file mode 100644 index 00000000..5951eea8 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/NativeChannelAccessEvaluator.cs @@ -0,0 +1,79 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; + +namespace EventLogExpert.Eventing.Readers; + +internal sealed class NativeChannelAccessEvaluator(IChannelAccessNative native) : IChannelAccessEvaluator +{ + private const uint EvtReadAccess = 0x1; + + private static GenericMapping ChannelGenericMapping => new() + { + GenericRead = 0x1, + GenericWrite = 0x2, + GenericExecute = 0x4, + GenericAll = 0x7 + }; + + public ChannelAccess EvaluateAccess(string? sddl, bool isSecurityChannel) + { + if (string.IsNullOrWhiteSpace(sddl) || + !sddl.Contains("O:", StringComparison.Ordinal) || + !sddl.Contains("G:", StringComparison.Ordinal) || + !native.TryConvertSecurityDescriptor(sddl, out IntPtr securityDescriptor)) + { + return ChannelAccess.Unknown; + } + + try + { + if (!native.TryGetImpersonationToken(out var token)) + { + return ChannelAccess.Unknown; + } + + var desiredAccess = EvtReadAccess; + var genericMapping = ChannelGenericMapping; + native.MapGenericMask(ref desiredAccess, ref genericMapping); + + Span privilegeSet = stackalloc byte[256]; + var privilegeSetLength = (uint)privilegeSet.Length; + + if (!native.AccessCheck( + securityDescriptor, + token, + desiredAccess, + ref genericMapping, + privilegeSet, + ref privilegeSetLength, + out _, + out bool accessStatus)) + { + return ChannelAccess.Unknown; + } + + if (accessStatus) + { + return ChannelAccess.Accessible; + } + + if (!isSecurityChannel) + { + return ChannelAccess.RequiresElevation; + } + + if (!native.PrivilegeCheck(token, "SeSecurityPrivilege", out bool privilegeEnabled)) + { + return ChannelAccess.Unknown; + } + + return privilegeEnabled ? ChannelAccess.Accessible : ChannelAccess.RequiresElevation; + } + finally + { + native.FreeSecurityDescriptor(securityDescriptor); + } + } +} diff --git a/src/EventLogExpert.Eventing/Readers/Win32ChannelAccessNative.cs b/src/EventLogExpert.Eventing/Readers/Win32ChannelAccessNative.cs new file mode 100644 index 00000000..f9160276 --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/Win32ChannelAccessNative.cs @@ -0,0 +1,168 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using Microsoft.Win32.SafeHandles; +using System.Buffers; +using System.Runtime.InteropServices; + +namespace EventLogExpert.Eventing.Readers; + +internal sealed class Win32ChannelAccessNative : IChannelAccessNative +{ + private const uint PrivilegeSetAllNecessary = 1; + private const uint SecurityDescriptorRevision = 1; + private const uint SePrivilegeEnabled = 0x2; + private const uint TokenDuplicate = 0x2; + private const uint TokenImpersonate = 0x4; + private const uint TokenQuery = 0x8; + + private readonly Lock _tokenLock = new(); + + private SafeAccessTokenHandle? _impersonationToken; + + public unsafe bool AccessCheck( + IntPtr securityDescriptor, + SafeAccessTokenHandle token, + uint desiredAccess, + ref GenericMapping genericMapping, + Span privilegeSet, + ref uint privilegeSetLength, + out uint grantedAccess, + out bool accessStatus) + { + fixed (byte* privilegeSetPointer = privilegeSet) + { + if (NativeMethods.AccessCheck( + securityDescriptor, + token, + desiredAccess, + ref genericMapping, + (IntPtr)privilegeSetPointer, + ref privilegeSetLength, + out grantedAccess, + out accessStatus)) + { + return true; + } + + if (Marshal.GetLastWin32Error() != Win32ErrorCodes.ERROR_INSUFFICIENT_BUFFER) + { + return false; + } + } + + byte[] rentedPrivilegeSet = ArrayPool.Shared.Rent((int)privilegeSetLength); + + try + { + fixed (byte* resizedPrivilegeSetPointer = rentedPrivilegeSet) + { + return NativeMethods.AccessCheck( + securityDescriptor, + token, + desiredAccess, + ref genericMapping, + (IntPtr)resizedPrivilegeSetPointer, + ref privilegeSetLength, + out grantedAccess, + out accessStatus); + } + } + finally + { + ArrayPool.Shared.Return(rentedPrivilegeSet); + } + } + + public void Dispose() + { + lock (_tokenLock) + { + _impersonationToken?.Dispose(); + _impersonationToken = null; + } + } + + public void FreeSecurityDescriptor(IntPtr securityDescriptor) + { + if (securityDescriptor != IntPtr.Zero) + { + _ = NativeMethods.LocalFree(securityDescriptor); + } + } + + public void MapGenericMask(ref uint desiredAccess, ref GenericMapping genericMapping) => + NativeMethods.MapGenericMask(ref desiredAccess, ref genericMapping); + + public bool PrivilegeCheck(SafeAccessTokenHandle token, string privilegeName, out bool result) + { + result = false; + + if (!NativeMethods.LookupPrivilegeValue(null, privilegeName, out var luid)) + { + return false; + } + + var privilegeSet = new PrivilegeSet + { + PrivilegeCount = 1, + Control = PrivilegeSetAllNecessary, + Privilege = new LuidAndAttributes + { + Luid = luid, + Attributes = SePrivilegeEnabled + } + }; + + return NativeMethods.PrivilegeCheck(token, ref privilegeSet, out result); + } + + public bool TryConvertSecurityDescriptor(string sddl, out IntPtr securityDescriptor) => + NativeMethods.ConvertStringSecurityDescriptorToSecurityDescriptor( + sddl, + SecurityDescriptorRevision, + out securityDescriptor, + out _); + + public bool TryGetImpersonationToken(out SafeAccessTokenHandle token) + { + lock (_tokenLock) + { + if (_impersonationToken is { IsClosed: false, IsInvalid: false }) + { + token = _impersonationToken; + return true; + } + + if (!NativeMethods.OpenProcessToken( + NativeMethods.GetCurrentProcess(), + TokenDuplicate | TokenQuery, + out var processToken)) + { + token = SafeAccessTokenHandle.InvalidHandle; + return false; + } + + using (processToken) + { + if (!NativeMethods.DuplicateTokenEx( + processToken, + TokenQuery | TokenImpersonate, + IntPtr.Zero, + SecurityImpersonationLevel.SecurityImpersonation, + TokenType.TokenImpersonation, + out var impersonationToken)) + { + token = SafeAccessTokenHandle.InvalidHandle; + return false; + } + + _impersonationToken = impersonationToken; + token = _impersonationToken; + + return true; + } + } + } +} diff --git a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs index 72a63460..f56aae53 100644 --- a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs +++ b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.DatabaseTools.DependencyInjection; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Logging.Abstractions; using EventLogExpert.Logging.Configuration; using EventLogExpert.Logging.Routing; @@ -273,7 +274,11 @@ public IServiceCollection AddEventLogRuntime() // aggregates every registered IScenarioSource (PR1 ships only the built-in source). services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(static sp => + new EventLogChannelConfigReader(CategoryLogger(sp, LogCategories.EventLog))); + services.AddSingleton(); + services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs b/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs index 55db1b99..f858451f 100644 --- a/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs +++ b/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs @@ -18,12 +18,6 @@ public interface IMenuActionService Task ExportEventsAsync(ExportFormat format); - /// - /// Returns the names of every event log channel known to the host. Cached on first call; subsequent calls return - /// immediately. - /// - Task> GetOtherLogNamesAsync(); - void LoadNewEvents(); Task OpenDatabaseToolsAsync(); @@ -38,9 +32,15 @@ public interface IMenuActionService Task OpenLiveLogAsync(string logName, bool combineLog); - Task OpenLiveLogsAsync(IEnumerable logNames, bool combineLog); + Task OpenLiveLogsAsync( + IEnumerable logNames, + bool combineLog, + bool showInlineAlerts = true); - Task OpenLogFilesAsync(IEnumerable filePaths, bool combineLog); + Task OpenLogFilesAsync( + IEnumerable filePaths, + bool combineLog, + bool showInlineAlerts = true); Task OpenSettingsAsync(); diff --git a/src/EventLogExpert.Runtime/Menu/MenuItem.cs b/src/EventLogExpert.Runtime/Menu/MenuItem.cs index 61b3dd47..430513e0 100644 --- a/src/EventLogExpert.Runtime/Menu/MenuItem.cs +++ b/src/EventLogExpert.Runtime/Menu/MenuItem.cs @@ -38,10 +38,12 @@ public sealed record MenuItem /// Optional explanation surfaced to assistive tech (and as a hover title) when is /// false. Items with a non-null reason participate in roving focus and type-ahead so keyboard / screen-reader /// users can discover that the option exists; activation is still blocked. Use for "not available because X" cases - /// where users need to know the option is there (e.g., "Cached — empty: add filters first"). + /// where users need to know the option is there (e.g., "Cached - empty: add filters first"). /// public string? DisabledReason { get; init; } + public string? StatusText { get; init; } + /// /// True when the item should participate in roving focus / type-ahead. Enabled items are always focusable; /// disabled items are focusable only when they expose a (informative-disabled pattern). @@ -57,7 +59,8 @@ public static MenuItem Item( bool? isChecked = null, bool isEnabled = true, bool isDanger = false, - string? disabledReason = null) => + string? disabledReason = null, + string? statusText = null) => new() { Label = label, @@ -67,6 +70,7 @@ public static MenuItem Item( IsEnabled = isEnabled, IsDanger = isDanger, DisabledReason = disabledReason, + StatusText = statusText, }; public static MenuItem Item( @@ -75,7 +79,8 @@ public static MenuItem Item( string? shortcut = null, bool? isChecked = null, bool isEnabled = true, - string? disabledReason = null) => + string? disabledReason = null, + string? statusText = null) => Item( label, () => { onClick(); return Task.CompletedTask; }, @@ -83,7 +88,8 @@ public static MenuItem Item( isChecked, isEnabled, isDanger: false, - disabledReason); + disabledReason, + statusText); public static MenuItem SubMenu(string label, IReadOnlyList children, bool isEnabled = true) => new() { Label = label, Children = children, IsEnabled = isEnabled }; diff --git a/src/EventLogExpert.Runtime/Menu/OpenLogsBatchResult.cs b/src/EventLogExpert.Runtime/Menu/OpenLogsBatchResult.cs index 72ef33d0..0c8a2bd7 100644 --- a/src/EventLogExpert.Runtime/Menu/OpenLogsBatchResult.cs +++ b/src/EventLogExpert.Runtime/Menu/OpenLogsBatchResult.cs @@ -1,6 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Runtime.Scenarios; using System.Collections.Immutable; namespace EventLogExpert.Runtime.Menu; @@ -12,7 +13,9 @@ public sealed record OpenLogsBatchResult( int Skipped, ImmutableArray EmptyNames) { - public static OpenLogsBatchResult None { get; } = new(0, 0, 0, 0, []); + public static OpenLogsBatchResult None { get; } = new(0, 0, 0, 0, []) { ChannelOutcomes = [] }; public bool AnyOpened => Opened > 0; + + public ImmutableArray ChannelOutcomes { get; init; } = []; } diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelEnablement.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelEnablement.cs new file mode 100644 index 00000000..56a71a15 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/ChannelEnablement.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +public enum ChannelEnablement +{ + Enabled, + Disabled, + Unknown +} diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelLaunchOutcome.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelLaunchOutcome.cs new file mode 100644 index 00000000..67e6630b --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/ChannelLaunchOutcome.cs @@ -0,0 +1,14 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +public enum ChannelLaunchOutcome +{ + Opened, + Empty, + AccessDenied, + NotPresent, + Skipped, + Failed +} diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelOutcome.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelOutcome.cs new file mode 100644 index 00000000..fc205219 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/ChannelOutcome.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +public sealed record ChannelOutcome(string Channel, ChannelLaunchOutcome Outcome); diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelPresence.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelPresence.cs new file mode 100644 index 00000000..bfdb333f --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/ChannelPresence.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +public enum ChannelPresence +{ + Present, + Absent, + Unknown +} diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelPresenceProbe.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelPresenceProbe.cs index c0a064b1..0e1ca3b5 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ChannelPresenceProbe.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ChannelPresenceProbe.cs @@ -3,41 +3,98 @@ using EventLogExpert.Eventing.Readers; using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Scenarios.Catalog; using System.Collections.Frozen; +using System.Collections.Immutable; namespace EventLogExpert.Runtime.Scenarios; -internal sealed class ChannelPresenceProbe : IChannelPresenceProbe +internal sealed class ChannelPresenceProbe : IChannelPresenceProbe, IChannelReadinessService { private static readonly IReadOnlySet s_empty = FrozenSet.Empty; + private readonly IChannelConfigReader _configReader; + private readonly ImmutableArray _eagerConfigChannels; private readonly SemaphoreSlim _gate = new(1, 1); private readonly Func> _readChannels; private readonly ITraceLogger _traceLogger; - private IReadOnlySet? _cache; - - public ChannelPresenceProbe(ITraceLogger traceLogger) - : this(traceLogger, static () => EventLogSession.GlobalSession.GetLogNames()) { } - - internal ChannelPresenceProbe(ITraceLogger traceLogger, Func> readChannels) + private Dictionary _config = new(StringComparer.OrdinalIgnoreCase); + private FrozenSet? _presentChannels; + + public ChannelPresenceProbe( + ITraceLogger traceLogger, + IChannelConfigReader configReader, + BuiltInScenarioRegistry registry) + : this( + traceLogger, + configReader, + CatalogChannels(registry), + static () => EventLogSession.GlobalSession.GetLogNames()) { } + + internal ChannelPresenceProbe( + ITraceLogger traceLogger, + IChannelConfigReader configReader, + IEnumerable eagerConfigChannels, + Func> readChannels) { ArgumentNullException.ThrowIfNull(traceLogger); + ArgumentNullException.ThrowIfNull(configReader); + ArgumentNullException.ThrowIfNull(eagerConfigChannels); ArgumentNullException.ThrowIfNull(readChannels); + _eagerConfigChannels = [.. eagerConfigChannels.Distinct(StringComparer.OrdinalIgnoreCase)]; + _configReader = configReader; _traceLogger = traceLogger; _readChannels = readChannels; } public IReadOnlySet GetPresentChannels() => TryGetPresentChannels() ?? s_empty; + public Task> GetReadinessAsync(CancellationToken cancellationToken = default) => + Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + return GetReadinessCore(channels: null, cancellationToken); + }, cancellationToken); + + public Task> GetReadinessAsync( + IEnumerable channels, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channels); + + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + return GetReadinessCore(channels, cancellationToken); + }, cancellationToken); + } + + public void Invalidate() + { + _gate.Wait(); + + try + { + _presentChannels = null; + _config = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + finally + { + _gate.Release(); + } + } + public bool IsPresent(string logName) => GetPresentChannels().Contains(logName); public Task PrimeAsync() => Task.Run(GetPresentChannels); public IReadOnlySet? TryGetPresentChannels() { - var cached = _cache; + var cached = _presentChannels; if (cached is not null) { return cached; } @@ -45,14 +102,112 @@ internal ChannelPresenceProbe(ITraceLogger traceLogger, Func try { - if (_cache is not null) { return _cache; } + return EnsurePresentChannels(); + } + finally + { + _gate.Release(); + } + } + + private static ImmutableArray CatalogChannels(BuiltInScenarioRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + + return + [ + .. registry.Scenarios + .SelectMany(static scenario => scenario.Channels.Concat(scenario.OptionalChannels)) + .Distinct(StringComparer.OrdinalIgnoreCase) + ]; + } + + private static ChannelEnablement ToEnablement(bool? enabled) => enabled switch + { + true => ChannelEnablement.Enabled, + false => ChannelEnablement.Disabled, + _ => ChannelEnablement.Unknown + }; + + private void EnrichConfig(IEnumerable channels, CancellationToken cancellationToken) + { + foreach (var channel in channels) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(channel) || _config.ContainsKey(channel)) { continue; } + + try + { + var config = _configReader.ReadConfig(channel); + _config[channel] = new ChannelConfigSnapshot(ToEnablement(config.Enabled), config.Access); + } + catch (Exception exception) when (exception is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + _traceLogger.Warning($"ChannelPresenceProbe: failed to read config for {channel}: {exception.Message}"); + _config[channel] = ChannelConfigSnapshot.Unknown; + } + } + } + + private FrozenSet? EnsurePresentChannels() + { + if (_presentChannels is not null) { return _presentChannels; } + + var loaded = TryReadChannels(); - var loaded = TryReadChannels(); + if (loaded is not null) + { + _presentChannels = loaded; + } - // A failed read is not cached, so the next read retries. - if (loaded is not null) { _cache = loaded; } + return loaded; + } + + private ImmutableArray GetReadinessCore(IEnumerable? channels, CancellationToken cancellationToken) + { + var requested = channels? + .Where(channel => !string.IsNullOrWhiteSpace(channel)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); - return loaded; + _gate.Wait(cancellationToken); + + try + { + var presentChannels = EnsurePresentChannels(); + ImmutableArray targets = requested ?? [.. (presentChannels ?? s_empty).OrderBy(channel => channel, StringComparer.OrdinalIgnoreCase)]; + + if (presentChannels is not null) + { + EnrichConfig(requested is not null ? targets : _eagerConfigChannels, cancellationToken); + } + + // EnrichConfig only checks the token between channels; re-check here so a cancellation that lands during + // the final (or only) config read still cancels the readiness op instead of returning a stale result. + cancellationToken.ThrowIfCancellationRequested(); + + return + [ + .. targets.Select(channel => + { + var presence = presentChannels is null + ? ChannelPresence.Unknown + : presentChannels.Contains(channel) ? ChannelPresence.Present : ChannelPresence.Absent; + + var config = _config.GetValueOrDefault(channel, ChannelConfigSnapshot.Unknown); + + return new ChannelReadiness( + channel, + presence, + config.Enablement) + { + Access = config.Access + }; + }) + ]; } finally { @@ -60,7 +215,7 @@ internal ChannelPresenceProbe(ITraceLogger traceLogger, Func } } - private IReadOnlySet? TryReadChannels() + private FrozenSet? TryReadChannels() { try { @@ -73,4 +228,10 @@ internal ChannelPresenceProbe(ITraceLogger traceLogger, Func return null; } } + + private readonly record struct ChannelConfigSnapshot(ChannelEnablement Enablement, ChannelAccess Access) + { + internal static ChannelConfigSnapshot Unknown { get; } = + new(ChannelEnablement.Unknown, ChannelAccess.Unknown); + } } diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelReadiness.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelReadiness.cs new file mode 100644 index 00000000..6ff9e637 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/ChannelReadiness.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Readers; + +namespace EventLogExpert.Runtime.Scenarios; + +public sealed record ChannelReadiness(string Channel, ChannelPresence Presence, ChannelEnablement Enablement) +{ + public ChannelAccess Access { get; init; } = ChannelAccess.Unknown; +} diff --git a/src/EventLogExpert.Runtime/Scenarios/IChannelPresenceProbe.cs b/src/EventLogExpert.Runtime/Scenarios/IChannelPresenceProbe.cs index 80e9b5dc..862dba13 100644 --- a/src/EventLogExpert.Runtime/Scenarios/IChannelPresenceProbe.cs +++ b/src/EventLogExpert.Runtime/Scenarios/IChannelPresenceProbe.cs @@ -3,17 +3,13 @@ namespace EventLogExpert.Runtime.Scenarios; -/// Reports which event-log channels exist on this host, cached per session. internal interface IChannelPresenceProbe { - /// Present channel names; empty if the channel set could not be read. IReadOnlySet GetPresentChannels(); bool IsPresent(string logName); - /// Warms the cache on a background thread. Task PrimeAsync(); - /// Present channel names, or null when the channel set could not be read. IReadOnlySet? TryGetPresentChannels(); } diff --git a/src/EventLogExpert.Runtime/Scenarios/IChannelReadinessService.cs b/src/EventLogExpert.Runtime/Scenarios/IChannelReadinessService.cs new file mode 100644 index 00000000..95d230b7 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/IChannelReadinessService.cs @@ -0,0 +1,17 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.Scenarios; + +public interface IChannelReadinessService +{ + Task> GetReadinessAsync(CancellationToken cancellationToken = default); + + Task> GetReadinessAsync( + IEnumerable channels, + CancellationToken cancellationToken = default); + + void Invalidate(); +} diff --git a/src/EventLogExpert.Runtime/Scenarios/IScenarioQueryService.cs b/src/EventLogExpert.Runtime/Scenarios/IScenarioQueryService.cs index fc73eed8..17ae425c 100644 --- a/src/EventLogExpert.Runtime/Scenarios/IScenarioQueryService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/IScenarioQueryService.cs @@ -11,9 +11,6 @@ public interface IScenarioQueryService /// Scenarios whose channels match a currently-loaded log name. IReadOnlyList GetInAppScenarios(IReadOnlyCollection loadedLogNames); - /// A snapshot of which channels exist on this host, for gating live launches. - LivePresence GetLivePresence(); - /// Every channel-presence scenario in the catalog, regardless of local availability. IReadOnlyList GetSplashScenarios(); } diff --git a/src/EventLogExpert.Runtime/Scenarios/LivePresence.cs b/src/EventLogExpert.Runtime/Scenarios/LivePresence.cs index ee0dc882..8daa8673 100644 --- a/src/EventLogExpert.Runtime/Scenarios/LivePresence.cs +++ b/src/EventLogExpert.Runtime/Scenarios/LivePresence.cs @@ -1,10 +1,29 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using System.Collections.Frozen; + namespace EventLogExpert.Runtime.Scenarios; /// /// A snapshot of the host's channel set. is false when the channel set could not be read, in /// which case callers should treat every scenario as launchable rather than falsely reporting it offline. /// -public sealed record LivePresence(bool Known, IReadOnlySet Present); +public sealed record LivePresence(bool Known, IReadOnlySet Present) +{ + /// + /// Derives presence from an already-fetched readiness snapshot so callers that need both readiness and presence + /// don't probe the host twice. is false when any channel reads back as + /// (a global enumeration failure), matching the treat-everything-launchable + /// fallback. + /// + public static LivePresence FromReadiness(IReadOnlyCollection readiness) => + readiness.Any(channel => channel.Presence == ChannelPresence.Unknown) + ? new LivePresence(false, FrozenSet.Empty) + : new LivePresence( + true, + readiness + .Where(channel => channel.Presence == ChannelPresence.Present) + .Select(channel => channel.Channel) + .ToFrozenSet(StringComparer.OrdinalIgnoreCase)); +} diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchResult.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchResult.cs index e861097d..857d7c50 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchResult.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchResult.cs @@ -1,12 +1,15 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using System.Collections.Immutable; + namespace EventLogExpert.Runtime.Scenarios; -/// Outcome of launching a scenario: how its channels opened. public sealed record ScenarioLaunchResult(int Opened, int Empty, int Failed) { - public static ScenarioLaunchResult None { get; } = new(0, 0, 0); + public static ScenarioLaunchResult None { get; } = new(0, 0, 0) { ChannelOutcomes = [] }; public bool AnyOpened => Opened > 0; + + public ImmutableArray ChannelOutcomes { get; init; } = []; } diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs index e5411a44..b13abf98 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs @@ -48,9 +48,9 @@ public async Task LaunchAsync( _dispatcher.Dispatch(new ReplaceFiltersAction(filters)); _dispatcher.Dispatch(new SetFilterDateRangeAction(dateWindow)); - var result = await _menuActionService.OpenLiveLogsAsync(scenario.Channels, combineLog); + var result = await _menuActionService.OpenLiveLogsAsync(scenario.Channels, combineLog, showInlineAlerts: false); - if (result.Opened == 0 && !combineLog) + if (!combineLog && (result.Opened == 0 || HasDegradedRequiredChannel(scenario, result.ChannelOutcomes))) { _dispatcher.Dispatch(new CloseAllLogsAction()); _dispatcher.Dispatch(new RestoreFilterPaneStateAction(priorFilterState)); @@ -68,7 +68,10 @@ public async Task LaunchAsync( } } - return new ScenarioLaunchResult(result.Opened, result.Empty, result.Failed); + return new ScenarioLaunchResult(result.Opened, result.Empty, result.Failed) + { + ChannelOutcomes = result.ChannelOutcomes + }; } public async Task LaunchFromFolderAsync( @@ -142,6 +145,21 @@ private static ImmutableArray AllTargetChannels(ScenarioDefinition scena private static string FilesWord(int count) => count == 1 ? "1 file" : $"{count} files"; + private static bool HasDegradedRequiredChannel( + ScenarioDefinition scenario, + ImmutableArray channelOutcomes) + { + if (channelOutcomes.IsDefaultOrEmpty) { return false; } + + HashSet requiredChannels = new(scenario.Channels, StringComparer.OrdinalIgnoreCase); + + return channelOutcomes.Any(outcome => + requiredChannels.Contains(outcome.Channel) && + outcome.Outcome is ChannelLaunchOutcome.AccessDenied + or ChannelLaunchOutcome.NotPresent + or ChannelLaunchOutcome.Failed); + } + private static HistogramDimension MapTimelineDimension(ScenarioTimelineDimension dimension) => dimension switch { ScenarioTimelineDimension.Severity => HistogramDimension.Severity, @@ -201,7 +219,7 @@ private async Task OpenMatchedLogsAsync( _dispatcher.Dispatch(new ReplaceFiltersAction(_registry.BuildFilterSet(scenario))); _dispatcher.Dispatch(new SetFilterDateRangeAction(dateWindow)); - var result = await _menuActionService.OpenLogFilesAsync(match.Paths, combineLog: false); + var result = await _menuActionService.OpenLogFilesAsync(match.Paths, combineLog: false, showInlineAlerts: false); var missing = MissingChannels(scenario, match.MatchedChannels); diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioQueryService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioQueryService.cs index ac3d1902..fbe0b53c 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioQueryService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioQueryService.cs @@ -2,14 +2,11 @@ // // Licensed under the MIT License. using EventLogExpert.Scenarios.Catalog; -using System.Collections.Frozen; namespace EventLogExpert.Runtime.Scenarios; -internal sealed class ScenarioQueryService(BuiltInScenarioRegistry registry, IChannelPresenceProbe presenceProbe) - : IScenarioQueryService +internal sealed class ScenarioQueryService(BuiltInScenarioRegistry registry) : IScenarioQueryService { - private readonly IChannelPresenceProbe _presenceProbe = presenceProbe; private readonly BuiltInScenarioRegistry _registry = registry; public IReadOnlyList GetInAppScenarios(IReadOnlyCollection loadedLogNames) @@ -21,15 +18,6 @@ public IReadOnlyList GetInAppScenarios(IReadOnlyCollection scenario.Channels.Any(loaded.Contains))]; } - public LivePresence GetLivePresence() - { - var present = _presenceProbe.TryGetPresentChannels(); - - return present is null - ? new LivePresence(false, FrozenSet.Empty) - : new LivePresence(true, present); - } - public IReadOnlyList GetSplashScenarios() => [ .._registry.Scenarios.Where(scenario => scenario.Gating == ScenarioGating.ChannelPresence) diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor index 8b2fa515..b06597a3 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor @@ -48,7 +48,7 @@ OnClick="OpenSystemAsync"> System (live) - @if (Version.IsAdmin) + @if (!SecurityRequiresElevation) { - - Requires running as administrator. + Access denied (Needs elevation) diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs index ca363b74..0784ad98 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs @@ -2,9 +2,9 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Announcement; -using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterPane; using EventLogExpert.Runtime.Menu; @@ -45,6 +45,8 @@ public sealed partial class EmptyStateDashboard : FluxorComponent private bool _isBusy; private LivePresence _livePresence = new(false, FrozenSet.Empty); private bool _pendingTabFocus; + private IReadOnlyDictionary _readinessByChannel = + new Dictionary(StringComparer.OrdinalIgnoreCase); private SidebarTabs? _sidebarTabs; private IReadOnlyList? _splashScenarios; private IReadOnlyList<(SplashCategory Tab, string Label)> _tabs = []; @@ -55,6 +57,8 @@ public sealed partial class EmptyStateDashboard : FluxorComponent [Inject] private IAnnouncementService Announcer { get; init; } = null!; + [Inject] private IChannelReadinessService ChannelReadinessService { get; init; } = null!; + [Inject] private IScenarioFavoriteCommands FavoriteCommands { get; init; } = null!; [Inject] private IStateSelection> Favorites { get; init; } = null!; @@ -69,7 +73,11 @@ public sealed partial class EmptyStateDashboard : FluxorComponent [Inject] private IScenarioQueryService ScenarioQuery { get; init; } = null!; - [Inject] private ICurrentVersionProvider Version { get; init; } = null!; + private bool SecurityRequiresElevation => + _readinessByChannel.GetValueOrDefault( + LogChannelNames.SecurityLog, + new ChannelReadiness(LogChannelNames.SecurityLog, ChannelPresence.Unknown, ChannelEnablement.Unknown)) + .Access == ChannelAccess.RequiresElevation; internal static string ScenarioIcon(ScenarioGroup group) => group switch { @@ -131,10 +139,11 @@ protected override void OnInitialized() protected override async Task OnInitializedAsync() { - // Both the catalog and the host channel set are read once, off the UI thread; the presence snapshot warms the - // probe cache that GetSplashScenarios no longer touches now that it returns the full catalog. - (_splashScenarios, _livePresence) = await Task.Run( - () => (ScenarioQuery.GetSplashScenarios(), ScenarioQuery.GetLivePresence())); + _splashScenarios = await Task.Run(ScenarioQuery.GetSplashScenarios); + + var readiness = await ChannelReadinessService.GetReadinessAsync(CatalogChannels(_splashScenarios)); + _readinessByChannel = readiness.ToDictionary(channel => channel.Channel, StringComparer.OrdinalIgnoreCase); + _livePresence = LivePresence.FromReadiness(readiness); RebuildCategories(); if (_categories.Count > 0 && !_categories.Any(category => category.Category.Equals(_activeCategory))) @@ -145,6 +154,13 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); } + private static IEnumerable CatalogChannels(IReadOnlyList? scenarios) => + scenarios is null + ? [] + : scenarios + .SelectMany(static scenario => scenario.Channels.Concat(scenario.OptionalChannels)) + .Distinct(StringComparer.OrdinalIgnoreCase); + private static string? DescribeFolderLaunch(ScenarioDefinition scenario, ScenarioFolderLaunchResult result) { var scanNote = result.Unreadable > 0 ? $" {FolderFilesWord(result.Unreadable)} could not be inspected." : string.Empty; @@ -165,6 +181,21 @@ protected override async Task OnInitializedAsync() private static string DescribeLaunch(ScenarioDefinition scenario, ScenarioLaunchResult result) { + if (!result.ChannelOutcomes.IsDefaultOrEmpty) + { + var failedOutcomes = result.ChannelOutcomes + .Where(outcome => outcome.Outcome is ChannelLaunchOutcome.AccessDenied + or ChannelLaunchOutcome.NotPresent + or ChannelLaunchOutcome.Failed) + .Select(DescribeLaunchOutcome) + .ToList(); + + if (failedOutcomes.Count > 0) + { + return $"{scenario.Name} could not open every required channel. {string.Join(" ", failedOutcomes)}"; + } + } + if (result.Opened == 0) { return $"No channels could be opened for {scenario.Name}."; @@ -175,6 +206,17 @@ private static string DescribeLaunch(ScenarioDefinition scenario, ScenarioLaunch : $"Opened {scenario.Name}."; } + private static string DescribeLaunchOutcome(ChannelOutcome outcome) => outcome.Outcome switch + { + ChannelLaunchOutcome.AccessDenied => + $"{outcome.Channel}: access denied - needs elevation, or open from a saved .evtx.", + ChannelLaunchOutcome.NotPresent => + $"{outcome.Channel}: not present on this computer - open from a saved .evtx.", + ChannelLaunchOutcome.Failed => + $"{outcome.Channel}: failed to open - open from a saved .evtx.", + _ => $"{outcome.Channel}: {outcome.Outcome}." + }; + private static string FolderFilesWord(int count) => count == 1 ? "1 file" : $"{count} files"; private static string FolderLogsWord(int count) => count == 1 ? "1 log" : $"{count} logs"; @@ -182,6 +224,20 @@ private static string DescribeLaunch(ScenarioDefinition scenario, ScenarioLaunch private static string FolderMissingNote(ImmutableArray missing) => missing.IsDefaultOrEmpty ? string.Empty : $" Not found: {string.Join(", ", missing)}."; + private static bool HasReactiveFolderFallback(ScenarioLaunchResult result) => + !result.ChannelOutcomes.IsDefaultOrEmpty && + result.ChannelOutcomes.Any(outcome => outcome.Outcome is ChannelLaunchOutcome.AccessDenied + or ChannelLaunchOutcome.NotPresent + or ChannelLaunchOutcome.Failed); + + // Analytic/Debug channels never have their access evaluated (NotEvaluated), so they must not be + // treated as blocked; only a genuine RequiresElevation or a read-failure Unknown disables launch. + private bool AccessAllowsLaunch(string channel) => + _readinessByChannel.GetValueOrDefault( + channel, + new ChannelReadiness(channel, ChannelPresence.Unknown, ChannelEnablement.Unknown)) + .Access is ChannelAccess.Accessible or ChannelAccess.NotEvaluated; + private void ClearFilter() => FilterCommands.ClearAllFilters(); private IEnumerable FavoriteScenarios() => @@ -192,46 +248,66 @@ _splashScenarios is null .OrderBy(scenario => scenario.Priority) .ThenBy(scenario => scenario.Order); + private IReadOnlyList GetChannelReadiness(ScenarioDefinition scenario) => + ReadinessFor(scenario.Channels); + + private IReadOnlyList GetOptionalChannelReadiness(ScenarioDefinition scenario) => + scenario.OptionalChannels.IsDefaultOrEmpty ? [] : ReadinessFor(scenario.OptionalChannels); + private bool IsFavored(ScenarioDefinition scenario) => ScenarioFavorites.Value.FavoriteScenarioIds.Contains(scenario.Id); - // A scenario is "live present" when the host exposes one of its channels. When the channel set could not be read - // (Unknown), every scenario stays launchable so a probe failure never falsely reports a scenario offline. private bool IsLivePresent(ScenarioDefinition scenario) => - !_livePresence.Known || scenario.Channels.Any(_livePresence.Present.Contains); + !_livePresence.Known || scenario.Channels.All(_livePresence.Present.Contains); private bool IsScenarioDisabled(ScenarioDefinition scenario) => - (scenario.RequiresAdmin && !Version.IsAdmin) || !IsLivePresent(scenario); + _livePresence.Known && + !scenario.Channels.All(channel => _livePresence.Present.Contains(channel) && AccessAllowsLaunch(channel)); private Task LaunchScenarioAsync(ScenarioDefinition scenario) => RunGuardedAsync(async () => { var result = await ScenarioLaunch.LaunchAsync(scenario, null); - Announcer.Announce(DescribeLaunch(scenario, result)); + var message = DescribeLaunch(scenario, result); + + if (HasReactiveFolderFallback(result)) + { + await AlertDialogService.ShowErrorAlert( + "Launch scenario", + message, + "Open from folder", + () => LaunchScenarioFromFolderCoreAsync(scenario)); + } + else + { + Announcer.Announce(message); + } }); private Task LaunchScenarioFromFolderAsync(ScenarioDefinition scenario) => - RunGuardedAsync(async () => - { - var result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null, _lifetimeCts.Token); + RunGuardedAsync(() => LaunchScenarioFromFolderCoreAsync(scenario)); - if (DescribeFolderLaunch(scenario, result) is not { } message) { return; } + private async Task LaunchScenarioFromFolderCoreAsync(ScenarioDefinition scenario) + { + var result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null, _lifetimeCts.Token); - // A launch that opens logs is self-evident (the workspace changes) and only needs the screen-reader detail; - // the outcomes that leave the dashboard unchanged need a visible dialog so a sighted user sees them. - switch (result.Outcome) - { - case ScenarioFolderOutcome.Completed: - Announcer.Announce(message); - break; - case ScenarioFolderOutcome.Error: - await AlertDialogService.ShowErrorAlert("Open from folder", message); - break; - default: - await AlertDialogService.ShowAlert("Open from folder", message, "OK"); - break; - } - }); + if (DescribeFolderLaunch(scenario, result) is not { } message) { return; } + + // A launch that opens logs is self-evident (the workspace changes) and only needs the screen-reader detail; + // the outcomes that leave the dashboard unchanged need a visible dialog so a sighted user sees them. + switch (result.Outcome) + { + case ScenarioFolderOutcome.Completed: + Announcer.Announce(message); + break; + case ScenarioFolderOutcome.Error: + await AlertDialogService.ShowErrorAlert("Open from folder", message); + break; + default: + await AlertDialogService.ShowAlert("Open from folder", message, "OK"); + break; + } + } private async void OnFavoritesChanged(object? _, ImmutableHashSet __) { @@ -263,6 +339,14 @@ private Task OpenApplicationAndSystemAsync() => private Task OpenSystemAsync() => RunGuardedAsync(() => Actions.OpenLiveLogAsync(LogChannelNames.SystemLog, false)); + private IReadOnlyList ReadinessFor(IEnumerable channels) => + [ + .. channels.Select(channel => + _readinessByChannel.GetValueOrDefault( + channel, + new ChannelReadiness(channel, ChannelPresence.Unknown, ChannelEnablement.Unknown))) + ]; + private void RebuildCategories() { List<(SplashCategory Category, string Label, IReadOnlyList Scenarios)> categories = []; diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor index 5c9eae81..ccef048a 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor +++ b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor @@ -26,13 +26,15 @@
@if (Selected is not null) { - }
diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor.cs b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor.cs index 64ff07ff..eecbe2f0 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor.cs @@ -1,6 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Scenarios.Catalog; using EventLogExpert.UI.Common.Interop; using Microsoft.AspNetCore.Components; @@ -17,6 +18,12 @@ public sealed partial class ScenarioBrowserPanel : IAsyncDisposable private ElementReference _scenarioBrowserRootRef; private IJSObjectReference? _scrollSuppressorModule; + [Parameter][EditorRequired] + public Func> GetChannelReadiness { get; set; } = static _ => []; + + [Parameter][EditorRequired] + public Func> GetOptionalChannelReadiness { get; set; } = static _ => []; + [Parameter] public bool IsBusy { get; set; } [Parameter][EditorRequired] public Func IsFavored { get; set; } = static _ => false; diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor index e17cab11..f746d373 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor @@ -1,4 +1,20 @@ +@using EventLogExpert.Eventing.Readers +@using EventLogExpert.Runtime.Scenarios @using EventLogExpert.Scenarios.Catalog +@{ + RenderFragment StatusTags(ChannelReadiness readiness) => + @ + @PresenceLabel(readiness.Presence) + @if (readiness.Enablement != ChannelEnablement.Enabled) + { + @EnablementLabel(readiness.Enablement) + } + @if (readiness.Access == ChannelAccess.RequiresElevation) + { + Access denied (Needs elevation) + } + ; +}

@@ -9,21 +25,26 @@

Logs - @string.Join(", ", Scenario.Channels) - @if (!Scenario.OptionalChannels.IsDefaultOrEmpty) + @foreach (var readiness in DisplayReadiness) { - Also if present: @string.Join(", ", Scenario.OptionalChannels) + + @readiness.Channel + @StatusTags(readiness) + + } + @if (DisplayOptionalReadiness.Count > 0) + { + Also if present: + + @foreach (var readiness in DisplayOptionalReadiness) + { + + @readiness.Channel + @StatusTags(readiness) + + } }
- @if (Scenario.RequiresAdmin) - { -
- - - Live launch requires administrator. - -
- } @if (FilterLines.Count > 0) {
@@ -44,7 +65,13 @@ @if (IsDisabled && !IsLivePresent) {

- This log is not on this computer. Use Open from folder to analyze an exported copy. + One or more of these logs are not on this computer. Use Open from folder to analyze an exported copy. +

+ } + else if (IsDisabled) + { +

+ Live access to one or more of these logs is blocked. Use Open from folder to analyze an exported copy.

}
@@ -53,7 +80,7 @@ CssClass="scenario-detail__star" IconClass="@(IsFavored ? "bi bi-star-fill" : "bi bi-star")" OnClick="OnToggleFavorite" /> - ChannelReadiness { get; set; } = []; + [Parameter] public bool IsBusy { get; set; } [Parameter] public bool IsDisabled { get; set; } @@ -29,8 +31,25 @@ public sealed partial class ScenarioDetail [Parameter] public EventCallback OnToggleFavorite { get; set; } + [Parameter] public IReadOnlyList OptionalChannelReadiness { get; set; } = []; + [Parameter][EditorRequired] public ScenarioDefinition Scenario { get; set; } = null!; + private IReadOnlyList DisplayOptionalReadiness => + OptionalChannelReadiness.Count > 0 ? OptionalChannelReadiness : + Scenario.OptionalChannels.IsDefaultOrEmpty ? [] : + [ + .. Scenario.OptionalChannels.Select(channel => + new ChannelReadiness(channel, ChannelPresence.Unknown, ChannelEnablement.Unknown)) + ]; + + private IReadOnlyList DisplayReadiness => + ChannelReadiness.Count > 0 ? ChannelReadiness : + [ + .. Scenario.Channels.Select(channel => + new ChannelReadiness(channel, ChannelPresence.Unknown, ChannelEnablement.Unknown)) + ]; + private IReadOnlyList FilterLines { get @@ -50,6 +69,20 @@ private IReadOnlyList FilterLines } } + private static string EnablementLabel(ChannelEnablement enablement) => enablement switch + { + ChannelEnablement.Enabled => "Enabled", + ChannelEnablement.Disabled => "Disabled", + _ => "Enablement unknown" + }; + + private static string PresenceLabel(ChannelPresence presence) => presence switch + { + ChannelPresence.Present => "Present", + ChannelPresence.Absent => "Not present", + _ => "Presence unknown" + }; + private async Task LaunchAsync() { if (IsDisabled) { return; } @@ -57,8 +90,6 @@ private async Task LaunchAsync() await OnLaunch.InvokeAsync(); } - // Opening exported files from a folder needs no elevation, so this stays available even when the live Launch is - // admin-gated (IsDisabled); only an in-flight operation (IsBusy) blocks it. private async Task LaunchFromFolderAsync() { if (IsBusy) { return; } diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css index 70025a46..715a800c 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css @@ -77,6 +77,27 @@ color: color-mix(in srgb, var(--text-secondary) 78%, transparent); } +.scenario-detail__channel-readiness { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.scenario-detail__channel-readiness--optional { + opacity: 0.85; +} + +.scenario-detail__status { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border: 1px solid var(--border-divider); + border-radius: 999px; + font-size: 0.78rem; + color: var(--text-secondary); +} + .scenario-detail__admin-badge { display: inline-flex; align-items: center; diff --git a/src/EventLogExpert.UI/Menu/MenuBar.razor.cs b/src/EventLogExpert.UI/Menu/MenuBar.razor.cs index e4a813e4..81957bb8 100644 --- a/src/EventLogExpert.UI/Menu/MenuBar.razor.cs +++ b/src/EventLogExpert.UI/Menu/MenuBar.razor.cs @@ -2,15 +2,16 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Common.Clipboard; -using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.Export; using EventLogExpert.Runtime.FilterPane; using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; +using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Runtime.Settings; using EventLogExpert.UI.Common; using EventLogExpert.UI.Common.Interop; @@ -34,6 +35,8 @@ public sealed partial class MenuBar private IJSObjectReference? _menuAnchorModule; private ElementReference _menuBarRootRef; private long _openRequestId; + private IReadOnlyDictionary _readinessByChannel = + new Dictionary(StringComparer.OrdinalIgnoreCase); private IJSObjectReference? _scrollSuppressorModule; [Inject] private IMenuActionService Actions { get; init; } = null!; @@ -42,11 +45,11 @@ public sealed partial class MenuBar [Inject] private IAlertDialogService AlertDialogService { get; init; } = null!; + [Inject] private IChannelReadinessService ChannelReadinessService { get; init; } = null!; + [Inject] private IStateSelection ContinuouslyUpdate { get; init; } = null!; - [Inject] private ICurrentVersionProvider CurrentVersionProvider { get; init; } = null!; - [Inject] private IStateSelection FilterPaneIsEnabled { get; init; } = null!; @@ -134,6 +137,11 @@ protected override void OnInitialized() base.OnInitialized(); } + private static string? ReadinessStatusText(ChannelReadiness readiness) => + readiness.Access == ChannelAccess.RequiresElevation + ? "(elevate)" + : readiness.Enablement == ChannelEnablement.Disabled ? "(disabled)" : null; + private IReadOnlyList BuildEdit() { var defaultCopyFormat = Settings.CopyFormat; @@ -186,8 +194,6 @@ private IReadOnlyList BuildHelp() => private IReadOnlyList BuildOpenSubMenu(bool combineLog) { - bool isAdmin = CurrentVersionProvider.IsAdmin; - return [ MenuItem.Item("File", () => Actions.OpenFileAsync(combineLog), combineLog ? null : "Ctrl+O"), @@ -195,36 +201,39 @@ private IReadOnlyList BuildOpenSubMenu(bool combineLog) MenuItem.SubMenu("Live", [ MenuItem.Item(LogChannelNames.ApplicationLog, - () => Actions.OpenLiveLogAsync(LogChannelNames.ApplicationLog, combineLog)), + () => Actions.OpenLiveLogAsync(LogChannelNames.ApplicationLog, combineLog), + statusText: ReadinessStatusText(LogChannelNames.ApplicationLog)), MenuItem.Item(LogChannelNames.SystemLog, - () => Actions.OpenLiveLogAsync(LogChannelNames.SystemLog, combineLog)), + () => Actions.OpenLiveLogAsync(LogChannelNames.SystemLog, combineLog), + statusText: ReadinessStatusText(LogChannelNames.SystemLog)), MenuItem.Item(LogChannelNames.SecurityLog, () => Actions.OpenLiveLogAsync(LogChannelNames.SecurityLog, combineLog), - isEnabled: isAdmin), + statusText: ReadinessStatusText(LogChannelNames.SecurityLog)), MenuItem.AsyncSubMenu( "Other Logs", - async () => BuildOtherLogsTree(await Actions.GetOtherLogNamesAsync(), combineLog, isAdmin)), + async () => BuildOtherLogsTree(await GetOtherLogReadinessAsync(), combineLog)), ]), ]; } - private IReadOnlyList BuildOtherLogsTree(IReadOnlyList logNames, bool combineLog, bool isAdmin) + private IReadOnlyList BuildOtherLogsTree( + IReadOnlyList channelReadiness, + bool combineLog) { var rootChildren = new List(); var folderMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var logName in logNames) + foreach (var readiness in channelReadiness) { + var logName = readiness.Channel; var path = LogChannelMethods.GetMenuPath(logName); if (path.Count == 0) { continue; } var log = path[^1]; - var logIsEnabled = isAdmin || !LogChannelNames.AdminOnlyLiveChannels.Contains(logName); - var logMenuItem = MenuItem.Item(log, () => Actions.OpenLiveLogAsync(logName, combineLog), - isEnabled: logIsEnabled); + statusText: ReadinessStatusText(readiness)); if (path.Count == 1) { @@ -330,6 +339,28 @@ private async Task ConfirmCloseAllLogsAsync() } } + private async Task> GetOtherLogReadinessAsync() + { + var snapshot = await ChannelReadinessService.GetReadinessAsync(); + var logNames = snapshot + .Select(channel => channel.Channel) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var readiness = await ChannelReadinessService.GetReadinessAsync(logNames); + + var readinessByChannel = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var channel in snapshot) { readinessByChannel[channel.Channel] = channel; } + foreach (var channel in readiness) { readinessByChannel[channel.Channel] = channel; } + + _readinessByChannel = readinessByChannel; + + return readiness + .OrderBy(channel => channel.Channel, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private void InvalidatePendingOpen() => Interlocked.Increment(ref _openRequestId); private bool IsActive(TopLevel bar) => @@ -466,9 +497,25 @@ private async Task OpenBarAsync(TopLevel bar, int index, bool focusFirst = true, private async Task PrewarmOtherLogNamesAsync() { - try { await Actions.GetOtherLogNamesAsync(); } - catch { /* prewarm best-effort */ } + try + { + var readiness = await ChannelReadinessService.GetReadinessAsync(); + _readinessByChannel = readiness.ToDictionary( + channel => channel.Channel, + StringComparer.OrdinalIgnoreCase); + + await InvokeAsync(StateHasChanged); + } + catch (Exception exception) + { + _ = exception; + } } + private string? ReadinessStatusText(string channel) => + _readinessByChannel.TryGetValue(channel, out var readiness) + ? ReadinessStatusText(readiness) + : null; + private sealed record TopLevel(string Label, Func> BuildItems); } diff --git a/src/EventLogExpert.UI/Menu/MenuRenderer.razor b/src/EventLogExpert.UI/Menu/MenuRenderer.razor index 9ca3177f..6cd00996 100644 --- a/src/EventLogExpert.UI/Menu/MenuRenderer.razor +++ b/src/EventLogExpert.UI/Menu/MenuRenderer.razor @@ -50,6 +50,11 @@ @item.Label + @if (!string.IsNullOrEmpty(item.StatusText)) + { + @item.StatusText + } + @if (hasReason) { @item.DisabledReason @@ -84,7 +89,7 @@ role="menuitem" tabindex="-1"> - Loading… + Loading... } diff --git a/src/EventLogExpert.UI/Menu/MenuRenderer.razor.css b/src/EventLogExpert.UI/Menu/MenuRenderer.razor.css index 5f75650f..626f39df 100644 --- a/src/EventLogExpert.UI/Menu/MenuRenderer.razor.css +++ b/src/EventLogExpert.UI/Menu/MenuRenderer.razor.css @@ -59,6 +59,13 @@ font-size: 0.9em; } +.menu-status { + flex: 0 0 auto; + margin-left: 6px; + color: var(--clr-yellow); + font-size: 0.85em; +} + .menu-arrow { flex: 0 0 auto; margin-left: 12px; diff --git a/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs b/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs index 063dc051..e22b8225 100644 --- a/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs +++ b/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs @@ -17,6 +17,7 @@ using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Modal; +using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Runtime.Settings; using EventLogExpert.Runtime.Update; using EventLogExpert.UI.DatabaseTools; @@ -75,7 +76,6 @@ public sealed class MauiMenuActionService( private readonly IFilterPaneCommands _filterPaneCommands = filterPaneCommands; private readonly IFolderPickerService _folderPickerService = folderPickerService; private readonly IHistogramCommands _histogramCommands = histogramCommands; - private readonly SemaphoreSlim _logNamesLock = new(1, 1); private readonly ILogTableCommands _logTableCommands = logTableCommands; private readonly IState _logTableState = logTableState; private readonly IModalCoordinator _modalCoordinator = modalCoordinator; @@ -83,7 +83,6 @@ public sealed class MauiMenuActionService( private readonly ITraceLogger _traceLogger = traceLogger; private readonly IUpdateService _updateService = updateService; - private IReadOnlyList? _cachedLogNames; private CancellationTokenSource _cancellationTokenSource = new(); private bool _disposed; private int _exportInFlight; @@ -125,7 +124,6 @@ public void Dispose() // Already disposed - continue tearing down remaining resources. } - _logNamesLock.Dispose(); _cancellationTokenSource.Dispose(); } @@ -199,7 +197,7 @@ await _dialogService.ShowAlert( // starting; dismissing the picker never reaches here, so it stays silent. The finished // flag turns a late Cancel click into a clean no-op. _exportProgress.Begin( - "Exporting events…", () => { if (!Volatile.Read(ref finished)) { cancellation.Cancel(); } }); + "Exporting events...", () => { if (!Volatile.Read(ref finished)) { cancellation.Cancel(); } }); await _eventTableExporter.ExportAsync( stream, format, events, columns, timeZone, includeDescription: true, token); @@ -263,30 +261,6 @@ await _dialogService.ShowAlert( } } - public async Task> GetOtherLogNamesAsync() - { - if (_cachedLogNames is not null) { return _cachedLogNames; } - - await _logNamesLock.WaitAsync(); - - try - { - if (_cachedLogNames is not null) { return _cachedLogNames; } - - _cachedLogNames = await Task.Run>(() => - EventLogSession.GlobalSession.GetLogNames() - .Where(name => !LogChannelMethods.HardCodedLiveChannels.Contains(name)) - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .ToList()); - - return _cachedLogNames; - } - finally - { - _logNamesLock.Release(); - } - } - public void LoadNewEvents() => _eventLogCommands.LoadNewEvents(); public Task OpenDatabaseToolsAsync() => @@ -356,72 +330,43 @@ public async Task OpenFolderAsync(bool combineLog) public Task OpenIssueAsync() => OpenBrowserAsync("https://github.com/microsoft/EventLogExpert/issues/new"); public Task OpenLiveLogAsync(string logName, bool combineLog) => - OpenLogsBatchCoreAsync([(logName, LogPathType.Channel)], combineLog); + OpenLogsBatchCoreAsync([(logName, LogPathType.Channel)], combineLog, showInlineAlerts: true); - public Task OpenLiveLogsAsync(IEnumerable logNames, bool combineLog) + public Task OpenLiveLogsAsync( + IEnumerable logNames, + bool combineLog, + bool showInlineAlerts = true) { ArgumentNullException.ThrowIfNull(logNames); - return OpenLogsBatchCoreAsync(logNames.Select(name => (name, LogPathType.Channel)), combineLog); + return OpenLogsBatchCoreAsync(logNames.Select(name => (name, LogPathType.Channel)), combineLog, showInlineAlerts); } public async Task OpenLogAsync(string logPath, LogPathType pathType, bool combineLog = false) { - if (string.IsNullOrWhiteSpace(logPath) || - (combineLog && _eventLogState.Value.IsLogOpen(logPath))) { return OpenLogStatus.Skipped; } - - EventLogInformation? eventLogInformation; + var outcome = await OpenLogWithOutcomeAsync(logPath, pathType, combineLog, showInlineAlerts: true); - try - { - eventLogInformation = EventLogSession.GlobalSession.GetLogInformation(logPath, pathType); - } - catch (UnauthorizedAccessException) - { - await _dialogService.ShowAlert( - "Log requires elevation", - "Please relaunch with \"Run as Administrator\" to open this log", - "Ok"); - - return OpenLogStatus.Failed; - } - catch (Exception ex) + return outcome.Outcome switch { - await _dialogService.ShowAlert("Failed to open Log", $"Exception: {ex.Message}", "Ok"); - - return OpenLogStatus.Failed; - } - - if (eventLogInformation.RecordCount is null or <= 0) - { - return OpenLogStatus.Empty; - } - - if (!combineLog) - { - await _cancellationTokenSource.CancelAsync(); - _dispatcher.Dispatch(new CloseAllLogsAction()); - } - - if (_cancellationTokenSource.IsCancellationRequested) - { - _cancellationTokenSource = new CancellationTokenSource(); - } - - _eventLogCommands.OpenLog(logPath, pathType, _cancellationTokenSource.Token); - - return OpenLogStatus.Opened; + ChannelLaunchOutcome.Opened => OpenLogStatus.Opened, + ChannelLaunchOutcome.Empty => OpenLogStatus.Empty, + ChannelLaunchOutcome.Skipped => OpenLogStatus.Skipped, + _ => OpenLogStatus.Failed + }; } - public Task OpenLogFilesAsync(IEnumerable filePaths, bool combineLog) + public Task OpenLogFilesAsync( + IEnumerable filePaths, + bool combineLog, + bool showInlineAlerts = true) { ArgumentNullException.ThrowIfNull(filePaths); - return OpenLogsBatchCoreAsync(filePaths.Select(path => (path, LogPathType.File)), combineLog); + return OpenLogsBatchCoreAsync(filePaths.Select(path => (path, LogPathType.File)), combineLog, showInlineAlerts); } public Task OpenLogsBatchAsync(IEnumerable<(string Path, LogPathType Type)> logs, bool combineLog) => - OpenLogsBatchCoreAsync(logs, combineLog); + OpenLogsBatchCoreAsync(logs, combineLog, showInlineAlerts: true); public Task OpenSettingsAsync() => TryOpenModalAsync(_modalCoordinator.OpenSettingsAsync, nameof(SettingsModal)); @@ -491,11 +436,13 @@ private async Task OpenBrowserAsync(string url) private async Task OpenLogsBatchCoreAsync( IEnumerable<(string Path, LogPathType Type)> logs, - bool combineLog) + bool combineLog, + bool showInlineAlerts) { ArgumentNullException.ThrowIfNull(logs); List? emptyDisplayNames = null; + List? channelOutcomes = null; var combineForCall = combineLog; var opened = 0; var failed = 0; @@ -503,21 +450,28 @@ private async Task OpenLogsBatchCoreAsync( foreach (var (path, type) in logs) { - // Only Opened consumed the close-existing semantics; Skipped/Failed/Empty did not, - // so combineForCall must NOT flip until a real open happens. - switch (await OpenLogAsync(path, type, combineForCall)) + var outcome = await OpenLogWithOutcomeAsync(path, type, combineForCall, showInlineAlerts); + + if (type == LogPathType.Channel) + { + (channelOutcomes ??= []).Add(outcome); + } + + switch (outcome.Outcome) { - case OpenLogStatus.Opened: + case ChannelLaunchOutcome.Opened: opened++; combineForCall = true; break; - case OpenLogStatus.Empty: + case ChannelLaunchOutcome.Empty: (emptyDisplayNames ??= []).Add(GetEmptyLogDisplayName(path, type)); break; - case OpenLogStatus.Failed: + case ChannelLaunchOutcome.AccessDenied: + case ChannelLaunchOutcome.NotPresent: + case ChannelLaunchOutcome.Failed: failed++; break; - case OpenLogStatus.Skipped: + case ChannelLaunchOutcome.Skipped: skipped++; break; } @@ -537,7 +491,83 @@ await _dialogService.ShowAlert( emptyDisplayNames?.Count ?? 0, failed, skipped, - emptyDisplayNames?.ToImmutableArray() ?? []); + emptyDisplayNames?.ToImmutableArray() ?? []) + { + ChannelOutcomes = channelOutcomes?.ToImmutableArray() ?? [] + }; + } + + private async Task OpenLogWithOutcomeAsync( + string logPath, + LogPathType pathType, + bool combineLog, + bool showInlineAlerts) + { + if (string.IsNullOrWhiteSpace(logPath) || + (combineLog && _eventLogState.Value.IsLogOpen(logPath))) + { + return new ChannelOutcome(logPath, ChannelLaunchOutcome.Skipped); + } + + EventLogInformation? eventLogInformation; + + try + { + eventLogInformation = EventLogSession.GlobalSession.GetLogInformation(logPath, pathType); + } + catch (UnauthorizedAccessException) + { + if (showInlineAlerts) + { + await _dialogService.ShowAlert( + "Log requires elevation", + "Please relaunch with \"Run as Administrator\" to open this log", + "Ok"); + } + + return new ChannelOutcome(logPath, ChannelLaunchOutcome.AccessDenied); + } + catch (FileNotFoundException) + { + if (showInlineAlerts) + { + await _dialogService.ShowAlert( + "Failed to open Log", + $"The log {logPath} was not found.", + "Ok"); + } + + return new ChannelOutcome(logPath, ChannelLaunchOutcome.NotPresent); + } + catch (Exception ex) + { + if (showInlineAlerts) + { + await _dialogService.ShowAlert("Failed to open Log", $"Exception: {ex.Message}", "Ok"); + } + + return new ChannelOutcome(logPath, ChannelLaunchOutcome.Failed); + } + + if (eventLogInformation.RecordCount is null or <= 0) + { + return new ChannelOutcome(logPath, ChannelLaunchOutcome.Empty); + } + + if (!combineLog) + { + await _cancellationTokenSource.CancelAsync(); + _dispatcher.Dispatch(new CloseAllLogsAction()); + } + + if (_cancellationTokenSource.IsCancellationRequested) + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + _eventLogCommands.OpenLog(logPath, pathType, _cancellationTokenSource.Token); + + return new ChannelOutcome(logPath, ChannelLaunchOutcome.Opened); } private async Task TryOpenModalAsync(Func>> open, string modalName) diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/ChannelAccessEvaluatorIntegrationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/ChannelAccessEvaluatorIntegrationTests.cs new file mode 100644 index 00000000..68802852 --- /dev/null +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/ChannelAccessEvaluatorIntegrationTests.cs @@ -0,0 +1,58 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Readers; +using System.Security.Principal; + +namespace EventLogExpert.Eventing.IntegrationTests.Readers; + +public sealed class ChannelAccessEvaluatorIntegrationTests +{ + [Fact] + public void EvaluateAccess_WhenSddlAllowsOnlyAdministratorsForNonAdmin_ReturnsRequiresElevation() + { + if (IsCurrentProcessElevated()) + { + Assert.Skip("This test requires a non-elevated process token."); + } + + using var native = new Win32ChannelAccessNative(); + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess("O:BAG:SYD:(A;;0x1;;;BA)", isSecurityChannel: false); + + Assert.Equal(ChannelAccess.RequiresElevation, access); + } + + [Fact] + public void EvaluateAccess_WhenSddlAllowsWorldRead_ReturnsAccessible() + { + using var native = new Win32ChannelAccessNative(); + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess("O:BAG:SYD:(A;;0x1;;;WD)", isSecurityChannel: false); + + Assert.Equal(ChannelAccess.Accessible, access); + } + + [Fact] + public void EvaluateAccess_WhenSddlDeniesEveryoneRead_ReturnsRequiresElevation() + { + using var native = new Win32ChannelAccessNative(); + var evaluator = new NativeChannelAccessEvaluator(native); + + // Real channel SDDLs grant specific hex rights (0x1), not generic (GR), and AccessCheck does not + // remap generic rights inside ACEs. An explicit world deny overrides the admin allow for any token. + var access = evaluator.EvaluateAccess("O:BAG:SYD:(D;;0x1;;;WD)(A;;0x1;;;BA)", isSecurityChannel: false); + + Assert.Equal(ChannelAccess.RequiresElevation, access); + } + + private static bool IsCurrentProcessElevated() + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + WindowsPrincipal principal = new(identity); + + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } +} diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/ChannelConfigReaderIntegrationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/ChannelConfigReaderIntegrationTests.cs new file mode 100644 index 00000000..dc709131 --- /dev/null +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/ChannelConfigReaderIntegrationTests.cs @@ -0,0 +1,20 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Readers; + +namespace EventLogExpert.Eventing.IntegrationTests.Readers; + +public sealed class ChannelConfigReaderIntegrationTests +{ + [Fact] + public void ReadConfig_WhenApplicationLogExists_ReturnsEnabled() + { + using EventLogChannelConfigReader reader = new(); + + var config = reader.ReadConfig(LogChannelNames.ApplicationLog); + + Assert.True(config.Enabled); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs index ea9cddc8..22d206ad 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs @@ -55,9 +55,14 @@ public void BucketTimeTicksByEventData_IsAllocationFreeOnSealedRows() // Warm: first scan resolves the schema and JITs. reader.BucketTimeTicksByEventData(rank, 0, long.MaxValue, 1, "LogonType", targetCodes, slotCounts, CancellationToken.None); - long before = GC.GetAllocatedBytesForCurrentThread(); - reader.BucketTimeTicksByEventData(rank, 0, long.MaxValue, 1, "LogonType", targetCodes, slotCounts, CancellationToken.None); - long delta = GC.GetAllocatedBytesForCurrentThread() - before; + long delta = long.MaxValue; + + for (int iteration = 0; iteration < 16; iteration++) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + reader.BucketTimeTicksByEventData(rank, 0, long.MaxValue, 1, "LogonType", targetCodes, slotCounts, CancellationToken.None); + delta = Math.Min(delta, GC.GetAllocatedBytesForCurrentThread() - before); + } // A single per-row byte would cost 8192; the fixed memo array is a few dozen bytes, so this proves the hot path is allocation-free. Assert.True(delta < 512, $"Per-row allocation detected: {delta} bytes over {events.Length} sealed rows."); @@ -118,9 +123,16 @@ public void BucketTimeTicksByEventDataHResult_IsAllocationFreeOnSealedRows() reader.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, targetCodes, slotCounts, CancellationToken.None); - long before = GC.GetAllocatedBytesForCurrentThread(); - reader.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, targetCodes, slotCounts, CancellationToken.None); - long delta = GC.GetAllocatedBytesForCurrentThread() - before; + // Take the minimum allocation delta across steady-state iterations so a one-off tiered-JIT/background-GC + // charge on a single call cannot flake the assertion; a real per-row allocation persists in every iteration. + long delta = long.MaxValue; + + for (int iteration = 0; iteration < 16; iteration++) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + reader.BucketTimeTicksByEventDataHResult(rank, 0, long.MaxValue, 1, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, targetCodes, slotCounts, CancellationToken.None); + delta = Math.Min(delta, GC.GetAllocatedBytesForCurrentThread() - before); + } // The eligible-index buffer is stack-allocated and the schema memo is a fixed per-scan array, so the per-row path is // allocation-free; a single per-row byte would cost 8192. @@ -215,10 +227,15 @@ public void CountEventDataHResults_IsAllocationFreeOnPendingRows() reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); - counts.Clear(); - long before = GC.GetAllocatedBytesForCurrentThread(); - reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); - long delta = GC.GetAllocatedBytesForCurrentThread() - before; + long delta = long.MaxValue; + + for (int iteration = 0; iteration < 16; iteration++) + { + counts.Clear(); + long before = GC.GetAllocatedBytesForCurrentThread(); + reader.CountEventDataHResults(rank, "errorCode", s_updateProviders, s_errorCodeUserDataPaths, counts, CancellationToken.None); + delta = Math.Min(delta, GC.GetAllocatedBytesForCurrentThread() - before); + } // The pending provider match uses a scan-local set built once, not a per-row enumerator (which cost 32 bytes x 3000). Assert.True(delta < 4096, $"Per-row allocation detected on pending rows: {delta} bytes over {events.Length} rows."); diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Readers/ChannelAccessEvaluatorTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Readers/ChannelAccessEvaluatorTests.cs new file mode 100644 index 00000000..bcc3acbd --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Readers/ChannelAccessEvaluatorTests.cs @@ -0,0 +1,254 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.Readers; +using Microsoft.Win32.SafeHandles; + +namespace EventLogExpert.Eventing.Tests.Readers; + +public sealed class ChannelAccessEvaluatorTests +{ + [Theory] + [InlineData(99u)] + [InlineData(uint.MaxValue)] + public void ConvertChannelType_WhenBoxedUInt32IsUndefined_ReturnsNull(uint raw) => + Assert.Null(EventLogChannelConfigPropertyReader.ConvertChannelType(raw)); + + [Theory] + [InlineData(0u, EvtChannelType.Admin)] + [InlineData(1u, EvtChannelType.Operational)] + [InlineData(2u, EvtChannelType.Analytic)] + [InlineData(3u, EvtChannelType.Debug)] + public void ConvertChannelType_WhenValueIsBoxedUInt32_ReturnsMatchingType(uint raw, EvtChannelType expected) => + Assert.Equal(expected, EventLogChannelConfigPropertyReader.ConvertChannelType(raw)); + + [Fact] + public void EvaluateAccess_WhenAccessCheckDeniesRead_ReturnsRequiresElevation() + { + var native = new FakeChannelAccessNative { AccessCheckStatus = false }; + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess("O:BAG:SYD:(A;;0x1;;;BA)", isSecurityChannel: false); + + Assert.Equal(ChannelAccess.RequiresElevation, access); + } + + [Fact] + public void EvaluateAccess_WhenAccessCheckFails_ReturnsUnknown() + { + var native = new FakeChannelAccessNative { AccessCheckReturns = false }; + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess("O:BAG:SYD:(A;;0x1;;;WD)", isSecurityChannel: false); + + Assert.Equal(ChannelAccess.Unknown, access); + } + + [Fact] + public void EvaluateAccess_WhenAccessCheckGrantsRead_ReturnsAccessible() + { + var native = new FakeChannelAccessNative { AccessCheckStatus = true }; + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess("O:BAG:SYD:(A;;0x1;;;WD)", isSecurityChannel: false); + + Assert.Equal(ChannelAccess.Accessible, access); + Assert.Equal(0x1u, native.DesiredAccess); + Assert.Equal(0x1u, native.GenericMapping.GenericRead); + Assert.Equal(1, native.FreeSecurityDescriptorCalls); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("D:(A;;0x1;;;WD)")] + public void EvaluateAccess_WhenSddlMissingOwnerOrGroup_ReturnsUnknown(string? sddl) + { + var native = new FakeChannelAccessNative(); + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess(sddl, isSecurityChannel: false); + + Assert.Equal(ChannelAccess.Unknown, access); + Assert.Equal(0, native.ConvertSecurityDescriptorCalls); + } + + [Fact] + public void EvaluateAccess_WhenSecurityDeniedAndPrivilegeDisabled_ReturnsRequiresElevation() + { + var native = new FakeChannelAccessNative + { + AccessCheckStatus = false, + PrivilegeCheckResult = false + }; + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess("O:BAG:SYD:(A;;0x1;;;BA)", isSecurityChannel: true); + + Assert.Equal(ChannelAccess.RequiresElevation, access); + } + + [Fact] + public void EvaluateAccess_WhenSecurityDeniedAndPrivilegeEnabled_ReturnsAccessible() + { + var native = new FakeChannelAccessNative + { + AccessCheckStatus = false, + PrivilegeCheckResult = true + }; + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess("O:BAG:SYD:(A;;0x1;;;BA)", isSecurityChannel: true); + + Assert.Equal(ChannelAccess.Accessible, access); + } + + [Fact] + public void EvaluateAccess_WhenSecurityPrivilegeCheckFails_ReturnsUnknown() + { + var native = new FakeChannelAccessNative + { + AccessCheckStatus = false, + PrivilegeCheckReturns = false + }; + var evaluator = new NativeChannelAccessEvaluator(native); + + var access = evaluator.EvaluateAccess("O:BAG:SYD:(A;;0x1;;;BA)", isSecurityChannel: true); + + Assert.Equal(ChannelAccess.Unknown, access); + } + + [Theory] + [InlineData(EvtChannelType.Analytic)] + [InlineData(EvtChannelType.Debug)] + public void ReadConfig_WhenChannelTypeIsAnalyticOrDebug_SkipsAccessEvaluation(EvtChannelType type) + { + var accessEvaluator = new FakeChannelAccessEvaluator(); + var reader = new EventLogChannelConfigReader( + new FakeChannelConfigPropertyReader(new ChannelConfigPropertySnapshot(true, "O:BAG:SYD:(A;;0x1;;;WD)", type)), + accessEvaluator); + + var config = reader.ReadConfig("Microsoft-Windows-Test/Analytic"); + + Assert.Equal(ChannelAccess.NotEvaluated, config.Access); + Assert.Equal(type, config.Type); + Assert.True(config.Enabled); + Assert.Equal(0, accessEvaluator.EvaluateAccessCalls); + } + + [Fact] + public void ReadConfig_WhenChannelTypeIsOperational_EvaluatesAccessAndPreservesType() + { + var accessEvaluator = new FakeChannelAccessEvaluator(); + var reader = new EventLogChannelConfigReader( + new FakeChannelConfigPropertyReader( + new ChannelConfigPropertySnapshot(true, "O:BAG:SYD:(A;;0x1;;;WD)", EvtChannelType.Operational)), + accessEvaluator); + + var config = reader.ReadConfig("Microsoft-Windows-Test/Operational"); + + Assert.Equal(EvtChannelType.Operational, config.Type); + Assert.Equal(ChannelAccess.Accessible, config.Access); + Assert.Equal(1, accessEvaluator.EvaluateAccessCalls); + } + + [Fact] + public void ReadConfig_WhenChannelTypeIsUnreadable_PreservesUnknownTypeAndSkipsAccessEvaluation() + { + var accessEvaluator = new FakeChannelAccessEvaluator(); + var reader = new EventLogChannelConfigReader( + new FakeChannelConfigPropertyReader(new ChannelConfigPropertySnapshot(null, null, null)), + accessEvaluator); + + var config = reader.ReadConfig("Microsoft-Windows-Test/Operational"); + + Assert.Null(config.Type); + Assert.Equal(ChannelAccess.Unknown, config.Access); + Assert.Equal(0, accessEvaluator.EvaluateAccessCalls); + } + + private sealed class FakeChannelAccessEvaluator : IChannelAccessEvaluator + { + internal int EvaluateAccessCalls { get; private set; } + + public ChannelAccess EvaluateAccess(string? sddl, bool isSecurityChannel) + { + EvaluateAccessCalls++; + + return ChannelAccess.Accessible; + } + } + + private sealed class FakeChannelAccessNative : IChannelAccessNative + { + private static readonly SafeAccessTokenHandle s_token = SafeAccessTokenHandle.InvalidHandle; + + internal bool AccessCheckReturns { get; init; } = true; + + internal bool AccessCheckStatus { get; init; } = true; + + internal int ConvertSecurityDescriptorCalls { get; private set; } + + internal uint DesiredAccess { get; private set; } + + internal int FreeSecurityDescriptorCalls { get; private set; } + + internal GenericMapping GenericMapping { get; private set; } + + internal bool PrivilegeCheckResult { get; init; } + + internal bool PrivilegeCheckReturns { get; init; } = true; + + public bool AccessCheck( + IntPtr securityDescriptor, + SafeAccessTokenHandle token, + uint desiredAccess, + ref GenericMapping genericMapping, + Span privilegeSet, + ref uint privilegeSetLength, + out uint grantedAccess, + out bool accessStatus) + { + DesiredAccess = desiredAccess; + GenericMapping = genericMapping; + grantedAccess = AccessCheckStatus ? desiredAccess : 0; + accessStatus = AccessCheckStatus; + + return AccessCheckReturns; + } + + public void Dispose() { } + + public void FreeSecurityDescriptor(IntPtr securityDescriptor) => FreeSecurityDescriptorCalls++; + + public void MapGenericMask(ref uint desiredAccess, ref GenericMapping genericMapping) { } + + public bool PrivilegeCheck(SafeAccessTokenHandle token, string privilegeName, out bool result) + { + result = PrivilegeCheckResult; + + return PrivilegeCheckReturns; + } + + public bool TryConvertSecurityDescriptor(string sddl, out IntPtr securityDescriptor) + { + ConvertSecurityDescriptorCalls++; + securityDescriptor = 123; + + return true; + } + + public bool TryGetImpersonationToken(out SafeAccessTokenHandle token) + { + token = s_token; + + return true; + } + } + + private sealed class FakeChannelConfigPropertyReader(ChannelConfigPropertySnapshot snapshot) : IChannelConfigPropertyReader + { + public ChannelConfigPropertySnapshot ReadProperties(string channelName) => snapshot; + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ChannelPresenceProbeTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ChannelPresenceProbeTests.cs index aef9a65b..0013a907 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ChannelPresenceProbeTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ChannelPresenceProbeTests.cs @@ -1,9 +1,12 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Logging.Abstractions; using EventLogExpert.Runtime.Scenarios; using NSubstitute; +using System.Collections.Immutable; namespace EventLogExpert.Runtime.Tests.Scenarios; @@ -14,7 +17,7 @@ public void FailedRead_DoesNotCacheEmpty_AndRetries() { var calls = 0; - var probe = new ChannelPresenceProbe(Logger(), () => + var probe = new ChannelPresenceProbe(Logger(), EnablementReader(), [], () => { calls++; @@ -29,12 +32,30 @@ public void FailedRead_DoesNotCacheEmpty_AndRetries() Assert.Equal(2, calls); } + [Fact] + public void GetPresentChannels_DoesNotEnrichChannelConfig() + { + var config = ConfigReader(new Dictionary + { + ["System"] = new(true, ChannelAccess.Accessible, EvtChannelType.Admin) + }); + var probe = new ChannelPresenceProbe( + Logger(), + config, + ["System"], + static () => ["System"]); + + _ = probe.GetPresentChannels(); + + config.DidNotReceive().ReadConfig(Arg.Any()); + } + [Fact] public void GetPresentChannels_ReadsOnceThenCaches() { var calls = 0; - var probe = new ChannelPresenceProbe(Logger(), () => + var probe = new ChannelPresenceProbe(Logger(), EnablementReader(), [], () => { calls++; @@ -47,10 +68,185 @@ public void GetPresentChannels_ReadsOnceThenCaches() Assert.Equal(1, calls); } + [Fact] + public async Task GetReadinessAsync_EnrichesUnprobedChannelsLazily() + { + var config = ConfigReader(new Dictionary + { + ["Security"] = new(false, ChannelAccess.RequiresElevation, EvtChannelType.Admin) + }); + var probe = new ChannelPresenceProbe( + Logger(), + config, + [], + static () => ["Security"]); + + var readiness = await probe.GetReadinessAsync(["Security"], TestContext.Current.CancellationToken); + + Assert.Equal(ChannelEnablement.Disabled, readiness.Single().Enablement); + Assert.Equal(ChannelAccess.RequiresElevation, readiness.Single().Access); + } + + [Fact] + public async Task GetReadinessAsync_HonorsCancellationDuringEnrichment() + { + using var cts = new CancellationTokenSource(); + var config = Substitute.For(); + config.ReadConfig("First").Returns(_ => + { + cts.Cancel(); + + return new ChannelConfig(true, ChannelAccess.Accessible, EvtChannelType.Admin); + }); + + var probe = new ChannelPresenceProbe(Logger(), config, [], static () => ["First", "Second"]); + + await Assert.ThrowsAnyAsync( + () => probe.GetReadinessAsync(["First", "Second"], cts.Token)); + + config.DidNotReceive().ReadConfig("Second"); + } + + [Fact] + public async Task GetReadinessAsync_HonorsCancellationDuringFinalConfigRead() + { + using var cts = new CancellationTokenSource(); + var config = Substitute.For(); + config.ReadConfig("Only").Returns(_ => + { + cts.Cancel(); + + return new ChannelConfig(true, ChannelAccess.Accessible, EvtChannelType.Admin); + }); + + var probe = new ChannelPresenceProbe(Logger(), config, [], static () => ["Only"]); + + await Assert.ThrowsAnyAsync( + () => probe.GetReadinessAsync(["Only"], cts.Token)); + } + + [Fact] + public async Task GetReadinessAsync_ParameterlessDoesNotEnrichNonCatalogChannels() + { + var config = ConfigReader(new Dictionary + { + ["System"] = new(true, ChannelAccess.Accessible, EvtChannelType.Admin), + ["Custom"] = new(false, ChannelAccess.RequiresElevation, EvtChannelType.Admin) + }); + var probe = new ChannelPresenceProbe( + Logger(), + config, + ["System"], + static () => ["System", "Custom"]); + + var allReadiness = await probe.GetReadinessAsync(TestContext.Current.CancellationToken); + + Assert.Contains( + new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.Accessible + }, + allReadiness); + Assert.Contains(new ChannelReadiness("Custom", ChannelPresence.Present, ChannelEnablement.Unknown), allReadiness); + config.Received(1).ReadConfig("System"); + config.DidNotReceive().ReadConfig("Custom"); + + var customReadiness = await probe.GetReadinessAsync(["Custom"], TestContext.Current.CancellationToken); + + Assert.Equal(ChannelEnablement.Disabled, customReadiness.Single().Enablement); + Assert.Equal(ChannelAccess.RequiresElevation, customReadiness.Single().Access); + config.Received(1).ReadConfig("Custom"); + } + + [Fact] + public async Task GetReadinessAsync_ReportsPresentAbsentAndEnablement() + { + var config = ConfigReader(new Dictionary + { + ["System"] = new(true, ChannelAccess.Accessible, EvtChannelType.Admin), + ["Application"] = new(false, ChannelAccess.RequiresElevation, EvtChannelType.Admin) + }); + + var probe = new ChannelPresenceProbe( + Logger(), + config, + ["System"], + static () => ["System"]); + + var readiness = await probe.GetReadinessAsync( + ["System", "Application", "Security"], + TestContext.Current.CancellationToken); + + Assert.Contains( + new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.Accessible + }, + readiness); + Assert.Contains( + new ChannelReadiness("Application", ChannelPresence.Absent, ChannelEnablement.Disabled) + { + Access = ChannelAccess.RequiresElevation + }, + readiness); + Assert.Contains(new ChannelReadiness("Security", ChannelPresence.Absent, ChannelEnablement.Unknown), readiness); + } + + [Fact] + public async Task GetReadinessAsync_WhenEnumerationFails_ReturnsUnknownPresence() + { + var probe = new ChannelPresenceProbe( + Logger(), + EnablementReader(), + [], + static () => throw new InvalidOperationException("event log service unavailable")); + + var readiness = await probe.GetReadinessAsync(["System"], TestContext.Current.CancellationToken); + + Assert.Equal(new ChannelReadiness("System", ChannelPresence.Unknown, ChannelEnablement.Unknown), readiness.Single()); + } + + [Fact] + public async Task GetReadinessAsync_WhenOneChannelConfigThrows_StillEnrichesOtherChannels() + { + var config = Substitute.For(); + config.ReadConfig("Faulting").Returns(_ => throw new InvalidOperationException("channel config read failed")); + config.ReadConfig("Healthy").Returns(new ChannelConfig(true, ChannelAccess.Accessible, EvtChannelType.Admin)); + + var probe = new ChannelPresenceProbe(Logger(), config, [], static () => ["Faulting", "Healthy"]); + + var readiness = await probe.GetReadinessAsync(["Faulting", "Healthy"], TestContext.Current.CancellationToken); + + var faulting = readiness.Single(item => item.Channel == "Faulting"); + var healthy = readiness.Single(item => item.Channel == "Healthy"); + + Assert.Equal(ChannelEnablement.Unknown, faulting.Enablement); + Assert.Equal(ChannelAccess.Unknown, faulting.Access); + Assert.Equal(ChannelEnablement.Enabled, healthy.Enablement); + Assert.Equal(ChannelAccess.Accessible, healthy.Access); + } + + [Fact] + public async Task Invalidate_ClearsCachedSnapshot() + { + var calls = 0; + var probe = new ChannelPresenceProbe(Logger(), EnablementReader(), [], () => + { + calls++; + return calls == 1 ? ["System"] : ["Application"]; + }); + + Assert.Contains("System", await Channels(probe)); + + probe.Invalidate(); + + Assert.Contains("Application", await Channels(probe)); + } + [Fact] public void IsPresent_IsCaseInsensitive() { - var probe = new ChannelPresenceProbe(Logger(), static () => ["System", "Security"]); + var probe = new ChannelPresenceProbe(Logger(), EnablementReader(), [], static () => ["System", "Security"]); Assert.True(probe.IsPresent("system")); Assert.True(probe.IsPresent("SECURITY")); @@ -62,6 +258,8 @@ public void TryGetPresentChannels_WhenReadFails_ReturnsNull() { var probe = new ChannelPresenceProbe( Logger(), + EnablementReader(), + [], static () => throw new InvalidOperationException("event log service unavailable")); Assert.Null(probe.TryGetPresentChannels()); @@ -70,7 +268,7 @@ public void TryGetPresentChannels_WhenReadFails_ReturnsNull() [Fact] public void TryGetPresentChannels_WhenReadSucceeds_ReturnsChannels() { - var probe = new ChannelPresenceProbe(Logger(), static () => ["System", "Security"]); + var probe = new ChannelPresenceProbe(Logger(), EnablementReader(), [], static () => ["System", "Security"]); var channels = probe.TryGetPresentChannels(); @@ -78,5 +276,25 @@ public void TryGetPresentChannels_WhenReadSucceeds_ReturnsChannels() Assert.Contains("System", channels); } + private static async Task> Channels(IChannelReadinessService service) => + [.. (await service.GetReadinessAsync(TestContext.Current.CancellationToken)).Select(readiness => readiness.Channel)]; + + private static IChannelConfigReader ConfigReader(IReadOnlyDictionary? values = null) + { + var reader = Substitute.For(); + reader.ReadConfig(Arg.Any()) + .Returns(call => + { + var channel = call.Arg()!; + return values is not null && values.TryGetValue(channel, out var config) + ? config + : ChannelConfig.Unknown; + }); + + return reader; + } + + private static IChannelConfigReader EnablementReader() => ConfigReader(); + private static ITraceLogger Logger() => Substitute.For(); } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/LivePresenceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/LivePresenceTests.cs new file mode 100644 index 00000000..998eb8f1 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/LivePresenceTests.cs @@ -0,0 +1,43 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Scenarios; + +namespace EventLogExpert.Runtime.Tests.Scenarios; + +public sealed class LivePresenceTests +{ + [Fact] + public void FromReadiness_ExcludesAbsentChannelsFromPresent() + { + var presence = LivePresence.FromReadiness( + [ + new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled), + new ChannelReadiness("Application", ChannelPresence.Absent, ChannelEnablement.Unknown) + ]); + + Assert.True(presence.Known); + Assert.Contains("System", presence.Present); + Assert.DoesNotContain("Application", presence.Present); + } + + [Fact] + public void FromReadiness_WhenAnyChannelUnknown_IsUnknownWithNoChannels() + { + var presence = LivePresence.FromReadiness( + [new ChannelReadiness("System", ChannelPresence.Unknown, ChannelEnablement.Unknown)]); + + Assert.False(presence.Known); + Assert.Empty(presence.Present); + } + + [Fact] + public void FromReadiness_WhenChannelsReadable_IsKnownWithPresentChannels() + { + var presence = LivePresence.FromReadiness( + [new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled)]); + + Assert.True(presence.Known); + Assert.Contains("System", presence.Present); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs index a0eea26f..693c2bb4 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs @@ -112,10 +112,61 @@ public async Task LaunchAsync_AppliesFiltersThenDate_BeforeOpening() { dispatcher.Dispatch(Arg.Any()); dispatcher.Dispatch(Arg.Any()); - _ = menu.OpenLiveLogsAsync(Arg.Any>(), false); + _ = menu.OpenLiveLogsAsync(Arg.Any>(), false, false); }); } + [Fact] + public async Task LaunchAsync_DegradedOptionalChannelFreshView_DoesNotRestore() + { + var scenario = ScenarioTestData.Single("a", "System", 1000) with + { + OptionalChannels = ["Security"] + }; + var registry = ScenarioTestData.Registry(scenario); + var menu = Substitute.For(); + menu.OpenLiveLogsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(new OpenLogsBatchResult(1, 0, 1, 0, []) + { + ChannelOutcomes = + [ + new ChannelOutcome("Security", ChannelLaunchOutcome.AccessDenied), + new ChannelOutcome("System", ChannelLaunchOutcome.Opened) + ] + }); + var dispatcher = Substitute.For(); + var service = CreateService(registry, menu, dispatcher: dispatcher); + + await service.LaunchAsync(scenario, dateWindow: null); + + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task LaunchAsync_DegradedRequiredChannelFreshView_RestoresAndSkipsTimeline() + { + var (service, dispatcher, menu, scenario) = Create( + new OpenLogsBatchResult(1, 0, 1, 0, []) + { + ChannelOutcomes = + [ + new ChannelOutcome("System", ChannelLaunchOutcome.AccessDenied), + new ChannelOutcome("Application", ChannelLaunchOutcome.Opened) + ] + }, + activatesTimeline: true); + + await service.LaunchAsync(scenario, dateWindow: null); + + Received.InOrder(() => + { + dispatcher.Dispatch(Arg.Any()); + dispatcher.Dispatch(Arg.Any()); + }); + menu.DidNotReceive().SetHistogramVisible(Arg.Any()); + } + [Fact] public async Task LaunchAsync_NonActivatingScenario_DoesNotShowTimeline() { @@ -136,6 +187,22 @@ public async Task LaunchAsync_NullWindow_ClearsDateFilter() dispatcher.Received().Dispatch(Arg.Is(action => action != null && action.DateFilter == null)); } + [Fact] + public async Task LaunchAsync_ProjectsChannelOutcomes() + { + var outcomes = new[] + { + new ChannelOutcome("System", ChannelLaunchOutcome.Opened), + new ChannelOutcome("Security", ChannelLaunchOutcome.AccessDenied) + }; + var (service, _, _, scenario) = Create( + new OpenLogsBatchResult(1, 0, 1, 0, []) { ChannelOutcomes = [.. outcomes] }); + + var result = await service.LaunchAsync(scenario, dateWindow: null, combineLog: true); + + Assert.Equal(outcomes, result.ChannelOutcomes); + } + [Fact] public async Task LaunchAsync_ReturnsOpenCounts() { @@ -168,6 +235,19 @@ public async Task LaunchAsync_SomethingOpened_DoesNotRestoreFilters() dispatcher.DidNotReceive().Dispatch(Arg.Any()); } + [Fact] + public async Task LaunchAsync_SuppressesInlineAlerts() + { + var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(0, 0, 1, 0, [])); + + await service.LaunchAsync(scenario, dateWindow: null); + + await menu.Received(1).OpenLiveLogsAsync( + Arg.Any>(), + combineLog: false, + showInlineAlerts: false); + } + [Fact] public async Task LaunchAsync_ZeroOpenButCombining_DoesNotDispatchCloseAll() { @@ -219,7 +299,7 @@ public async Task LaunchAsync_ZeroOpenFreshView_RestoresFilterStateCapturedBefor var registry = ScenarioTestData.Registry(scenario); var menu = Substitute.For(); - menu.OpenLiveLogsAsync(Arg.Any>(), Arg.Any()) + menu.OpenLiveLogsAsync(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(new OpenLogsBatchResult(0, 1, 0, 0, ["System"])); var priorState = new FilterPaneState { Filters = [FilterBuilder.CreateTestFilter(isEnabled: false)] }; @@ -256,7 +336,7 @@ public async Task LaunchFromFolderAsync_ActivatingScenarioWhenHidden_ShowsTimeli picker.PickFolderAsync().Returns("C:\\bundle"); enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); @@ -337,7 +417,7 @@ public async Task LaunchFromFolderAsync_ActivatingScenarioWithoutTimelineDimensi picker.PickFolderAsync().Returns("C:\\bundle"); enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); @@ -356,7 +436,7 @@ public async Task LaunchFromFolderAsync_ActivatingScenarioWithTimelineDimension_ picker.PickFolderAsync().Returns("C:\\bundle"); enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); @@ -378,7 +458,7 @@ public async Task LaunchFromFolderAsync_ActivatingScenarioWithTimelineDimension_ picker.PickFolderAsync().Returns("C:\\bundle"); enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); @@ -395,7 +475,7 @@ public async Task LaunchFromFolderAsync_CompletedWithUnreadableFile_PlumbsUnread .Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx", "C:\\bundle\\bad.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); reader.ReadChannel("C:\\bundle\\bad.evtx").Returns(EvtxChannelReadResult.Unreadable); - menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); @@ -414,7 +494,7 @@ public async Task LaunchFromFolderAsync_EmptyFolder_ReturnsNoMatchingLogs() var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.NoMatchingLogs, result.Outcome); - await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any(), Arg.Any()); } [Fact] @@ -437,7 +517,7 @@ public async Task LaunchFromFolderAsync_MatchedButNoneOpened_RollsBackAndReturns picker.PickFolderAsync().Returns("C:\\bundle"); enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(0, 1, 0, 0, [])); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(0, 1, 0, 0, [])); var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); @@ -454,7 +534,7 @@ public async Task LaunchFromFolderAsync_MatchOpened_ReturnsCompletedAndAppliesFi picker.PickFolderAsync().Returns("C:\\bundle"); enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); - menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + menu.OpenLogFilesAsync(Arg.Any>(), false, false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); @@ -469,7 +549,9 @@ public async Task LaunchFromFolderAsync_MatchOpened_ReturnsCompletedAndAppliesFi }); await menu.Received(1).OpenLogFilesAsync( - Arg.Is>(paths => paths != null && paths.Single() == "C:\\bundle\\System.evtx"), false); + Arg.Is>(paths => paths != null && paths.Single() == "C:\\bundle\\System.evtx"), + combineLog: false, + showInlineAlerts: false); } [Fact] @@ -484,7 +566,7 @@ public async Task LaunchFromFolderAsync_NoChannelMatch_ReturnsNoMatchingLogsWith Assert.Equal(ScenarioFolderOutcome.NoMatchingLogs, result.Outcome); Assert.Contains("System", result.MissingChannels); - await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any(), Arg.Any()); } [Fact] @@ -510,7 +592,7 @@ public async Task LaunchFromFolderAsync_PickerCancelled_ReturnsCancelledAndTouch var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.Cancelled, result.Outcome); - await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any(), Arg.Any()); dispatcher.DidNotReceive().Dispatch(Arg.Any()); } @@ -571,21 +653,10 @@ private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuAct var registry = ScenarioTestData.Registry(scenario); var menu = Substitute.For(); - menu.OpenLiveLogsAsync(Arg.Any>(), Arg.Any()).Returns(openResult); - - var filterPaneState = Substitute.For>(); - filterPaneState.Value.Returns(new FilterPaneState()); - - var histogramState = Substitute.For>(); - histogramState.Value.Returns(new HistogramState { IsVisible = timelineVisible }); - - var folderPicker = Substitute.For(); - var folderEnumerator = Substitute.For(); - var channelReader = Substitute.For(); + menu.OpenLiveLogsAsync(Arg.Any>(), Arg.Any(), Arg.Any()).Returns(openResult); var dispatcher = Substitute.For(); - var service = new ScenarioLaunchService( - registry, menu, filterPaneState, histogramState, folderPicker, folderEnumerator, channelReader, dispatcher); + var service = CreateService(registry, menu, timelineVisible, dispatcher); return (service, dispatcher, menu, scenario); } @@ -616,5 +687,32 @@ private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuAct return (service, dispatcher, menu, folderPicker, enumerator, channelReader, scenario); } + private static IScenarioLaunchService CreateService( + BuiltInScenarioRegistry registry, + IMenuActionService menu, + bool timelineVisible = false, + IDispatcher? dispatcher = null) + { + var filterPaneState = Substitute.For>(); + filterPaneState.Value.Returns(new FilterPaneState()); + + var histogramState = Substitute.For>(); + histogramState.Value.Returns(new HistogramState { IsVisible = timelineVisible }); + + var folderPicker = Substitute.For(); + var folderEnumerator = Substitute.For(); + var channelReader = Substitute.For(); + + return new ScenarioLaunchService( + registry, + menu, + filterPaneState, + histogramState, + folderPicker, + folderEnumerator, + channelReader, + dispatcher ?? Substitute.For()); + } + private static ScenarioDefinition FolderScenario() => ScenarioTestData.Single("a", "System", 1000); } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioQueryServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioQueryServiceTests.cs index 00e903ed..21832d77 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioQueryServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioQueryServiceTests.cs @@ -3,7 +3,6 @@ using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Scenarios.Catalog; -using NSubstitute; namespace EventLogExpert.Runtime.Tests.Scenarios; @@ -14,7 +13,7 @@ public void GetInAppScenarios_ExcludesScenario_WhenNoChannelLoaded() { var registry = ScenarioTestData.Registry(ScenarioTestData.Single("system", "System", 1000)); - var service = new ScenarioQueryService(registry, Substitute.For()); + var service = new ScenarioQueryService(registry); Assert.Empty(service.GetInAppScenarios(["Application"])); } @@ -26,7 +25,7 @@ public void GetInAppScenarios_MatchesLoadedLogName_CaseInsensitive() ScenarioTestData.Single("system", "System", 1000), ScenarioTestData.Single("application", "Application", 1001)); - var service = new ScenarioQueryService(registry, Substitute.For()); + var service = new ScenarioQueryService(registry); Assert.Equal(["system"], service.GetInAppScenarios(["system"]).Select(scenario => scenario.Id)); } @@ -36,39 +35,11 @@ public void GetInAppScenarios_ShowsCombinedScenario_WhenAnyChannelLoaded() { var registry = ScenarioTestData.Registry(ScenarioTestData.Combined("combined", "System", "Security")); - var service = new ScenarioQueryService(registry, Substitute.For()); + var service = new ScenarioQueryService(registry); Assert.Single(service.GetInAppScenarios(["Security"])); } - [Fact] - public void GetLivePresence_WhenChannelsReadable_IsKnownWithChannels() - { - var probe = Substitute.For(); - probe.TryGetPresentChannels().Returns(new HashSet(StringComparer.OrdinalIgnoreCase) { "System" }); - - var service = new ScenarioQueryService(ScenarioTestData.Registry(), probe); - - var presence = service.GetLivePresence(); - - Assert.True(presence.Known); - Assert.Contains("System", presence.Present); - } - - [Fact] - public void GetLivePresence_WhenChannelsUnreadable_IsUnknown() - { - var probe = Substitute.For(); - probe.TryGetPresentChannels().Returns((IReadOnlySet?)null); - - var service = new ScenarioQueryService(ScenarioTestData.Registry(), probe); - - var presence = service.GetLivePresence(); - - Assert.False(presence.Known); - Assert.Empty(presence.Present); - } - [Fact] public void GetSplashScenarios_ExcludesNonChannelPresenceGating() { @@ -76,7 +47,7 @@ public void GetSplashScenarios_ExcludesNonChannelPresenceGating() ScenarioTestData.Single("channel-gated", "System", 1000), ScenarioTestData.Single("source-gated", "Application", 1001) with { Gating = ScenarioGating.SourceRegistration }); - var service = new ScenarioQueryService(registry, Substitute.For()); + var service = new ScenarioQueryService(registry); Assert.Equal(["channel-gated"], service.GetSplashScenarios().Select(scenario => scenario.Id)); } @@ -84,14 +55,11 @@ public void GetSplashScenarios_ExcludesNonChannelPresenceGating() [Fact] public void GetSplashScenarios_ReturnsAllChannelPresenceScenarios_RegardlessOfLocalAvailability() { - var probe = Substitute.For(); - probe.TryGetPresentChannels().Returns(new HashSet(StringComparer.OrdinalIgnoreCase) { "System" }); - var registry = ScenarioTestData.Registry( ScenarioTestData.Single("present", "System", 1000), ScenarioTestData.Single("absent", "Microsoft-Windows-DNS-Client/Operational", 1014)); - var service = new ScenarioQueryService(registry, probe); + var service = new ScenarioQueryService(registry); var ids = service.GetSplashScenarios().Select(scenario => scenario.Id).ToHashSet(); @@ -99,4 +67,4 @@ public void GetSplashScenarios_ReturnsAllChannelPresenceScenarios_RegardlessOfLo Assert.Contains("absent", ids); Assert.Equal(2, ids.Count); } -} +} \ No newline at end of file diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs index 8184157e..4f32b559 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs @@ -3,10 +3,10 @@ using AngleSharp.Dom; using Bunit; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Filtering.Evaluation; using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Announcement; -using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterPane; using EventLogExpert.Runtime.Menu; @@ -32,6 +32,7 @@ public sealed class EmptyStateDashboardTests : BunitContext private readonly IMenuActionService _actions = Substitute.For(); private readonly IAlertDialogService _alertDialog = Substitute.For(); private readonly IAnnouncementService _announcer = Substitute.For(); + private readonly IChannelReadinessService _channelReadinessService = Substitute.For(); private readonly IScenarioFavoriteCommands _favoriteCommands = Substitute.For(); private readonly IState _favorites = Substitute.For>(); private readonly IStateSelection> _favoritesSelection = @@ -41,15 +42,20 @@ public sealed class EmptyStateDashboardTests : BunitContext private readonly IFilterPaneCommands _filterCommands = Substitute.For(); private readonly IScenarioLaunchService _scenarioLaunch = Substitute.For(); private readonly IScenarioQueryService _scenarioQuery = Substitute.For(); - private readonly ICurrentVersionProvider _version = Substitute.For(); public EmptyStateDashboardTests() { _scenarioLaunch.LaunchAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new ScenarioLaunchResult(1, 0, 0)); _scenarioQuery.GetSplashScenarios().Returns([]); - _scenarioQuery.GetLivePresence() - .Returns(new LivePresence(true, new HashSet(StringComparer.OrdinalIgnoreCase) { "System" })); + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns( + [ + new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.Accessible + } + ]); _favorites.Value.Returns(new ScenarioFavoritesState()); _favoritesSelection.Value.Returns(ImmutableHashSet.Empty); @@ -63,7 +69,7 @@ public EmptyStateDashboardTests() Services.AddSingleton(_filterCommands); Services.AddSingleton(_scenarioLaunch); Services.AddSingleton(_scenarioQuery); - Services.AddSingleton(_version); + Services.AddSingleton(_channelReadinessService); Services.AddFluxor(options => options.ScanAssemblies(typeof(EmptyStateDashboard).Assembly)); JSInterop.Mode = JSRuntimeMode.Loose; } @@ -129,18 +135,82 @@ public void DetailLaunch_InvokesScenarioLaunchAndAnnounces() }); } + [Fact] + public void DetailLaunch_WhenAccessDenied_ShowsFolderFallbackAction() + { + _scenarioLaunch.LaunchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ScenarioLaunchResult(1, 0, 1) + { + ChannelOutcomes = [new ChannelOutcome("System", ChannelLaunchOutcome.AccessDenied)] + }); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); + + cut.Find(ActiveDetailLaunch).Click(); + + cut.WaitForAssertion(() => _alertDialog.Received(1).ShowErrorAlert( + "Launch scenario", + Arg.Is(message => message != null && message.Contains("access denied")), + "Open from folder", + Arg.Any>())); + } + + [Fact] + public void DetailLaunch_WhenChannelAccessDenied_IsDisabledWithBlockedNote() + { + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns( + [ + new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.RequiresElevation + } + ]); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); + + Assert.Equal("true", cut.Find(ActiveDetailLaunch).GetAttribute("aria-disabled")); + var blockedNote = cut.Find(".sidebar-tabs-tabpanel.active .scenario-detail__unavailable"); + Assert.Contains("blocked", blockedNote.TextContent, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DetailLaunch_WhenChannelAccessNotEvaluated_StaysLaunchable() + { + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns( + [ + new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.NotEvaluated + } + ]); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); + + Assert.Equal("false", cut.Find(ActiveDetailLaunch).GetAttribute("aria-disabled")); + Assert.Empty(cut.FindAll(".sidebar-tabs-tabpanel.active .scenario-detail__unavailable")); + } + [Fact] public void DetailLaunch_WhenChannelNotOnHost_IsDisabledWithOfflineNote() { - _scenarioQuery.GetLivePresence() - .Returns(new LivePresence(true, new HashSet(StringComparer.OrdinalIgnoreCase))); + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns([new ChannelReadiness("System", ChannelPresence.Absent, ChannelEnablement.Unknown)]); _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); var cut = Render(); cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); Assert.Equal("true", cut.Find(ActiveDetailLaunch).GetAttribute("aria-disabled")); - Assert.NotEmpty(cut.FindAll(".sidebar-tabs-tabpanel.active .scenario-detail__unavailable")); + var offlineNote = cut.Find(".sidebar-tabs-tabpanel.active .scenario-detail__unavailable"); + Assert.Contains("not on this computer", offlineNote.TextContent, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -159,11 +229,35 @@ public void DetailLaunch_WhenLaunchOpensNothing_AnnouncesFailure() _announcer.Received(1).Announce(Arg.Is(message => message != null && message.Contains("No channels")))); } + [Fact] + public void DetailLaunch_WhenOneRequiredChannelMissing_IsDisabledWithOfflineNote() + { + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns( + [ + new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.Accessible + } + ]); + _scenarioQuery.GetSplashScenarios().Returns( + [ + Scenario("application-crashes", "Application crashes") with { Channels = ["System", "Missing"] } + ]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); + + Assert.Equal("true", cut.Find(ActiveDetailLaunch).GetAttribute("aria-disabled")); + var note = cut.Find(".sidebar-tabs-tabpanel.active .scenario-detail__unavailable"); + Assert.Contains("not on this computer", note.TextContent, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void DetailLaunch_WhenPresenceUnknown_StaysLaunchable() { - _scenarioQuery.GetLivePresence() - .Returns(new LivePresence(false, new HashSet(StringComparer.OrdinalIgnoreCase))); + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns([new ChannelReadiness("System", ChannelPresence.Unknown, ChannelEnablement.Unknown)]); _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); var cut = Render(); @@ -179,6 +273,20 @@ public void DetailLaunch_WhenPresenceUnknown_StaysLaunchable() .LaunchAsync(Arg.Is(scenario => scenario != null && scenario.Id == "application-crashes"), null)); } + [Fact] + public void DetailLaunch_WhenScenarioRequiresAdminButLivePresent_StaysLaunchable() + { + _scenarioQuery.GetSplashScenarios().Returns( + [ + Scenario("security", "Security", requiresAdmin: true) + ]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); + + Assert.Equal("false", cut.Find(ActiveDetailLaunch).GetAttribute("aria-disabled")); + } + [Fact] public void DetailOpenFromFolder_WhenCompleted_AnnouncesWithoutAlert() { @@ -228,6 +336,40 @@ public void DetailOpenFromFolder_WhenNoMatchingLogs_ShowsVisibleAlert() cut.WaitForAssertion(() => _alertDialog.Received(1).ShowAlert("Open from folder", Arg.Any(), "OK")); } + [Fact] + public void DetailReadiness_WhenChannelDisabled_ShowsDisabledStatus() + { + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns([new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Disabled)]); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + + cut.WaitForAssertion(() => + Assert.Contains("Disabled", cut.Find(".sidebar-tabs-tabpanel.active .scenario-detail__channel-readiness").TextContent)); + } + + [Fact] + public void DetailReadiness_WhenChannelRequiresElevation_ShowsAccessStatus() + { + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns( + [ + new ChannelReadiness("System", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.RequiresElevation + } + ]); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + + cut.WaitForAssertion(() => + Assert.Contains( + "Access denied (Needs elevation)", + cut.Find(".sidebar-tabs-tabpanel.active .scenario-detail__channel-readiness").TextContent)); + } + [Fact] public void DetailStar_TogglesFavorite() { @@ -295,7 +437,8 @@ public void QuickLaunch_OpenApplicationAndSystem_InvokesCombineOpen() cut.WaitForAssertion(() => _actions.Received(1).OpenLiveLogsAsync( Arg.Is>(channels => channels != null && channels.SequenceEqual(new[] { "Application", "System" })), - false)); + false, + showInlineAlerts: true)); } [Fact] @@ -335,28 +478,45 @@ public void QuickLaunch_WhileBusy_DisablesOtherButtons() } [Fact] - public void Security_WhenAdmin_InvokesOpen() + public void Security_WhenAccessDenied_IsAriaDisabledWithReason() { - _version.IsAdmin.Returns(true); + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns( + [ + new ChannelReadiness("Security", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.RequiresElevation + } + ]); var cut = Render(); - FindLaunch(cut, "Open Security (live)").Click(); - cut.WaitForAssertion(() => _actions.Received(1).OpenLiveLogAsync("Security", false)); + cut.WaitForAssertion(() => + { + var security = FindLaunch(cut, "Open Security (live)"); + Assert.Equal("true", security.GetAttribute("aria-disabled")); + var reasonId = security.GetAttribute("aria-describedby"); + Assert.False(string.IsNullOrEmpty(reasonId)); + Assert.Contains("Access denied", cut.Find($"#{reasonId}").TextContent, StringComparison.OrdinalIgnoreCase); + }); } [Fact] - public void Security_WhenNotAdmin_IsAriaDisabledWithReason() + public void Security_WhenAccessible_InvokesOpen() { - _version.IsAdmin.Returns(false); + _channelReadinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns( + [ + new ChannelReadiness("Security", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.Accessible + } + ]); var cut = Render(); - var security = FindLaunch(cut, "Open Security (live)"); - var reasonId = security.GetAttribute("aria-describedby"); + FindLaunch(cut, "Open Security (live)").Click(); - Assert.Equal("true", security.GetAttribute("aria-disabled")); - Assert.False(string.IsNullOrEmpty(reasonId)); - Assert.Contains("administrator", cut.Find($"#{reasonId}").TextContent, StringComparison.OrdinalIgnoreCase); + cut.WaitForAssertion(() => _actions.Received(1).OpenLiveLogAsync("Security", false)); } [Fact] diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs index 530153b4..6b29fed3 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs @@ -2,9 +2,11 @@ // // Licensed under the MIT License. using Bunit; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Filtering.Basic; using EventLogExpert.Filtering.Common.Filtering; using EventLogExpert.Filtering.Persistence; +using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Scenarios.Catalog; using EventLogExpert.UI.Dashboard; @@ -15,27 +17,29 @@ public sealed class ScenarioDetailTests : BunitContext public ScenarioDetailTests() => JSInterop.Mode = JSRuntimeMode.Loose; [Fact] - public void AdminBadge_WhenNotRequiresAdmin_IsAbsent() + public void AdminBadge_WhenRequiresAdmin_IsAbsent() { var cut = Render(parameters => parameters - .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes"))); + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes") with + { + RequiresAdmin = true + })); Assert.Empty(cut.FindAll(".scenario-detail__admin-badge")); + Assert.DoesNotContain("administrator", cut.Markup, StringComparison.OrdinalIgnoreCase); } [Fact] - public void AdminBadge_WhenRequiresAdmin_IsRendered() + public void BlockedNote_WhenDisabledAndLivePresent_IsShown() { var cut = Render(parameters => parameters - .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes") with - { - RequiresAdmin = true - })); - - var badge = cut.Find(".scenario-detail__admin-badge"); + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) + .Add(detail => detail.IsDisabled, true) + .Add(detail => detail.IsLivePresent, true)); - Assert.Contains("Live launch requires administrator", badge.TextContent); - Assert.NotEmpty(cut.FindAll(".scenario-detail__admin-badge .bi-shield-lock")); + var note = cut.Find(".scenario-detail__unavailable"); + Assert.Contains("blocked", note.TextContent, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Open from folder", note.TextContent); } [Fact] @@ -47,9 +51,26 @@ public void Facts_ShowsRequiredChannels() Channels = ["Application", "System"] })); - var values = cut.FindAll(".scenario-detail__fact-value"); + var values = cut.FindAll(".scenario-detail__channel-readiness .scenario-detail__fact-value"); - Assert.Contains(values, value => value.TextContent == "Application, System"); + Assert.Contains(values, value => value.TextContent == "Application"); + Assert.Contains(values, value => value.TextContent == "System"); + } + + [Fact] + public void Facts_WhenAccessRequiresElevation_ShowsAccessStatus() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("security", "Security") with { Channels = ["Security"] }) + .Add(detail => detail.ChannelReadiness, + [ + new ChannelReadiness("Security", ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.RequiresElevation + } + ])); + + Assert.Contains("Access denied (Needs elevation)", cut.Find(".scenario-detail__channel-readiness").TextContent); } [Fact] @@ -61,6 +82,26 @@ public void Facts_WhenNoOptionalChannels_OmitsAlsoIfPresentLine() Assert.Empty(cut.FindAll(".scenario-detail__fact-muted")); } + [Fact] + public void Facts_WhenOptionalChannelReadinessProvided_ShowsOptionalStatus() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes") with + { + Channels = ["Application"], + OptionalChannels = ["Security"] + }) + .Add(detail => detail.OptionalChannelReadiness, + [ + new ChannelReadiness("Security", ChannelPresence.Absent, ChannelEnablement.Unknown) + ])); + + var optional = cut.Find(".scenario-detail__channel-readiness--optional"); + + Assert.Contains("Security", optional.TextContent); + Assert.Contains("Not present", optional.TextContent); + } + [Fact] public void Facts_WhenOptionalChannelsPresent_ShowsAlsoIfPresentLine() { @@ -71,9 +112,11 @@ public void Facts_WhenOptionalChannelsPresent_ShowsAlsoIfPresentLine() OptionalChannels = ["Setup", "Security"] })); - Assert.Equal( - "Also if present: Setup, Security", - cut.Find(".scenario-detail__fact-muted").TextContent); + Assert.Equal("Also if present:", cut.Find(".scenario-detail__fact-muted").TextContent); + + var optional = cut.FindAll(".scenario-detail__channel-readiness--optional .scenario-detail__fact-value"); + Assert.Contains(optional, value => value.TextContent == "Setup"); + Assert.Contains(optional, value => value.TextContent == "Security"); } [Fact] @@ -152,7 +195,7 @@ public void Filters_WhenTryFormatFails_RowIsSkipped() } [Fact] - public void Launch_WhenAdminGated_IsGuardedAndDescribesAdminBadge() + public void Launch_WhenDisabledAndLivePresent_IsGuardedWithBlockedNote() { bool launched = false; @@ -165,12 +208,11 @@ public void Launch_WhenAdminGated_IsGuardedAndDescribesAdminBadge() .Add(detail => detail.OnLaunch, () => launched = true)); var launch = cut.Find(".scenario-detail__launch"); - var describedBy = launch.GetAttribute("aria-describedby"); + var note = cut.Find(".scenario-detail__unavailable"); Assert.Equal("true", launch.GetAttribute("aria-disabled")); - Assert.False(string.IsNullOrEmpty(describedBy)); - Assert.Contains("administrator", cut.Find($"#{describedBy}").TextContent, StringComparison.OrdinalIgnoreCase); - Assert.Empty(cut.FindAll(".scenario-detail__unavailable")); + Assert.Equal(note.Id, launch.GetAttribute("aria-describedby")); + Assert.Contains("Open from folder", note.TextContent); launch.Click(); @@ -215,17 +257,6 @@ public void Launch_WhenOffline_IsGuardedAndDescribesUnavailableNote() Assert.False(launched); } - [Fact] - public void OfflineNote_WhenLivePresent_IsAbsent() - { - var cut = Render(parameters => parameters - .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.IsDisabled, true) - .Add(detail => detail.IsLivePresent, true)); - - Assert.Empty(cut.FindAll(".scenario-detail__unavailable")); - } - [Fact] public void OpenFromFolder_Click_InvokesLaunchFromFolder() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuBarGroupingTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuBarGroupingTests.cs index c6403db9..109e3418 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuBarGroupingTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuBarGroupingTests.cs @@ -2,7 +2,9 @@ // // Licensed under the MIT License. using Bunit; +using EventLogExpert.Eventing.Common.Channels; using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.EventLog; @@ -10,12 +12,14 @@ using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; +using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Runtime.Settings; using EventLogExpert.UI.Menu; using Fluxor; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using System.Collections.Immutable; namespace EventLogExpert.UI.Tests.Menu; @@ -30,6 +34,7 @@ public sealed class MenuBarGroupingTests : BunitContext private readonly IStateSelection _histogramVisible = Substitute.For>(); private readonly List> _logTableSelections = []; private readonly IMenuService _menuService = Substitute.For(); + private readonly IChannelReadinessService _readinessService = Substitute.For(); private readonly ISettingsService _settings = Substitute.For(); private readonly ICurrentVersionProvider _versionProvider = Substitute.For(); @@ -44,6 +49,7 @@ public MenuBarGroupingTests() Services.AddSingleton(_histogramVisible); Services.AddTransient>(_ => CreateLogTableSelection()); Services.AddSingleton(_menuService); + Services.AddSingleton(_readinessService); Services.AddSingleton(_settings); Services.AddSingleton(_versionProvider); @@ -53,6 +59,9 @@ public MenuBarGroupingTests() JSInterop.SetupModule("./_content/EventLogExpert.UI/Menu/MenuAnchor.js") .Setup("getMenuElementRect", _ => true) .SetResult(new MenuAnchorRect(0, 0, 0, 0, 0, 0)); + _readinessService.GetReadinessAsync(Arg.Any()).Returns([]); + _readinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns(call => ReadinessFor(call.Arg>()!)); } [Fact] @@ -85,6 +94,58 @@ public async Task File_CloseAll_ConfirmCancelled_DoesNotInvokeCloseAllLogs() await _actions.DidNotReceive().CloseAllLogsAsync(); } + [Fact] + public async Task File_LiveSecurity_WhenRequiresElevation_RendersStatusAndStaysClickable() + { + SetReadiness( + new ChannelReadiness(LogChannelNames.SecurityLog, ChannelPresence.Present, ChannelEnablement.Enabled) + { + Access = ChannelAccess.RequiresElevation + }); + + var live = LiveItems(await OpenMenu("File")); + var security = Item(live, LogChannelNames.SecurityLog); + + Assert.True(security.IsEnabled); + Assert.Equal("(elevate)", security.StatusText); + } + + [Fact] + public async Task File_OtherLogs_WhenChannelDisabled_RendersStatusAndStaysClickable() + { + const string channel = "Microsoft-Windows-Test/Operational"; + SetReadiness(new ChannelReadiness(channel, ChannelPresence.Present, ChannelEnablement.Disabled)); + var live = LiveItems(await OpenMenu("File")); + + var otherLogs = Item(live, "Other Logs"); + var children = await otherLogs.ChildrenLoader!(); + var testFolder = Item(Item(Item(children, "Microsoft").Children!, "Windows").Children!, "Test"); + var operational = Item(testFolder.Children!, "Operational"); + + Assert.True(operational.IsEnabled); + Assert.Equal("(disabled)", operational.StatusText); + + await operational.OnClickAsync!(); + + await _actions.Received(1).OpenLiveLogAsync(channel, false); + } + + [Fact] + public async Task File_OtherLogs_WhenStateRequiresElevation_RendersStatusAndStaysClickable() + { + SetReadiness( + new ChannelReadiness(LogChannelNames.StateLog, ChannelPresence.Present, ChannelEnablement.Disabled) + { + Access = ChannelAccess.RequiresElevation + }); + var live = LiveItems(await OpenMenu("File")); + + var state = Item(await Item(live, "Other Logs").ChildrenLoader!(), LogChannelNames.StateLog); + + Assert.True(state.IsEnabled); + Assert.Equal("(elevate)", state.StatusText); + } + [Fact] public async Task File_WhenActiveLogOpen_CloseAllAndCombineEnabled() { @@ -196,6 +257,14 @@ public async Task View_WhenNotGrouping_GroupActionsDisabledWithReason() private static MenuItem Item(IReadOnlyList items, string label) => items.Single(item => item.Label == label); + private static IReadOnlyList LiveItems(IReadOnlyList fileItems) => + Item(Item(fileItems, "Open").Children!, "Live").Children!; + + private static ImmutableArray ReadinessFor(IEnumerable channels) => + [ + .. channels.Select(channel => new ChannelReadiness(channel, ChannelPresence.Present, ChannelEnablement.Unknown)) + ]; + // Distinct substitute per selection; Value applies its own projection to _logTableState. private IStateSelection CreateLogTableSelection() { @@ -229,4 +298,18 @@ await cut.FindAll("button.menu-bar-item") } private Task> OpenViewMenu() => OpenMenu("View"); + + private void SetReadiness(params ChannelReadiness[] readiness) + { + _readinessService.GetReadinessAsync(Arg.Any()).Returns([.. readiness]); + _readinessService.GetReadinessAsync(Arg.Any>(), Arg.Any()) + .Returns(call => + { + var requested = call.Arg>()!.ToHashSet(StringComparer.OrdinalIgnoreCase); + + return readiness + .Where(channel => requested.Contains(channel.Channel)) + .ToImmutableArray(); + }); + } } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuRendererTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuRendererTests.cs index 338c9fed..d3232361 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuRendererTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuRendererTests.cs @@ -64,7 +64,7 @@ public void MenuRenderer_WithDisabledItemAndNoReason_RendersInertEntryThatSkipsR [Fact] public void MenuRenderer_WithDisabledItemAndReason_AnnouncesReasonAndParticipatesInRovingFocus() { - const string reason = "No cached filters yet — apply a Basic or Advanced filter to populate."; + const string reason = "No cached filters yet - apply a Basic or Advanced filter to populate."; var items = new[] { MenuItem.Item("Cached", () => { }, isEnabled: false, disabledReason: reason), @@ -109,6 +109,24 @@ public void MenuRenderer_WithEnabledItem_RendersFocusableMenuItemWithoutDisabled Assert.Empty(listItem.QuerySelectorAll("span.visually-hidden")); } + [Fact] + public async Task MenuRenderer_WithEnabledStatus_RendersVisibleTagAndAllowsActivation() + { + bool actionInvoked = false; + var items = new[] + { + MenuItem.Item("Operational", () => actionInvoked = true, statusText: "(disabled)"), + }; + + var component = Render(parameters => parameters.Add(p => p.Items, items)); + + await component.Find("li.menu-item").ClickAsync(new()); + + Assert.True(actionInvoked); + Assert.Equal("(disabled)", component.Find(".menu-status").TextContent); + Assert.Null(component.Find("li.menu-item").GetAttribute("aria-disabled")); + } + [Fact] public void MenuRenderer_WithMultipleInformativeDisabledItems_GeneratesUniqueDescribedByIds() {