diff --git a/src/EventLogExpert.DatabaseTools/Common/Operations/OperationBase.cs b/src/EventLogExpert.DatabaseTools/Common/Operations/OperationBase.cs index c24eae9e6..322bfb72d 100644 --- a/src/EventLogExpert.DatabaseTools/Common/Operations/OperationBase.cs +++ b/src/EventLogExpert.DatabaseTools/Common/Operations/OperationBase.cs @@ -108,6 +108,30 @@ protected static async IAsyncEnumerable LoadLocalProvidersAsync } } + /// + /// Streams providers extracted from a mounted or extracted foreign Windows image, fully offline. Mirrors + /// : offline extraction is synchronous (registry-hive + DLL reads), so this + /// wrapper exposes it as an for the shared await foreach consume loop; the + /// await keeps it a valid async iterator without scheduling an extra continuation. The facade applies the name filter + /// and exclude set itself (so this does not re-filter) and stamps each provider with the IMAGE's OS provenance. + /// + protected static async IAsyncEnumerable LoadOfflineImageProvidersAsync( + string offlineImagePath, + ITraceLogger logger, + Regex? regex, + IReadOnlySet? excludeProviderNames = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + + foreach (var details in OfflineImageProviderSource.LoadProviders(offlineImagePath, logger, regex, excludeProviderNames)) + { + cancellationToken.ThrowIfCancellationRequested(); + + yield return details; + } + } + /// /// Emits the provider-details column header sized to the longest provider name. Updates the instance format /// string so subsequent calls align to the same width. diff --git a/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs b/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs index e99c3f3f0..4caa8ffb9 100644 --- a/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs +++ b/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs @@ -3,6 +3,7 @@ using EventLogExpert.DatabaseTools.Common.Operations; using EventLogExpert.Eventing.PublisherMetadata; +using EventLogExpert.Eventing.PublisherMetadata.Offline; using EventLogExpert.Logging.Abstractions; using EventLogExpert.Provider.Resolution; using EventLogExpert.ProviderDatabase.Context; @@ -21,6 +22,8 @@ internal sealed class CreateDatabaseOperation(CreateDatabaseRequest request) : O { private const int BatchSize = 100; + internal enum CreateDatabaseMode { Local, FileSource, OfflineImage } + public async Task ExecuteAsync( ITraceLogger logger, IProgress? progress, @@ -40,6 +43,11 @@ public async Task ExecuteAsync( return DatabaseToolsOutcome.Failed; } + if (!ValidateOfflineImageRequest(request, logger)) + { + return DatabaseToolsOutcome.Failed; + } + if (request.SourcePath is not null && !ProviderSource.TryValidate(request.SourcePath, logger)) { return DatabaseToolsOutcome.Failed; @@ -73,19 +81,80 @@ public async Task ExecuteAsync( // already-hashed row in a multi-file source): both re-hash to the same VersionKey, so the second would // otherwise collide on the composite primary key. Track stamped identities and skip duplicates first-wins. var stampedIdentities = new HashSet(); +#if DEBUG + // CI-only tripwire: fail the build if a (Name, VersionKey) collision's rows are not content-equivalent (hash and + // merge drift). No release retention. + var firstByIdentity = new Dictionary(); +#endif // Defer creating the DbContext (and therefore the .db file on disk) until we have // at least one provider to persist. This prevents leaving an empty database behind // when no provider details could be resolved. ProviderDbContext? dbContext = null; + // A WIM image is extracted to a temp folder up front; the OfflineWimImage owns that ~GB-scale temp and is disposed + // in the finally (AFTER the final SaveChangesAsync, which materializes lazy DLL reads from the extracted tree). + OfflineWimImage? wimImage = null; + try { - IAsyncEnumerable providersToAdd = request.SourcePath is null - ? LoadLocalProvidersAsync(logger, filterRegex, excludeProviderNames, cancellationToken) - : ProviderSource.LoadProvidersAsync(request.SourcePath, logger, filterRegex, excludeProviderNames, cancellationToken: cancellationToken); + var mode = SelectMode(request); + + // For a WIM image, extract the requested index to a temp folder, then read providers from that folder exactly + // like a mounted volume. A failed extraction surfaces a specific, actionable error and leaves no .db behind. + string? effectiveOfflineImagePath = request.OfflineImagePath; + + if (mode == CreateDatabaseMode.OfflineImage && ResolveImageKind(request) == OfflineImageKind.Wim) + { + OfflineWimExtractResult extraction = await OfflineWimImage.TryExtractAsync( + request.OfflineImagePath!, request.WimIndex!.Value, Path.GetTempPath(), logger, cancellationToken); + + if (extraction.Status != OfflineWimExtractStatus.Extracted) + { + return HandleWimExtractionFailure(extraction.Status, request.OfflineImagePath!, request.WimIndex!.Value, logger); + } + + wimImage = extraction.Image; + effectiveOfflineImagePath = wimImage!.ExtractedRoot; + } + + // ONE switch picks BOTH the provider stream AND the provenance so the two cannot desync: an offline image + // build must NOT read host provenance (the facade already stamped each row with the IMAGE's OS, and a host + // read here would overwrite it); host provenance is read ONLY for a local build. The bounded filterRegex + // (not request.FilterRegex) reaches every source so the RegexMatchTimeoutException catch stays reachable. + IAsyncEnumerable providersToAdd; + SourceOsProvenance? sourceOsProvenance; + + switch (mode) + { + case CreateDatabaseMode.OfflineImage: + providersToAdd = LoadOfflineImageProvidersAsync(effectiveOfflineImagePath!, + logger, + filterRegex, + excludeProviderNames, + cancellationToken); + + sourceOsProvenance = null; - var hostOsProvenance = request.SourcePath is null ? HostOsProvenance.Read(logger) : null; + break; + case CreateDatabaseMode.Local: + providersToAdd = + LoadLocalProvidersAsync(logger, filterRegex, excludeProviderNames, cancellationToken); + + sourceOsProvenance = SourceOsProvenance.Read(logger); + + break; + default: + providersToAdd = ProviderSource.LoadProvidersAsync(request.SourcePath!, + logger, + filterRegex, + excludeProviderNames, + cancellationToken: cancellationToken); + + sourceOsProvenance = null; + + break; + } await foreach (var details in providersToAdd.WithCancellation(cancellationToken)) { @@ -96,14 +165,27 @@ public async Task ExecuteAsync( // already-hashed source; computes the key for freshly-resolved (live) providers. details.VersionKey = VersionKeyCalculator.Compute(details); - if (!stampedIdentities.Add(ProviderIdentity.Of(details))) { continue; } + var identity = ProviderIdentity.Of(details); + + if (!stampedIdentities.Add(identity)) + { +#if DEBUG + AssertContentEquivalent(firstByIdentity[identity], details); +#endif + + continue; + } + +#if DEBUG + firstByIdentity[identity] = details; +#endif - if (hostOsProvenance is not null) + if (sourceOsProvenance is not null) { - details.SourceOsBuild = hostOsProvenance.Build; - details.SourceOsRevision = hostOsProvenance.Revision; - details.SourceOsEdition = hostOsProvenance.Edition; - details.SourceOsDisplayVersion = hostOsProvenance.DisplayVersion; + details.SourceOsBuild = sourceOsProvenance.Build; + details.SourceOsRevision = sourceOsProvenance.Revision; + details.SourceOsEdition = sourceOsProvenance.Edition; + details.SourceOsDisplayVersion = sourceOsProvenance.DisplayVersion; } if (!headerLogged) @@ -185,6 +267,192 @@ public async Task ExecuteAsync( finally { if (dbContext is not null) { await dbContext.DisposeAsync(); } + + // Delete the extracted WIM temp AFTER persistence completes (the final SaveChangesAsync reads from it). + wimImage?.Dispose(); + } + } + + // The effective kind is the explicit --image-kind when given, otherwise inferred from the path: an existing directory + // is a mounted volume / extracted folder; a .wim/.esd is a WIM; a .iso is an ISO. Null = neither given nor inferable. + internal static OfflineImageKind? ResolveImageKind(CreateDatabaseRequest request) + { + if (request.ImageKind is { } explicitKind) { return explicitKind; } + + if (string.IsNullOrWhiteSpace(request.OfflineImagePath)) { return null; } + + if (Directory.Exists(request.OfflineImagePath)) { return OfflineImageKind.Directory; } + + string extension = Path.GetExtension(request.OfflineImagePath.TrimEnd('.', ' ')); + + return extension.Equals(".wim", StringComparison.OrdinalIgnoreCase) || extension.Equals(".esd", StringComparison.OrdinalIgnoreCase) + ? OfflineImageKind.Wim + : extension.Equals(".iso", StringComparison.OrdinalIgnoreCase) ? OfflineImageKind.Iso : null; + } + + /// + /// Picks the provider source for the request. An offline image (a non-whitespace OfflineImagePath) wins; + /// otherwise a null SourcePath means local providers and a non-null one means a file source. Pure so the mode + /// selection (and the host-provenance suppression keyed on it) can be unit-tested without a real image. + /// + internal static CreateDatabaseMode SelectMode(CreateDatabaseRequest request) => + !string.IsNullOrWhiteSpace(request.OfflineImagePath) ? CreateDatabaseMode.OfflineImage + : request.SourcePath is null ? CreateDatabaseMode.Local + : CreateDatabaseMode.FileSource; + + internal static bool ValidateOfflineImageRequest(CreateDatabaseRequest request, ITraceLogger logger) + { + if (string.IsNullOrWhiteSpace(request.OfflineImagePath)) + { + // No offline image: reject orphan WIM options so they are never silently ignored (which would build from the + // wrong source). Kind is auto-detected, so an EXPLICIT kind or a stray index without an image is the error. + if (request.ImageKind is not null) + { + logger.Error($"--image-kind requires an offline image (--offline-image)."); + + return false; + } + + if (request.WimIndex is not null) + { + logger.Error($"--wim-index requires an offline image (--offline-image) pointing at a .wim/.esd file."); + + return false; + } + + return true; + } + + if (request.SourcePath is not null) + { + logger.Error($"Specify a source OR an offline image, not both."); + + return false; + } + + switch (ResolveImageKind(request)) + { + case OfflineImageKind.Directory: + if (request.WimIndex is not null) + { + logger.Error($"--wim-index applies only to --image-kind wim."); + + return false; + } + + if (Directory.Exists(request.OfflineImagePath)) { return true; } + + if (File.Exists(request.OfflineImagePath)) + { + logger.Error($"Offline image path is a file, not a directory: {request.OfflineImagePath}. For a .wim/.esd file, add --image-kind wim --wim-index N."); + } + else + { + logger.Error($"Offline image directory not found: {request.OfflineImagePath}"); + } + + return false; + + case OfflineImageKind.Wim: + if (!File.Exists(request.OfflineImagePath)) + { + logger.Error($"WIM image file not found: {request.OfflineImagePath}"); + + return false; + } + + if (!IsWimImageFile(request.OfflineImagePath)) + { + logger.Error($"--image-kind wim expects a .wim or .esd file: {request.OfflineImagePath}"); + + return false; + } + + // The index range and elevation are validated by the extraction itself (so the messages list the actual + // images and the elevation prompt comes only when an apply is really needed). A MISSING index, though, is + // a request-shape error - list the choices so the user can pick one. + if (request.WimIndex is null) + { + logger.Error($"--wim-index is required for --image-kind wim. Choose an image:"); + LogAvailableWimIndices(request.OfflineImagePath, logger); + + return false; + } + + return true; + + case null: + logger.Error( + $"Could not determine the offline image kind for '{request.OfflineImagePath}'. Pass --image-kind " + + $"directory or wim (.wim/.esd), or point at a mounted volume / extracted image folder."); + + return false; + + default: + logger.Error( + $"Offline ISO images are not yet supported; use a mounted volume or extracted image folder " + + $"(--image-kind directory) or a .wim/.esd file (--image-kind wim)."); + + return false; + } + } + + /// + /// Maps a non-success to an actionable error and the operation outcome. + /// The extraction already cleaned up any partial temp, so there is nothing to dispose here. + /// + private static DatabaseToolsOutcome HandleWimExtractionFailure( + OfflineWimExtractStatus status, string wimPath, int wimIndex, ITraceLogger logger) + { + string wimName = Path.GetFileName(wimPath); + + switch (status) + { + case OfflineWimExtractStatus.Cancelled: + return DatabaseToolsOutcome.Cancelled; + case OfflineWimExtractStatus.NeedsElevation: + logger.Error($"Extracting an image from {wimName} requires administrator privileges. Re-run elevated."); + + break; + case OfflineWimExtractStatus.IndexOutOfRange: + logger.Error($"Image index {wimIndex} is not in {wimName}."); + LogAvailableWimIndices(wimPath, logger); + + break; + case OfflineWimExtractStatus.InsufficientSpace: + logger.Error($"Not enough free disk space to extract image {wimIndex} from {wimName}."); + + break; + case OfflineWimExtractStatus.NotAWim: + logger.Error($"{wimPath} is not a readable WIM or ESD image."); + + break; + default: + logger.Error($"Could not extract image {wimIndex} from {wimName}."); + + break; + } + + return DatabaseToolsOutcome.Failed; + } + + private static bool IsWimImageFile(string path) + { + string extension = Path.GetExtension(path); + + return string.Equals(extension, ".wim", StringComparison.OrdinalIgnoreCase) + || string.Equals(extension, ".esd", StringComparison.OrdinalIgnoreCase); + } + + private static void LogAvailableWimIndices(string wimPath, ITraceLogger logger) + { + WimImageList imageList = OfflineWimImage.ReadIndexList(wimPath, logger); + + if (imageList.Status != WimImageListStatus.Ok || imageList.Images.Count == 0) { return; } + + foreach (WimImageEntry image in imageList.Images) + { + logger.Information($" --wim-index {image.Index} {image.Name} ({image.Edition})"); } } @@ -206,4 +474,91 @@ private async Task FlushHeaderAndBufferAsync( dbContext.ChangeTracker.Clear(); buffer.Clear(); } + +#if DEBUG + private static void AssertContentEquivalent(ProviderDetails first, ProviderDetails duplicate) + { + if (ContentEquivalent(first, duplicate)) { return; } + + throw new InvalidOperationException( + $"Provider '{duplicate.ProviderName}' produced two rows sharing VersionKey '{duplicate.VersionKey}' that " + + $"are not content-equivalent. The content hash and {nameof(ProviderContentMerge)} have drifted - a field " + + $"is hashed for identity but not compared for equivalence (or vice versa)."); + } + + private static bool ContentEquivalent(ProviderDetails first, ProviderDetails duplicate) => + ModelsEquivalent(first.Events, duplicate.Events, static model => ProviderContentMerge.IdentityOf(model), ProviderContentMerge.EventsAreEquivalent) && + ModelsEquivalent(first.Messages, duplicate.Messages, static model => ProviderContentMerge.IdentityOf(model), ProviderContentMerge.MessagesAreEquivalent) && + ModelsEquivalent(first.Parameters, duplicate.Parameters, static model => ProviderContentMerge.IdentityOf(model), ProviderContentMerge.MessagesAreEquivalent) && + MapsEquivalent(first.Maps, duplicate.Maps) && + DictionaryEqual(first.Keywords, duplicate.Keywords) && + DictionaryEqual(first.Opcodes, duplicate.Opcodes) && + DictionaryEqual(first.Tasks, duplicate.Tasks) && + string.Equals( + first.ResolvedFromOwningPublisher ?? string.Empty, + duplicate.ResolvedFromOwningPublisher ?? string.Empty, + StringComparison.Ordinal); + + private static bool DictionaryEqual(IDictionary first, IDictionary second) + where TKey : notnull + { + if (first.Count != second.Count) { return false; } + + foreach ((TKey key, string value) in first) + { + if (!second.TryGetValue(key, out string? other) || !string.Equals(value, other, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static bool MapsEquivalent( + IReadOnlyDictionary first, + IReadOnlyDictionary second) + { + if (first.Count != second.Count) { return false; } + + foreach ((string key, ValueMapDefinition map) in first) + { + if (!second.TryGetValue(key, out ValueMapDefinition? other) || !ProviderContentMerge.MapsAreEquivalent(map, other)) + { + return false; + } + } + + return true; + } + + private static bool ModelsEquivalent( + IReadOnlyList first, + IReadOnlyList second, + Func identityOf, + Func areEquivalent) + where TIdentity : notnull + { + // Compare DISTINCT identities both ways (the hash drops exact-duplicate rows, so raw counts can differ). + var firstByIdentity = new Dictionary(first.Count); + + foreach (TModel model in first) { firstByIdentity[identityOf(model)] = model; } + + var secondByIdentity = new Dictionary(second.Count); + + foreach (TModel model in second) { secondByIdentity[identityOf(model)] = model; } + + if (firstByIdentity.Count != secondByIdentity.Count) { return false; } + + foreach ((TIdentity identity, TModel model) in firstByIdentity) + { + if (!secondByIdentity.TryGetValue(identity, out TModel? other) || !areEquivalent(model, other)) + { + return false; + } + } + + return true; + } +#endif } diff --git a/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseRequest.cs b/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseRequest.cs index 2474e06b7..14611cb94 100644 --- a/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseRequest.cs +++ b/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseRequest.cs @@ -6,11 +6,37 @@ namespace EventLogExpert.DatabaseTools.CreateDatabase; /// -/// Creates a new provider database from local providers or from a source. When is null, -/// local providers on this machine are used (no fallback). When supplied, ONLY the source is used. +/// Creates a new provider database from local providers, a file source, or an offline Windows image. When both +/// and are null, local providers on this machine are used +/// (no fallback). When is supplied, ONLY that source is used. When +/// is supplied, ONLY that image is used, fully offline; it is mutually exclusive with +/// . /// /// Target .db file path. Must not already exist; must have .db extension. -/// Optional source: .db, .evtx, or folder. Null = local providers. +/// +/// Optional source: .db, .evtx, or folder. Null = local providers (unless an offline image is +/// given). +/// /// Optional regex applied to provider names; null = no filter. /// Optional source whose provider names are excluded from the new database. -public sealed record CreateDatabaseRequest(string TargetPath, string? SourcePath, Regex? FilterRegex, string? SkipProvidersInFile); +/// +/// Optional offline Windows image to extract providers from, fully offline (no host +/// registry or host files). Null = not an offline build. Mutually exclusive with . +/// +/// +/// How is accessed: a mounted volume/extracted folder ( +/// ) or a .wim/.esd file (, +/// which extracts first). Null = auto-detect from the path (directory vs .wim/.esd/.iso). +/// +/// +/// The 1-based image index to extract from a .wim/.esd, for +/// . Null otherwise. +/// +public sealed record CreateDatabaseRequest( + string TargetPath, + string? SourcePath, + Regex? FilterRegex, + string? SkipProvidersInFile, + string? OfflineImagePath = null, + OfflineImageKind? ImageKind = null, + int? WimIndex = null); diff --git a/src/EventLogExpert.DatabaseTools/CreateDatabase/OfflineImageKind.cs b/src/EventLogExpert.DatabaseTools/CreateDatabase/OfflineImageKind.cs new file mode 100644 index 000000000..1f81a0081 --- /dev/null +++ b/src/EventLogExpert.DatabaseTools/CreateDatabase/OfflineImageKind.cs @@ -0,0 +1,17 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.DatabaseTools.CreateDatabase; + +/// +/// How the offline Windows image named by is accessed when +/// building a provider database from it. reads a mounted volume or extracted image folder; +/// extracts an image (by ) from a .wim/ +/// .esd file first. is reserved for a later phase. +/// +public enum OfflineImageKind +{ + Directory, + Wim, + Iso +} diff --git a/src/EventLogExpert.EventDbTool/Commands/CreateDatabaseCommand.cs b/src/EventLogExpert.EventDbTool/Commands/CreateDatabaseCommand.cs index e3fad6a5f..292420e57 100644 --- a/src/EventLogExpert.EventDbTool/Commands/CreateDatabaseCommand.cs +++ b/src/EventLogExpert.EventDbTool/Commands/CreateDatabaseCommand.cs @@ -45,6 +45,29 @@ public static Command GetCommand() "would be saved in the new database." }; + Option offlineImageOption = new("--offline-image") + { + Description = + "Build the database from a Windows image, fully offline (no host registry or host files are " + + "read): a mounted volume root (e.g. an attached VHDX), an extracted image folder, or a .wim/.esd " + + "file (use --wim-index N to pick the image). The kind is auto-detected from the path; override with " + + "--image-kind. Mutually exclusive with the source argument." + }; + + Option imageKindOption = new("--image-kind") + { + Description = + "Override how --offline-image is read: 'directory' (a mounted volume or extracted folder) or 'wim' " + + "(a .wim/.esd file). Omit to auto-detect from the path (a directory, or a .wim/.esd file)." + }; + + Option wimIndexOption = new("--wim-index") + { + Description = + "The 1-based image index to extract from a .wim/.esd, for --image-kind wim. Omit to list the " + + "available images." + }; + Option verboseOption = new("--verbose") { Description = "Enable verbose logging. May be useful for troubleshooting." @@ -54,6 +77,9 @@ public static Command GetCommand() createDatabaseCommand.Arguments.Add(sourceArgument); createDatabaseCommand.Options.Add(filterOption); createDatabaseCommand.Options.Add(skipProvidersInFileOption); + createDatabaseCommand.Options.Add(offlineImageOption); + createDatabaseCommand.Options.Add(imageKindOption); + createDatabaseCommand.Options.Add(wimIndexOption); createDatabaseCommand.Options.Add(verboseOption); createDatabaseCommand.SetAction(async result => @@ -70,11 +96,31 @@ public static Command GetCommand() return; } + var imageKindValue = result.GetValue(imageKindOption); + OfflineImageKind? imageKind = null; + + if (!string.IsNullOrWhiteSpace(imageKindValue)) + { + if (!Enum.TryParse(imageKindValue, ignoreCase: true, out OfflineImageKind parsed) || + !Enum.IsDefined(parsed) || + parsed == OfflineImageKind.Iso) + { + logger.Error($"Invalid --image-kind '{imageKindValue}'. Valid values: directory, wim."); + + return; + } + + imageKind = parsed; + } + var request = new CreateDatabaseRequest( result.GetRequiredValue(fileArgument), result.GetValue(sourceArgument), regex, - result.GetValue(skipProvidersInFileOption)); + result.GetValue(skipProvidersInFileOption), + OfflineImagePath: result.GetValue(offlineImageOption), + ImageKind: imageKind, + WimIndex: result.GetValue(wimIndexOption)); var factory = sp.GetRequiredService(); diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.Registry.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.Registry.cs new file mode 100644 index 000000000..46bc55110 --- /dev/null +++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.Registry.cs @@ -0,0 +1,91 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Runtime.InteropServices; + +namespace EventLogExpert.Eventing.Interop; + +internal static partial class NativeMethods +{ + /// Read access mask (STANDARD_RIGHTS_READ | KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY). + internal const int KEY_READ = 0x20019; + internal const int SE_PRIVILEGE_ENABLED = 0x00000002; + internal const int TOKEN_ADJUST_PRIVILEGES = 0x00000020; + internal const int TOKEN_QUERY = 0x00000008; + + private const string Advapi32Api = "advapi32.dll"; + + /// Predefined handle for HKEY_LOCAL_MACHINE, under which recovery-loaded image hives are mounted. + internal static readonly IntPtr HKEY_LOCAL_MACHINE = unchecked((int)0x80000002); + + /// + /// Enables or disables the single privilege in on , + /// writing the prior state to so the caller can restore it exactly. The BOOL return + /// is even when the privilege is not held by the token; callers MUST also check + /// GetLastError() == ERROR_SUCCESS (a not-held privilege reports ERROR_NOT_ALL_ASSIGNED). + /// + [LibraryImport(Advapi32Api, EntryPoint = "AdjustTokenPrivileges", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool AdjustTokenPrivileges( + IntPtr tokenHandle, + [MarshalAs(UnmanagedType.Bool)] bool disableAllPrivileges, + ref TOKEN_PRIVILEGES newState, + int bufferLength, + ref TOKEN_PRIVILEGES previousState, + out int returnLength); + + [LibraryImport(Kernel32Api, EntryPoint = "CloseHandle", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CloseHandle(IntPtr handle); + + [LibraryImport(Kernel32Api, EntryPoint = "GetCurrentProcess")] + internal static partial IntPtr GetCurrentProcess(); + + [LibraryImport(Advapi32Api, EntryPoint = "LookupPrivilegeValueW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool LookupPrivilegeValue(string? lpSystemName, string lpName, out long lpLuid); + + [LibraryImport(Advapi32Api, EntryPoint = "OpenProcessToken", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool OpenProcessToken(IntPtr processHandle, int desiredAccess, out IntPtr tokenHandle); + + /// + /// Loads the registry hive in into a private, process-local application subtree and + /// returns the root key handle. Unlike RegLoadKey it needs no backup/restore privilege (so it runs unelevated), + /// and the hive auto-unloads once every returned key is closed. The handle is returned as a raw + /// because the source-generated marshaller cannot construct a SafeRegistryHandle for an out parameter; + /// the caller wraps it. Returns a non-zero Win32 error code on failure (it does not set last error). It cannot replay + /// the dual-log sidecars a dirty image hive carries - that recovery requires . + /// + [LibraryImport(Advapi32Api, EntryPoint = "RegLoadAppKeyW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int RegLoadAppKey(string lpFile, out IntPtr phkResult, int samDesired, int dwOptions, int reserved); + + /// + /// Mounts the hive in under \, + /// performing log recovery (replaying the dual .LOG1/.LOG2 sidecars a dirty image hive carries) that + /// cannot. Requires SeBackupPrivilege + SeRestorePrivilege; returns a + /// non-zero Win32 error code on failure. The named subtree persists until . + /// + [LibraryImport(Advapi32Api, EntryPoint = "RegLoadKeyW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int RegLoadKey(IntPtr hKey, string lpSubKey, string lpFile); + + /// + /// Unmounts the hive previously mounted at \ by + /// . Requires SeRestorePrivilege; fails while any key under the subtree is still open. + /// Returns a non-zero Win32 error code on failure. + /// + [LibraryImport(Advapi32Api, EntryPoint = "RegUnLoadKeyW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int RegUnLoadKey(IntPtr hKey, string lpSubKey); + + // TOKEN_PRIVILEGES with exactly one LUID_AND_ATTRIBUTES. Pack=4 is REQUIRED: the native layout is + // DWORD PrivilegeCount; LUID(8 bytes, 4-aligned); DWORD Attributes. Default 8-byte packing would 8-align the + // long Luid (offset 8 instead of 4), so AdjustTokenPrivileges reads a misaligned LUID and silently enables nothing + // (returns TRUE with ERROR_NOT_ALL_ASSIGNED) - the exact wrong-Pack trap the lease unit test guards against. + [StructLayout(LayoutKind.Sequential, Pack = 4)] + internal struct TOKEN_PRIVILEGES + { + internal int PrivilegeCount; + internal long Luid; + internal int Attributes; + } +} diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.Wim.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.Wim.cs new file mode 100644 index 000000000..cc635d09d --- /dev/null +++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.Wim.cs @@ -0,0 +1,113 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Runtime.InteropServices; + +namespace EventLogExpert.Eventing.Interop; + +internal static partial class NativeMethods +{ + internal const uint WIM_COMPRESS_NONE = 0; + + /// + /// flags that skip restoring the captured directory/file security descriptors, so + /// the extracted tree is owned by the (elevated) caller and is readable + deletable. Without these the default apply + /// restores TrustedInstaller-owned ACLs, which would block staging the hives and the recursive temp cleanup. + /// + internal const uint WIM_FLAG_NO_DIRACL = 0x00000010; + + internal const uint WIM_FLAG_NO_FILEACL = 0x00000020; + /// Read access for (GENERIC_READ). + internal const uint WIM_GENERIC_READ = 0x80000000; + + /// sentinel returned when registration fails. + internal const uint WIM_INVALID_CALLBACK_VALUE = 0xFFFFFFFF; + + /// Message callback return: abort the in-progress apply (WIMApplyImage then fails with ERROR_REQUEST_ABORTED). + internal const uint WIM_MSG_ABORT_IMAGE = 0xFFFFFFFF; + + /// Message callback return: continue the operation. + internal const uint WIM_MSG_SUCCESS = 0; + + /// Open-existing disposition for (OPEN_EXISTING). + internal const uint WIM_OPEN_EXISTING = 3; + + private const string WimgapiApi = "wimgapi.dll"; + + /// + /// Native WIM message callback (fpMessageProc). Returns to continue or + /// to abort the current apply. + /// + internal delegate uint WimMessageCallback(uint messageId, IntPtr wParam, IntPtr lParam, IntPtr userData); + + /// + /// Frees a buffer allocated by the system with LocalAlloc (e.g. the XML from + /// ). + /// + [LibraryImport(Kernel32Api, EntryPoint = "LocalFree", SetLastError = true)] + internal static partial IntPtr LocalFree(IntPtr mem); + + /// + /// Applies (extracts) the loaded to with + /// . Requires administrator privileges. Returns on failure + /// (sets last error); a callback-driven abort surfaces as ERROR_REQUEST_ABORTED. + /// + [LibraryImport(WimgapiApi, EntryPoint = "WIMApplyImage", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool WIMApplyImage(WimImageSafeHandle image, string path, uint applyFlags); + + /// Closes a WIM file or image handle. Used by the SafeHandle wrappers' release. + [LibraryImport(WimgapiApi, EntryPoint = "WIMCloseHandle", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool WIMCloseHandle(IntPtr handle); + + /// + /// Opens a .wim/.esd image file. Returns an invalid handle on failure (sets last error); + /// receives whether the file was created or opened. + /// + [LibraryImport(WimgapiApi, EntryPoint = "WIMCreateFile", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + internal static partial WimFileSafeHandle WIMCreateFile( + string wimPath, + uint desiredAccess, + uint creationDisposition, + uint flagsAndAttributes, + uint compressionType, + out uint creationResult); + + /// Returns the number of images in (0 on failure, sets last error). + [LibraryImport(WimgapiApi, EntryPoint = "WIMGetImageCount", SetLastError = true)] + internal static partial uint WIMGetImageCount(WimFileSafeHandle wim); + + /// + /// Returns the WIM's <WIM> XML metadata (UTF-16) describing every image, in a system-allocated + /// buffer the caller MUST release with . is the byte + /// length. + /// + [LibraryImport(WimgapiApi, EntryPoint = "WIMGetImageInformation", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool WIMGetImageInformation(WimFileSafeHandle wim, out IntPtr imageInfo, out uint imageInfoBytes); + + /// + /// Loads the 1-based image from . Invalid handle on + /// failure. + /// + [LibraryImport(WimgapiApi, EntryPoint = "WIMLoadImage", SetLastError = true)] + internal static partial WimImageSafeHandle WIMLoadImage(WimFileSafeHandle wim, uint imageIndex); + + /// + /// Registers a per-WIM message callback (a raw function pointer, so the caller MUST keep the delegate rooted for + /// the call's duration). Returns the callback index, or on failure. + /// + [LibraryImport(WimgapiApi, EntryPoint = "WIMRegisterMessageCallback", SetLastError = true)] + internal static partial uint WIMRegisterMessageCallback(WimFileSafeHandle wim, IntPtr messageProc, IntPtr userData); + + /// Sets the scratch directory WIMGAPI uses while loading/applying images on . + [LibraryImport(WimgapiApi, EntryPoint = "WIMSetTemporaryPath", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool WIMSetTemporaryPath(WimFileSafeHandle wim, string path); + + /// Unregisters the callback previously registered for . + [LibraryImport(WimgapiApi, EntryPoint = "WIMUnregisterMessageCallback", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool WIMUnregisterMessageCallback(WimFileSafeHandle wim, IntPtr messageProc); +} diff --git a/src/EventLogExpert.Eventing/Interop/WimFileSafeHandle.cs b/src/EventLogExpert.Eventing/Interop/WimFileSafeHandle.cs new file mode 100644 index 000000000..ba1b68f3a --- /dev/null +++ b/src/EventLogExpert.Eventing/Interop/WimFileSafeHandle.cs @@ -0,0 +1,18 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Microsoft.Win32.SafeHandles; + +namespace EventLogExpert.Eventing.Interop; + +/// +/// A WIM file handle returned by , released with WIMCloseHandle. +/// Closing the WIM file handle also tears down any message callbacks registered against it. +/// +internal sealed class WimFileSafeHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + // Must be public for the source-generated P/Invoke marshaller to construct the handle for a return value. + public WimFileSafeHandle() : base(true) { } + + protected override bool ReleaseHandle() => NativeMethods.WIMCloseHandle(handle); +} diff --git a/src/EventLogExpert.Eventing/Interop/WimImageSafeHandle.cs b/src/EventLogExpert.Eventing/Interop/WimImageSafeHandle.cs new file mode 100644 index 000000000..efb60683a --- /dev/null +++ b/src/EventLogExpert.Eventing/Interop/WimImageSafeHandle.cs @@ -0,0 +1,18 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Microsoft.Win32.SafeHandles; + +namespace EventLogExpert.Eventing.Interop; + +/// +/// A loaded WIM image handle returned by , released with +/// WIMCloseHandle. It must be closed before its parent . +/// +internal sealed class WimImageSafeHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + // Must be public for the source-generated P/Invoke marshaller to construct the handle for a return value. + public WimImageSafeHandle() : base(true) { } + + protected override bool ReleaseHandle() => NativeMethods.WIMCloseHandle(handle); +} diff --git a/src/EventLogExpert.Eventing/Interop/Win32ErrorCodes.cs b/src/EventLogExpert.Eventing/Interop/Win32ErrorCodes.cs index 527398c0d..bc56ef19c 100644 --- a/src/EventLogExpert.Eventing/Interop/Win32ErrorCodes.cs +++ b/src/EventLogExpert.Eventing/Interop/Win32ErrorCodes.cs @@ -7,25 +7,36 @@ namespace EventLogExpert.Eventing.Interop; internal static class Win32ErrorCodes { internal const int ERROR_ACCESS_DENIED = 5; + /// The registry hive needs log recovery (a dirty hive captured from a live/imaged system). + internal const int ERROR_BADDB = 1009; internal const int ERROR_CANCELLED = 1223; + /// The destination drive ran out of space (e.g. during WIMApplyImage). + internal const int ERROR_DISK_FULL = 112; + internal const int ERROR_FILE_NOT_FOUND = 2; + internal const int ERROR_INSUFFICIENT_BUFFER = 122; + internal const int ERROR_INVALID_DATA = 13; + internal const int ERROR_INVALID_HANDLE = 6; + internal const int ERROR_INVALID_PARAMETER = 87; + internal const int ERROR_NO_MORE_ITEMS = 259; + /// A token privilege requested by AdjustTokenPrivileges is not held by the token. + internal const int ERROR_NOT_ALL_ASSIGNED = 1300; + internal const int ERROR_PATH_NOT_FOUND = 3; + /// A required privilege (e.g. SeRestorePrivilege) is not held by the caller. + internal const int ERROR_PRIVILEGE_NOT_HELD = 1314; + /// The registry hive is corrupt; like it marks a hive that needs recovery. + internal const int ERROR_REGISTRY_CORRUPT = 1015; + /// An operation was aborted via its message callback - WIMApplyImage returns this when cancelled. + internal const int ERROR_REQUEST_ABORTED = 1235; + internal const int ERROR_SUCCESS = 0; + internal const int ERROR_EVT_CHANNEL_NOT_FOUND = 0x3A9F; internal const int ERROR_EVT_INVALID_EVENT_DATA = 0x3A9D; internal const int ERROR_EVT_MAX_INSERTS_REACHED = 0x3AB7; internal const int ERROR_EVT_MESSAGE_ID_NOT_FOUND = 0x3AB4; - internal const int ERROR_EVT_MESSAGE_NOT_FOUND = 0x3AB3; - internal const int ERROR_EVT_PUBLISHER_METADATA_NOT_FOUND = 0x3A9A; internal const int ERROR_EVT_UNRESOLVED_PARAMETER_INSERT = 0x3AB6; internal const int ERROR_EVT_UNRESOLVED_VALUE_INSERT = 0x3AB5; - internal const int ERROR_FILE_NOT_FOUND = 2; - - internal const int ERROR_INSUFFICIENT_BUFFER = 122; - internal const int ERROR_INVALID_DATA = 13; - internal const int ERROR_INVALID_HANDLE = 6; - internal const int ERROR_INVALID_PARAMETER = 87; - internal const int ERROR_NO_MORE_ITEMS = 259; - internal const int ERROR_PATH_NOT_FOUND = 3; internal const int RPC_S_CALL_CANCELED = 1818; } diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs b/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs index 95fd53821..b888fb066 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs @@ -29,43 +29,6 @@ public sealed class EventMessageProvider( public ProviderDetails LoadProviderDetails() => LoadProviderDetailsCore(null); - internal static string InjectMapAttribute(string template, string fieldName, string mapName) - { - string nameAttribute = $"name=\"{fieldName}\""; - int searchStart = 0; - - while (true) - { - int dataIndex = template.IndexOf(" node. - if (delimiter is not (' ' or '\t' or '\r' or '\n' or '>' or '/')) - { - searchStart = afterTag; - - continue; - } - - int elementEnd = template.IndexOf('>', afterTag); - - if (elementEnd < 0) { return template; } - - int nameIndex = template.IndexOf(nameAttribute, dataIndex, StringComparison.OrdinalIgnoreCase); - - if (nameIndex >= 0 && nameIndex < elementEnd) - { - return template.Insert(nameIndex + nameAttribute.Length, $" map=\"{mapName}\""); - } - - searchStart = elementEnd + 1; - } - } - internal static List LoadMessagesFromFiles( IEnumerable legacyProviderFiles, string providerName, @@ -86,126 +49,19 @@ internal static List LoadMessagesFromFiles( return messages; } - private static void InjectMapAttributes( - IReadOnlyList events, - IReadOnlyDictionary> eventFieldMaps, - IReadOnlyDictionary decodedMaps) - { - if (eventFieldMaps.Count == 0) { return; } - - foreach (EventModel model in events) - { - if (string.IsNullOrEmpty(model.Template)) { continue; } - - if (!eventFieldMaps.TryGetValue( - new WevtEventKey((uint)model.Id, model.Version), - out IReadOnlyDictionary? fieldMaps)) - { - continue; - } - - string template = model.Template; - - foreach ((string fieldName, string mapName) in fieldMaps) - { - if (decodedMaps.ContainsKey(mapName)) - { - template = InjectMapAttribute(template, fieldName, mapName); - } - } - - model.Template = template; - } - } - - private LegacyMessageFileSource? BuildLazySource(IReadOnlyList files) - { - if (files.Count == 0) { return null; } - - var walkable = new List(); - int total = 0; - - foreach (var file in files) - { - if (!MessageTableReader.TryOpen(file, _logger, out var handle, out nint memTable, out uint size)) { continue; } - - try - { - int count = MessageTableReader.CountEntries(memTable, size); - - if (count > 0) - { - walkable.Add(file); - total += count; - } - } - finally { handle.Dispose(); } - } - - return total > 0 ? new LegacyMessageFileSource(walkable, _providerName, total, _logger) : null; - } - private ProviderDetails LoadMessagesFromModernProvider(ProviderMetadata providerMetadata) { _logger?.Debug($"{nameof(LoadMessagesFromModernProvider)} called for provider {_providerName}"); - var provider = new ProviderDetails { ProviderName = _providerName }; - if (!providerMetadata.IsLocaleMetadata && !s_allProviderNames.Contains(_providerName)) { _logger?.Debug($"{_providerName} modern provider is not present. Returning empty provider."); - return provider; - } - - try - { - provider.Events = providerMetadata.Events.Select(e => new EventModel - { - Description = e.Description, - Id = e.Id, - Keywords = e.Keywords.ToArray(), - Level = e.Level, - LogName = e.LogName, - Opcode = e.Opcode, - Task = e.Task, - Template = e.Template, - Version = e.Version - }).ToArray(); - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load Events for modern provider: {_providerName}. Exception:\n{ex}"); - } - - try - { - provider.Keywords = providerMetadata.Keywords; - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load Keywords for modern provider: {_providerName}. Exception:\n{ex}"); - } - - try - { - provider.Opcodes = providerMetadata.Opcodes; - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load Opcodes for modern provider: {_providerName}. Exception:\n{ex}"); - } - - try - { - provider.Tasks = providerMetadata.Tasks; - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load Tasks for modern provider: {_providerName}. Exception:\n{ex}"); + return new ProviderDetails { ProviderName = _providerName }; } - PopulateValueMaps(provider, providerMetadata); + ProviderDetails provider = + ProviderDetailsFactory.Create(providerMetadata.ToRawContent(_providerName, _logger), _logger); _logger?.Debug($"Returning {provider.Events.Count} events for provider {_providerName}"); @@ -273,80 +129,6 @@ private ProviderDetails LoadProviderDetailsCore(HashSet? visited) return provider; } - private void PopulateValueMaps(ProviderDetails provider, ProviderMetadata providerMetadata) - { - try - { - Guid publisherGuid = providerMetadata.PublisherGuid; - - if (publisherGuid == Guid.Empty) { return; } - - string resourceFilePath = providerMetadata.ResourceFilePath; - - if (string.IsNullOrEmpty(resourceFilePath)) { return; } - - WevtTemplateData? templateData = WevtTemplateReader.TryRead(resourceFilePath, publisherGuid, _logger); - - if (templateData is null || templateData.Maps.Count == 0) { return; } - - Dictionary decodedMaps = new(StringComparer.Ordinal); - - foreach ((string mapName, WevtRawMap rawMap) in templateData.Maps) - { - ValueMapDefinition? definition = ResolveMap(rawMap, providerMetadata); - - if (definition is not null) - { - decodedMaps[mapName] = definition; - } - } - - if (decodedMaps.Count == 0) { return; } - - provider.Maps = decodedMaps; - - InjectMapAttributes(provider.Events, templateData.EventFieldMaps, decodedMaps); - } - catch (Exception ex) when (ex is not OutOfMemoryException - and not StackOverflowException - and not AccessViolationException) - { - _logger?.Debug($"Failed to populate value maps for modern provider: {_providerName}. Exception:\n{ex}"); - } - } - - private ValueMapDefinition? ResolveMap(WevtRawMap rawMap, ProviderMetadata providerMetadata) - { - List entries = new(rawMap.Entries.Count); - - foreach (WevtRawMapEntry entry in rawMap.Entries) - { - if (entry.MessageId == uint.MaxValue) { continue; } - - string name; - - try - { - name = providerMetadata.FormatMessageById(entry.MessageId); - } - catch (Exception ex) when (ex is not OutOfMemoryException - and not StackOverflowException - and not AccessViolationException) - { - _logger?.Debug( - $"Failed to resolve map message {entry.MessageId} for provider {_providerName}: {ex.Message}"); - - continue; - } - - if (string.IsNullOrEmpty(name)) { continue; } - - entries.Add(new ValueMapEntry(entry.Value, name.TrimEnd('\0', '\r', '\n', '\t', ' '))); - } - - return entries.Count > 0 ? new ValueMapDefinition(rawMap.IsBitMap, entries) : null; - } - // Stamps the provider with the newest 4-part numeric file version across its resolved message DLLs - the // per-provider recency signal. Uses FileVersionInfo NUMERIC parts, not the FileVersion string: inbox DLLs carry // a trailing " (WinBuild.160101.0800)" that Version.Parse rejects, which would null the ordinal for nearly every @@ -533,7 +315,7 @@ and not StackOverflowException private bool TrySetLazyMessages(ProviderDetails provider, IReadOnlyList files) { - var source = BuildLazySource(files); + var source = LegacyMessageFileSource.TryCreate(files, _providerName, _logger); if (source is null) { return false; } @@ -544,7 +326,7 @@ private bool TrySetLazyMessages(ProviderDetails provider, IReadOnlyList private bool TrySetLazyParameters(ProviderDetails provider, IReadOnlyList files) { - var source = BuildLazySource(files); + var source = LegacyMessageFileSource.TryCreate(files, _providerName, _logger); if (source is null) { return false; } diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/EventMetadata.cs b/src/EventLogExpert.Eventing/PublisherMetadata/EventMetadata.cs deleted file mode 100644 index 02d019312..000000000 --- a/src/EventLogExpert.Eventing/PublisherMetadata/EventMetadata.cs +++ /dev/null @@ -1,81 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Eventing.PublisherMetadata; - -internal sealed record EventMetadata -{ - private readonly byte _channelId; - private readonly long _keywords; - private readonly ProviderMetadata _provider; - - internal EventMetadata( - uint id, - byte version, - byte channelId, - byte level, - byte opcode, - short task, - long keywords, - string template, - string description, - ProviderMetadata provider) - { - Id = id; - Version = version; - _channelId = channelId; - Level = level; - Opcode = opcode; - Task = task; - _keywords = keywords; - Template = template; - Description = description; - _provider = provider; - } - - internal string Description { get; } - - internal long Id { get; } - - internal IEnumerable Keywords - { - get - { - List keywords = []; - - ulong mask = 0x8000000000000000; - - for (int i = 0; i < 64; i++) - { - if (((ulong)_keywords & mask) > 0) - { - keywords.Add(unchecked((long)mask)); - } - - mask >>= 1; - } - - return keywords; - } - } - - internal byte Level { get; } - - internal string? LogName - { - get - { - _provider.Channels.TryGetValue(_channelId, out string? logName); - - return logName; - } - } - - internal int Opcode { get; } - - internal int Task { get; } - - internal string Template { get; } - - internal byte Version { get; } -} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/HostOsProvenance.cs b/src/EventLogExpert.Eventing/PublisherMetadata/HostOsProvenance.cs deleted file mode 100644 index a6725b092..000000000 --- a/src/EventLogExpert.Eventing/PublisherMetadata/HostOsProvenance.cs +++ /dev/null @@ -1,54 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Logging.Abstractions; -using Microsoft.Win32; - -namespace EventLogExpert.Eventing.PublisherMetadata; - -/// -/// Provenance of the host OS a provider database is built from, read once per db-create from the local -/// HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion hive. Recorded per provider row so resolution can prefer -/// the newest source (the recency tiebreak) without relying on the database file name. All fields are null when the -/// hive cannot be read; resolution degrades gracefully to completeness + load order. -/// -public sealed record HostOsProvenance(int? Build, int? Revision, string? Edition, string? DisplayVersion) -{ - public static HostOsProvenance Empty { get; } = new(null, null, null, null); - - public static HostOsProvenance Read(ITraceLogger? logger = null) - { - try - { - // Open an owned base key (do NOT use Registry.LocalMachine - that's a shared static), matching - // RegistryProvider so concurrent instances dispose independently. - using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Default); - - using var currentVersion = hklm.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); - - if (currentVersion is null) - { - logger?.Debug($"{nameof(HostOsProvenance)}: CurrentVersion key not found; provenance unavailable."); - - return Empty; - } - - // CurrentBuildNumber is a REG_SZ; UBR is a DWORD; EditionID / DisplayVersion are REG_SZ. - var build = int.TryParse(currentVersion.GetValue("CurrentBuildNumber") as string, out var parsedBuild) - ? parsedBuild - : (int?)null; - - var revision = currentVersion.GetValue("UBR") is int rawRevision ? rawRevision : (int?)null; - var edition = currentVersion.GetValue("EditionID") as string; - var displayVersion = currentVersion.GetValue("DisplayVersion") as string; - - return new HostOsProvenance(build, revision, edition, displayVersion); - } - catch (Exception ex) - { - logger?.Debug($"{nameof(HostOsProvenance)}: failed to read host OS provenance. Exception:\n{ex}"); - - return Empty; - } - } -} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/ILegacyMessageFileResolver.cs b/src/EventLogExpert.Eventing/PublisherMetadata/ILegacyMessageFileResolver.cs new file mode 100644 index 000000000..3fb99ac3d --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/ILegacyMessageFileResolver.cs @@ -0,0 +1,21 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.PublisherMetadata; + +/// +/// Resolves the legacy (pre-manifest) message/category file paths registered for a provider under +/// SYSTEM\…\Services\EventLog. The live implementation () reads the host +/// HKLM\SYSTEM; the offline implementation reads a foreign image's SYSTEM hive. Parameterizing the +/// legacy lookup lets the shared populate legacy tables without a +/// host-registry dependency, so an offline image build never reads host data. +/// +internal interface ILegacyMessageFileResolver +{ + /// + /// Returns the legacy message/category file paths registered for , or an empty + /// list when the provider has no legacy registration. Paths are already fully resolved (host-expanded for the live + /// source, re-rooted onto the image for the offline source) so the caller can open them directly. + /// + IReadOnlyList GetMessageFilesForLegacyProvider(string providerName); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/LegacyMessageFileSource.cs b/src/EventLogExpert.Eventing/PublisherMetadata/LegacyMessageFileSource.cs index edd925610..a4d4f716f 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/LegacyMessageFileSource.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/LegacyMessageFileSource.cs @@ -42,19 +42,48 @@ public IReadOnlyList GetByShortId(int shortId) => public IReadOnlyList MaterializeAll() => _all.Value; - private MessageModel? ExtractByRawId(long rawId) + internal static LegacyMessageFileSource? TryCreate(IReadOnlyList files, string providerName, ITraceLogger? logger) { - foreach (var file in _files) + if (files.Count == 0) { return null; } + + var walkable = new List(); + int total = 0; + + foreach (var (file, memTable, size) in OpenMessageTables(files, logger)) { - if (!MessageTableReader.TryOpen(file, _logger, out var handle, out var memTable, out uint size)) { continue; } + int count = MessageTableReader.CountEntries(memTable, size); - try + if (count > 0) { - var match = MessageTableReader.FindFirstByRawId(memTable, size, rawId, _providerName); - if (match is not null) { return match; } + walkable.Add(file); + total += count; } + } + + return total > 0 ? new LegacyMessageFileSource(walkable, providerName, total, logger) : null; + } + + private static IEnumerable<(string File, nint MemTable, uint Size)> OpenMessageTables( + IReadOnlyList files, + ITraceLogger? logger) + { + foreach (var file in files) + { + if (!MessageTableReader.TryOpen(file, logger, out var handle, out var memTable, out uint size)) { continue; } + + try { yield return (file, memTable, size); } finally { handle.Dispose(); } } + } + + private MessageModel? ExtractByRawId(long rawId) + { + foreach (var (_, memTable, size) in OpenMessageTables(_files, _logger)) + { + var match = MessageTableReader.FindFirstByRawId(memTable, size, rawId, _providerName); + + if (match is not null) { return match; } + } return null; } @@ -63,12 +92,9 @@ private IReadOnlyList ExtractByShortId(int shortId) { var result = new List(); - foreach (var file in _files) + foreach (var (_, memTable, size) in OpenMessageTables(_files, _logger)) { - if (!MessageTableReader.TryOpen(file, _logger, out var handle, out var memTable, out uint size)) { continue; } - - try { MessageTableReader.AppendMatches(memTable, size, _providerName, shortId, result); } - finally { handle.Dispose(); } + MessageTableReader.AppendMatches(memTable, size, _providerName, shortId, result); } return result.Count == 0 ? s_empty : result; @@ -78,12 +104,9 @@ private IReadOnlyList MaterializeAllCore() { var result = new List(_count); - foreach (var file in _files) + foreach (var (_, memTable, size) in OpenMessageTables(_files, _logger)) { - if (!MessageTableReader.TryOpen(file, _logger, out var handle, out var memTable, out uint size)) { continue; } - - try { MessageTableReader.AppendMatches(memTable, size, _providerName, -1, result); } - finally { handle.Dispose(); } + MessageTableReader.AppendMatches(memTable, size, _providerName, -1, result); } return result; diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/BackupRestorePrivilegeScope.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/BackupRestorePrivilegeScope.cs new file mode 100644 index 000000000..493ce1591 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/BackupRestorePrivilegeScope.cs @@ -0,0 +1,171 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using System.Runtime.InteropServices; +using static EventLogExpert.Eventing.Interop.NativeMethods; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// A short-lived, process-wide-serialized enabling of SeBackupPrivilege + SeRestorePrivilege for +/// the duration of a single privileged registry call (RegLoadKey / RegUnLoadKey). The privileges are +/// present-but-disabled in an elevated token; this enables them, runs the call, then restores each privilege to its +/// EXACT prior state - never leaving backup/restore (ACL-bypass) semantics enabled for unrelated code in a long-lived +/// host such as the MAUI app. A static gate serializes the enable/call/revert sections so two concurrent offline loads +/// cannot disable a privilege the other is mid-call relying on. +/// +internal sealed class BackupRestorePrivilegeScope : IDisposable +{ + private static readonly Lock s_gate = new(); + // SeRestorePrivilege is required by both RegLoadKey and RegUnLoadKey; SeBackupPrivilege by RegLoadKey. Enable both. + private static readonly string[] s_requiredPrivileges = ["SeBackupPrivilege", "SeRestorePrivilege"]; + + private readonly List _statesToRestore; + private readonly IntPtr _token; + + private bool _disposed; + + private BackupRestorePrivilegeScope(IntPtr token, List statesToRestore) + { + _token = token; + _statesToRestore = statesToRestore; + } + + public void Dispose() + { + if (_disposed) { return; } + + _disposed = true; + + try + { + RestoreAll(_token, _statesToRestore); + CloseHandle(_token); + } + finally + { + s_gate.Exit(); + } + } + + /// + /// Verifies the token holds by enabling then immediately restoring it. Exists + /// so a CI unit test can assert the Pack=4 marshalling against a privilege EVERY token holds ( + /// SeChangeNotifyPrivilege) - a wrong struct pack and a not-held privilege both report + /// ERROR_NOT_ALL_ASSIGNED, so only a SUCCESS on a held privilege proves the marshalling is correct. + /// + internal static bool CanEnablePrivilegeForTest(string privilegeName) + { + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, out IntPtr token)) + { + return false; + } + + try + { + if (!TryEnable(token, privilegeName, out TOKEN_PRIVILEGES previous)) { return false; } + + var state = previous; + AdjustTokenPrivileges(token, false, ref state, Marshal.SizeOf(), ref previous, out _); + + return true; + } + finally + { + CloseHandle(token); + } + } + + /// + /// Enters the gate and enables both privileges, capturing each prior state for restore. Returns + /// (gate released, nothing changed) when the token does not hold them - i.e. the process is + /// not elevated, which the caller surfaces as "needs administrator". The returned scope MUST be disposed to revert + + /// release the gate. + /// + internal static BackupRestorePrivilegeScope? TryAcquire(ITraceLogger? logger) + { + s_gate.Enter(); + + IntPtr token = IntPtr.Zero; + var restore = new List(); + + try + { + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, out token)) + { + logger?.Debug($"{nameof(BackupRestorePrivilegeScope)}: OpenProcessToken failed (error {Marshal.GetLastWin32Error()})."); + + return Release(token, restore); + } + + foreach (string privilege in s_requiredPrivileges) + { + if (!TryEnable(token, privilege, out TOKEN_PRIVILEGES previous)) + { + logger?.Debug($"{nameof(BackupRestorePrivilegeScope)}: token does not hold {privilege}; not elevated."); + + // Atomic rollback: restore any privilege already enabled before returning not-held. + RestoreAll(token, restore); + + return Release(token, restore); + } + + restore.Add(previous); + } + + return new BackupRestorePrivilegeScope(token, restore); + } + catch + { + RestoreAll(token, restore); + Release(token, restore); + + throw; + } + } + + private static BackupRestorePrivilegeScope? Release(IntPtr token, List _) + { + if (token != IntPtr.Zero) { CloseHandle(token); } + + s_gate.Exit(); + + return null; + } + + private static void RestoreAll(IntPtr token, List states) + { + if (token == IntPtr.Zero) { return; } + + foreach (TOKEN_PRIVILEGES previous in states) + { + var state = previous; + var discard = new TOKEN_PRIVILEGES(); + AdjustTokenPrivileges(token, false, ref state, Marshal.SizeOf(), ref discard, out _); + } + } + + // Enables a single privilege; true iff the token HOLDS it and it is now enabled. AdjustTokenPrivileges returns TRUE + // even when the privilege is not held (it then sets ERROR_NOT_ALL_ASSIGNED), so the SUCCESS check is mandatory. + private static bool TryEnable(IntPtr token, string privilegeName, out TOKEN_PRIVILEGES previous) + { + previous = default; + + if (!LookupPrivilegeValue(null, privilegeName, out long luid)) { return false; } + + var newState = new TOKEN_PRIVILEGES { PrivilegeCount = 1, Luid = luid, Attributes = SE_PRIVILEGE_ENABLED }; + var capturedPrevious = new TOKEN_PRIVILEGES(); + bool adjusted = AdjustTokenPrivileges(token, false, ref newState, Marshal.SizeOf(), ref capturedPrevious, out _); + + if (adjusted && Marshal.GetLastWin32Error() == Win32ErrorCodes.ERROR_SUCCESS) + { + previous = capturedPrevious; + + return true; + } + + return false; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImagePathMapper.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImagePathMapper.cs new file mode 100644 index 000000000..1692411c2 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImagePathMapper.cs @@ -0,0 +1,168 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; + +/// +/// Maps a registry-stored DLL path (resource / message / parameter file) from a foreign Windows image onto the +/// mounted-or-extracted image root (re-rooting it), NEVER touching the host filesystem. Real images store these paths +/// predominantly as drive-absolute literals such as C:\Windows\System32\foo.dll (a host-registry sample found +/// 842 drive-absolute vs 0 %SystemRoot% publisher paths), so absolute re-rooting - not env-token expansion - is +/// the load-bearing case, and the image's system drive may not be C:. The host +/// is intentionally never used. Values that cannot be re-rooted +/// onto the image with certainty (unsupported environment tokens, UNC / DOS-device / NT / drive-relative forms, +/// alternate-data-stream syntax) are DROPPED (return ) rather than risk resolving against the +/// host. Every non-null result is a fully literal, directory-bearing path under the image root - never a bare leaf - +/// so the downstream loader's host env re-expansion and host-System32 leaf fallback are unreachable. +/// +internal sealed class OfflineImagePathMapper(OfflineImageRoot imageRoot, ITraceLogger? logger) +{ + /// + /// Maps a single registry path value onto the image, or returns when the value cannot be + /// safely re-rooted. Callers pass each already-split segment of a multi-value (;-separated) registry string. + /// + public string? Map(string? registryPath) + { + if (string.IsNullOrWhiteSpace(registryPath)) { return null; } + + string value = registryPath.Trim().Trim('"').Trim(); + + if (value.Length == 0) { return null; } + + // UNC (\\server\share), DOS-device (\\.\), extended-length (\\?\, \\?\UNC\) and NT object (\??\) forms are not + // collapsed by Path.GetFullPath and could escape the image - reject before any further handling. + if (value.StartsWith(@"\\", StringComparison.Ordinal) || value.StartsWith(@"\??\", StringComparison.Ordinal)) + { + return Drop(registryPath, "UNC/DOS-device/NT path form"); + } + + string? relativeToImageRoot = ToImageRootRelative(value); + + if (relativeToImageRoot is null) + { + return Drop(registryPath, "unsupported path form"); + } + + // A residual '%' means an unsupported variable (e.g. a per-user/volatile token like %APPDATA%) we will not guess + // at; a ':' in the remainder is a stray drive letter or an alternate-data-stream (foo.dll:stream) - both fail + // closed. + if (relativeToImageRoot.Contains('%', StringComparison.Ordinal) || + relativeToImageRoot.Contains(':', StringComparison.Ordinal)) + { + return Drop(registryPath, "residual environment token or stream/drive separator"); + } + + string mappedPath; + + try + { + mappedPath = Path.GetFullPath(Path.Combine(imageRoot.ImageRoot, relativeToImageRoot)); + } + catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException) + { + // A hostile or corrupt hive can yield a value with an embedded NUL (or other invalid path syntax): Path.Combine + // tolerates it but Path.GetFullPath throws. Drop fail-closed rather than let it abort the whole catalog read. + return Drop(registryPath, $"invalid path syntax ({ex.GetType().Name})"); + } + + // A '..' segment can normalize above the image root. Drop it here rather than emit an escaping path: the guard + // would otherwise reject it by THROWING (aborting the whole catalog read for one bad value), whereas an unsafe + // value should be skipped. This also keeps the "every non-null result is under the image root" invariant true. + return !imageRoot.ContainsPath(mappedPath) ? Drop(registryPath, "path escapes the image root") : mappedPath; + } + + private static bool IsDriveAbsolute(string value) => + value.Length >= 3 && IsDriveLetter(value[0]) && value[1] == ':' && value[2] is '\\' or '/'; + + private static bool IsDriveLetter(char c) => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + // Maps a (non-UNC) registry path to the path relative to the image root that it should resolve to, or null when the + // form is unsafe. Env tokens are mapped to their image-relative location WITHOUT host expansion; a bare leaf is + // redirected under the image System32 so the result always carries directory information. + private static string? ToImageRootRelative(string value) + { + if (TryStripTokenPrefix(value, "%SystemRoot%", out string remainder) || + TryStripTokenPrefix(value, "%windir%", out remainder)) + { + return Path.Combine("Windows", remainder); + } + + if (TryStripTokenPrefix(value, "%SystemDrive%", out remainder)) + { + return remainder; + } + + // Machine-scoped program-directory tokens map to their well-known image-relative locations, defaulting to the + // standard folder names (a relocated ProgramFiles is rare and not honored). Per-user / volatile tokens + // (%APPDATA%, %TEMP%, %USERPROFILE%, ...) are deliberately NOT mapped - they have no stable image-relative home. + if (TryStripTokenPrefix(value, "%ProgramFiles(x86)%", out remainder)) + { + return Path.Combine("Program Files (x86)", remainder); + } + + if (TryStripTokenPrefix(value, "%ProgramFiles%", out remainder)) + { + return Path.Combine("Program Files", remainder); + } + + if (TryStripTokenPrefix(value, "%CommonProgramFiles(x86)%", out remainder)) + { + return Path.Combine("Program Files (x86)", "Common Files", remainder); + } + + if (TryStripTokenPrefix(value, "%CommonProgramFiles%", out remainder)) + { + return Path.Combine("Program Files", "Common Files", remainder); + } + + if (TryStripTokenPrefix(value, "%ProgramData%", out remainder)) + { + return Path.Combine("ProgramData", remainder); + } + + if (IsDriveAbsolute(value)) + { + return value[3..]; + } + + // Drive-relative (e.g. C:foo.dll) resolves against the host's current directory on that drive - reject. + if (value.Length >= 2 && IsDriveLetter(value[0]) && value[1] == ':') + { + return null; + } + + return value[0] is '\\' or '/' ? value.TrimStart('\\', '/') : + // Bare leaf or relative subpath: anchor under the image System32 (the leaf-fallback redirect). The combined + // remainder still carries directory information, so the mapped output is never a bare leaf. + Path.Combine("Windows", "System32", value); + } + + // Strips a leading environment token (case-insensitive) plus any immediately following separators, leaving the + // remainder relative. Returns false (and remainder = "") when value does not start with the token. + private static bool TryStripTokenPrefix(string value, string token, out string remainder) + { + // Require the token to sit on a path boundary: end-of-string or a separator. Otherwise "%ProgramFiles%evil" would + // strip to "evil" and re-root under Program Files, while live expansion produces "Program Filesevil" (a different + // directory). Anything past the boundary falls through to the residual-'%' drop instead of being silently re-rooted. + if (value.StartsWith(token, StringComparison.OrdinalIgnoreCase) && + (value.Length == token.Length || value[token.Length] is '\\' or '/')) + { + remainder = value[token.Length..].TrimStart('\\', '/'); + + return true; + } + + remainder = string.Empty; + + return false; + } + + private string? Drop(string registryPath, string reason) + { + logger?.Debug($"{nameof(OfflineImagePathMapper)}: dropping '{registryPath}' ({reason}); cannot map onto the image safely."); + + return null; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImagePathResolver.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImagePathResolver.cs new file mode 100644 index 000000000..4ab41092c --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImagePathResolver.cs @@ -0,0 +1,57 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; + +/// +/// The single seam every offline registry reader uses to turn a stored path into one that is safe to open: map it +/// onto the image (), then assert it stays inside the image ( +/// ). Keeping the map-then-guard contract here means callers never see an unsafe path. +/// +internal sealed class OfflineImagePathResolver(OfflineImagePathMapper mapper, OfflineRootGuard guard) +{ + /// + /// Maps a single registry value onto the image, or when it cannot be mapped safely - both + /// lexically unsafe values (dropped by the mapper) and values that escape the image only through a reparse point (a + /// junction in a hostile or corrupt image, caught by the guard) return , so one bad value is + /// skipped rather than throwing out of the offline enumeration. + /// + public string? Resolve(string? registryValue, string what) + { + string? reRooted = mapper.Map(registryValue); + + if (reRooted is null) { return null; } + + try + { + guard.Assert(reRooted, what); + } + catch (OfflineRootGuardViolationException) + { + // The mapper already drops lexical escapes, so a guard violation here is a reparse point leaving the image - + // hostile/corrupt image content, not our re-rooting bug. Drop it (the guard already logged the violation) so the + // public LoadProviders enumeration honours its "never throws for a bad or hostile image" contract. + return null; + } + + return reRooted; + } + + /// + /// Resolves each segment of a multi-valued (;-separated) registry path string, preserving order and + /// dropping any that cannot be mapped safely. + /// + public IReadOnlyList ResolveMany(string? multiValue, string what) + { + if (string.IsNullOrWhiteSpace(multiValue)) { return []; } + + var result = new List(); + + foreach (string part in multiValue.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (Resolve(part, what) is { } resolved) { result.Add(resolved); } + } + + return result; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImageRoot.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImageRoot.cs new file mode 100644 index 000000000..e1a2e3050 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineImageRoot.cs @@ -0,0 +1,175 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; + +/// +/// Describes a mounted or extracted foreign Windows image: the image root (the directory that contains +/// Windows), the image's Windows / System32 directories, and the on-disk SOFTWARE / +/// SYSTEM hive files. The image root - NOT the host volume root - is the re-rooting base and the root-guard +/// boundary, so an extracted image under D:\Win2019\Windows is bounded by D:\Win2019 (a host +/// C:\Windows\… path then correctly fails the guard) while a mounted volume X:\Windows is bounded by +/// X:\. +/// +internal sealed class OfflineImageRoot +{ + private readonly string _imageRootWithSeparator; + + private OfflineImageRoot(string imageRoot, string windowsDirectory, string softwareHivePath, string systemHivePath) + { + ImageRoot = imageRoot; + WindowsDirectory = windowsDirectory; + System32Directory = Path.Combine(windowsDirectory, "System32"); + SoftwareHivePath = softwareHivePath; + SystemHivePath = systemHivePath; + _imageRootWithSeparator = EnsureTrailingSeparator(imageRoot); + } + + /// The directory that contains the image's Windows folder; the re-root base and root-guard boundary. + public string ImageRoot { get; } + + /// Full path to the image's SOFTWARE hive file. + public string SoftwareHivePath { get; } + + /// The image's Windows\System32 directory. + public string System32Directory { get; } + + /// Full path to the image's SYSTEM hive file. + public string SystemHivePath { get; } + + /// The image's Windows directory (e.g. X:\Windows). + public string WindowsDirectory { get; } + + /// + /// Accepts either the image root (the directory containing Windows, e.g. a mounted volume root X:\ + /// or an extracted D:\Win2019) or the image's Windows directory itself, and resolves the layout. Returns + /// unless BOTH the SOFTWARE and SYSTEM hives are present (validating up front + /// avoids starting a run that can only ever drop legacy providers). A malformed path is also a + /// (fail-closed) result rather than a throw, so a hostile or unreadable image path is skipped, not surfaced as an + /// exception from the public offline-source enumeration. + /// + public static OfflineImageRoot? TryCreate(string imageOrWindowsRoot, ITraceLogger? logger) + { + if (string.IsNullOrWhiteSpace(imageOrWindowsRoot)) + { + logger?.Debug($"{nameof(OfflineImageRoot)}: image path was null or empty."); + + return null; + } + + string full; + + try + { + full = Path.GetFullPath(imageOrWindowsRoot.Trim()); + } + catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException) + { + logger?.Debug($"{nameof(OfflineImageRoot)}: image path '{imageOrWindowsRoot}' is not a valid path: {ex.Message}"); + + return null; + } + + try + { + // Canonicalize the root's OWN reparse points up front so the boundary is comparable like-for-like with the + // reparse-resolved file paths the guard checks: if the image root is itself reached through a junction / folder + // mount-point, a lexical boundary would make every resolved file path fail containment. Resolving once here + // keeps the whole layout (Windows / System32 / hives / re-rooted paths) on one canonical base. + full = ResolveReparsePoints(full); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or PathTooLongException or NotSupportedException) + { + logger?.Debug($"{nameof(OfflineImageRoot)}: could not resolve reparse points for image path '{imageOrWindowsRoot}': {ex.Message}"); + + return null; + } + + // The input is the Windows directory itself when its System32\config\SOFTWARE exists directly under it; + // otherwise treat the input as the image root and look for a Windows subdirectory. + string windowsDirectory = HiveExistsUnderWindows(full) ? full : Path.Combine(full, "Windows"); + + string softwareHivePath = HivePath(windowsDirectory, "SOFTWARE"); + string systemHivePath = HivePath(windowsDirectory, "SYSTEM"); + + if (!File.Exists(softwareHivePath)) + { + logger?.Debug($"{nameof(OfflineImageRoot)}: SOFTWARE hive not found at {softwareHivePath} for input {imageOrWindowsRoot}."); + + return null; + } + + if (!File.Exists(systemHivePath)) + { + logger?.Debug($"{nameof(OfflineImageRoot)}: SYSTEM hive not found at {systemHivePath} for input {imageOrWindowsRoot}."); + + return null; + } + + string? imageRoot = Path.GetDirectoryName(windowsDirectory); + + if (string.IsNullOrEmpty(imageRoot)) + { + logger?.Debug($"{nameof(OfflineImageRoot)}: could not determine the image root (parent of {windowsDirectory})."); + + return null; + } + + return new OfflineImageRoot(imageRoot, windowsDirectory, softwareHivePath, systemHivePath); + } + + /// + /// True when lies under the image root. Segment-boundary safe (a trailing separator + /// is appended to both sides, so a sibling like X:\WindowsEvil never matches an image root of X:\Windows + /// ) and case-insensitive. Purely lexical: callers that must honor reparse points ( for + /// guarded file paths, and for the root boundary itself) canonicalize via + /// first so both sides of the comparison share one reparse-resolved base. + /// + public bool ContainsPath(string fullPath) => + EnsureTrailingSeparator(Path.GetFullPath(fullPath)).StartsWith(_imageRootWithSeparator, StringComparison.OrdinalIgnoreCase); + + // Walks fullPath component-by-component from its root, replacing the first existing component that is a reparse point + // with its final link target and continuing from there, so a junction at ANY level - including the image root itself + // reached via a folder mount-point - is canonicalized before the containment comparison. Shared by TryCreate (the root + // boundary) and OfflineRootGuard (every guarded file path) so the two sides compare like-for-like. A resolution failure + // propagates; each caller decides whether that is fail-closed-null (TryCreate) or a guard violation (OfflineRootGuard). + internal static string ResolveReparsePoints(string fullPath) + { + string root = Path.GetPathRoot(fullPath) ?? string.Empty; + + if (root.Length == 0) { return fullPath; } + + string resolved = root; + string[] segments = fullPath[root.Length..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + foreach (string segment in segments) + { + string candidate = Path.Combine(resolved, segment); + + if ((File.Exists(candidate) || Directory.Exists(candidate)) && + (File.GetAttributes(candidate) & FileAttributes.ReparsePoint) != 0 && + File.ResolveLinkTarget(candidate, returnFinalTarget: true) is { } target) + { + resolved = target.FullName; + + continue; + } + + resolved = candidate; + } + + return resolved; + } + + private static string EnsureTrailingSeparator(string path) => + path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar; + + private static bool HiveExistsUnderWindows(string windowsDirectory) => File.Exists(HivePath(windowsDirectory, "SOFTWARE")); + + private static string HivePath(string windowsDirectory, string hiveName) => + Path.Combine(windowsDirectory, "System32", "config", hiveName); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineRootGuard.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineRootGuard.cs new file mode 100644 index 000000000..6cd381ace --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/Containment/OfflineRootGuard.cs @@ -0,0 +1,57 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; + +/// Thrown when an offline image build is about to open a file path that escapes the image root. +internal sealed class OfflineRootGuardViolationException(string message) : Exception(message); + +/// +/// Fail-closed guard asserting that every file the offline pipeline opens lies under the image root. Same-machine +/// parity testing CANNOT detect host contamination (a host DLL resolves to the same content as the image's), so this +/// is the real isolation gate. A purely lexical prefix check is insufficient because the file open transparently +/// follows NTFS junctions / symlinks, so resolves reparse points in the path chain first and +/// verifies the FINAL target is still inside the image; anything outside throws. +/// +internal sealed class OfflineRootGuard(OfflineImageRoot imageRoot, ITraceLogger? logger) +{ + private readonly OfflineImageRoot _imageRoot = imageRoot; + private readonly ITraceLogger? _logger = logger; + + /// + /// Throws when resolves outside the + /// image root. Reparse points in the existing portion of the path are resolved to their final target first so a + /// junction whose target leaves the image is caught here rather than silently followed by the file open; if that + /// resolution itself fails, the path is rejected (fail closed) rather than accepted on its unresolved lexical form. + /// + public void Assert(string path, string what) + { + string resolved; + + try + { + // GetFullPath, the reparse walk, and the (GetFullPath-based) containment check all go inside the try so a + // hostile junction whose final target is malformed / extended-form / too long fails closed as a violation + // rather than throwing a raw path exception past the resolver's drop translation. Filter mirrors the mapper. + resolved = OfflineImageRoot.ResolveReparsePoints(Path.GetFullPath(path)); + + if (_imageRoot.ContainsPath(resolved)) { return; } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or PathTooLongException or NotSupportedException) + { + throw Violation(path, what, $"its reparse points could not be resolved ({ex.Message})"); + } + + throw Violation(path, what, $"resolves to '{resolved}'"); + } + + private OfflineRootGuardViolationException Violation(string path, string what, string detail) + { + string message = $"Offline {what} path '{path}' {detail}, which is outside the image root."; + _logger?.Warning($"{nameof(OfflineRootGuard)}: {message}"); + + return new OfflineRootGuardViolationException(message); + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IOfflineHiveNativeApi.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IOfflineHiveNativeApi.cs new file mode 100644 index 000000000..6365dfca1 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IOfflineHiveNativeApi.cs @@ -0,0 +1,51 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// The native registry operations orchestrates, behind an interface so the +/// load/recover/open/unload state machine can be unit-tested with a fake that returns crafted error codes - the real +/// dirty-hive recovery path cannot be exercised by the synthetic clean hives the other tests use (clean hives never +/// return the recovery-needed error), and it requires administrator privileges. +/// +internal interface IOfflineHiveNativeApi +{ + /// + /// Enumerates the immediate subkey names under HKLM, used by the orphan sweep to find ELX_ recovery + /// mounts left by a crashed prior run. Behind the seam so the sweep is hermetic under a fake (no real HKLM read). + /// + IReadOnlyList EnumerateHklmSubKeyNames(); + + /// + /// Loads a hive as a private application subtree (RegLoadAppKey, no privilege required). Returns the Win32 + /// error code; on success owns the hive (closing it unloads the hive). + /// + int LoadApplicationHive(string hiveFilePath, out SafeRegistryHandle? root); + + /// + /// Mounts a hive under HKLM\ with log recovery (RegLoadKey). The + /// caller MUST hold a scope. Returns the Win32 error code. + /// + int LoadHiveForRecovery(string mountSubKey, string hiveFilePath); + + /// Opens the root of a hive mounted at HKLM\ for reading. + RegistryKey? OpenMountedRoot(string mountSubKey); + + /// + /// Enters the privileged section enabling backup/restore for a recovery load or unload, or returns + /// when the process is not elevated (the token lacks the privileges). The returned scope + /// reverts the privileges and leaves the section when disposed. + /// + IDisposable? TryEnterRecoveryPrivilege(ITraceLogger? logger); + + /// + /// Unmounts the hive at HKLM\ (RegUnLoadKey). The caller MUST hold a + /// scope. Returns the Win32 error code. + /// + int UnloadHive(string mountSubKey); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IOfflineImageProviderExtractor.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IOfflineImageProviderExtractor.cs new file mode 100644 index 000000000..d8cee2815 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IOfflineImageProviderExtractor.cs @@ -0,0 +1,29 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Provider.Resolution; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// The offline image-extraction surface the public OfflineImageProviderSource facade orchestrates over. +/// Extracted as an interface so the facade's enumeration / de-dup / provenance-stamping logic can be unit-tested with +/// a fake extractor - real WEVT and message-table builds need real DLLs and only run against a real image. +/// +internal interface IOfflineImageProviderExtractor : IDisposable +{ + /// Distinct legacy provider names registered under the image's SYSTEM hive. + IReadOnlyList EnumerateLegacyProviderNames(); + + /// Reads the image's OS provenance (build / revision / edition / display version) from its SOFTWARE hive. + SourceOsProvenance ReadImageProvenance(); + + /// The modern (manifest) publisher registrations in the image's SOFTWARE hive. + IReadOnlyList ReadModernRegistrations(); + + /// Builds a pure-legacy provider, or when it has no usable legacy message files. + ProviderDetails? TryBuildLegacyProvider(string providerName); + + /// Builds a modern provider from its registration, or when its manifest cannot be parsed. + ProviderDetails? TryBuildModernProvider(OfflinePublisherRegistration registration); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IWimNativeApi.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IWimNativeApi.cs new file mode 100644 index 000000000..a216a55e8 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/IWimNativeApi.cs @@ -0,0 +1,38 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// The native WIM operations orchestrates, behind an interface so the +/// read/validate/ extract/cancel state machine can be unit-tested with a fake that returns crafted image lists and +/// Win32 results - the real WIMApplyImage path needs administrator privileges and a real multi-GB image. The +/// seam is COARSE on purpose: no native handle ever crosses it, so the fake needs no real wimgapi handle, and +/// the handle + message-callback lifetime stays entirely inside . +/// +internal interface IWimNativeApi +{ + /// + /// Applies (extracts) the 1-based of into + /// , using for WIMGAPI scratch. Returns a + /// Win32 error code (0 = success; ERROR_REQUEST_ABORTED when cancelled). Requires elevation. + /// + int ApplyImage( + string wimPath, + int imageIndex, + string destinationDirectory, + string scratchDirectory, + CancellationToken cancellationToken, + ITraceLogger? logger); + + /// Whether the current process is elevated (administrator) - required before . + bool IsProcessElevated(); + + /// + /// Reads the image-index metadata from (no elevation needed). Never throws for a bad, + /// corrupt, locked, or non-WIM file - it returns . + /// + WimImageList ReadImageList(string wimPath, ITraceLogger? logger); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineHiveLoadStatus.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineHiveLoadStatus.cs new file mode 100644 index 000000000..eee0e8c50 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineHiveLoadStatus.cs @@ -0,0 +1,24 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// The outcome of attempting to load an offline image registry hive. Distinguishes the cases the caller must +/// surface differently: a clean load, a path that is not a hive at all, a dirty hive that needs recovery the current +/// (non-elevated) process cannot perform, and a recovery that was attempted but failed. +/// +internal enum OfflineHiveLoadStatus +{ + /// The hive loaded (either directly, or recovered under administrator privileges). + Loaded, + + /// The file is missing or is not a registry hive (no regf signature); no recovery was attempted. + NotAHive, + + /// The hive is dirty and needs registry recovery, which requires running as administrator. + NeedsElevation, + + /// The hive needed recovery and the process is elevated, but the recovery load still failed. + RecoveryFailed +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineHiveNativeApi.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineHiveNativeApi.cs new file mode 100644 index 000000000..62a654dba --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineHiveNativeApi.cs @@ -0,0 +1,44 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// The production , calling the real Win32 registry APIs. +internal sealed class OfflineHiveNativeApi : IOfflineHiveNativeApi +{ + internal static OfflineHiveNativeApi Instance { get; } = new(); + + public IReadOnlyList EnumerateHklmSubKeyNames() + { + using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Default); + + return hklm.GetSubKeyNames(); + } + + public int LoadApplicationHive(string hiveFilePath, out SafeRegistryHandle? root) + { + int result = NativeMethods.RegLoadAppKey(hiveFilePath, out nint handle, NativeMethods.KEY_READ, 0, 0); + root = result == Win32ErrorCodes.ERROR_SUCCESS ? new SafeRegistryHandle(handle, ownsHandle: true) : null; + + return result; + } + + public int LoadHiveForRecovery(string mountSubKey, string hiveFilePath) => + NativeMethods.RegLoadKey(NativeMethods.HKEY_LOCAL_MACHINE, mountSubKey, hiveFilePath); + + public RegistryKey? OpenMountedRoot(string mountSubKey) + { + using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Default); + + return hklm.OpenSubKey(mountSubKey, writable: false); + } + + public IDisposable? TryEnterRecoveryPrivilege(ITraceLogger? logger) => BackupRestorePrivilegeScope.TryAcquire(logger); + + public int UnloadHive(string mountSubKey) => NativeMethods.RegUnLoadKey(NativeMethods.HKEY_LOCAL_MACHINE, mountSubKey); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineImageProviderExtractor.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineImageProviderExtractor.cs new file mode 100644 index 000000000..a66f7aaaa --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineImageProviderExtractor.cs @@ -0,0 +1,146 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; +using EventLogExpert.Eventing.PublisherMetadata.Wevt; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// Extracts provider metadata from a mounted or extracted foreign Windows image entirely offline: it loads the +/// image's SOFTWARE and SYSTEM hives, reads the modern publisher catalog and the legacy +/// Services\EventLog registrations, and builds with every file path re-rooted +/// onto the image and guard-checked - never reading host registry or host files. The modern reader is given the +/// image's own , so even a manifest provider's legacy table population +/// stays host-free. It exposes the publisher catalog together with per-provider builders for the modern and legacy +/// paths. +/// +internal sealed class OfflineImageProviderExtractor : IOfflineImageProviderExtractor +{ + private readonly OfflinePublisherCatalog _catalog; + private readonly OfflineLegacyProviderBuilder _legacyBuilder; + private readonly OfflineLegacyMessageFileResolver _legacyResolver; + private readonly ITraceLogger? _logger; + private readonly OfflineRegistryHive _softwareHive; + private readonly OfflineRegistryHive _systemHive; + + private OfflineImageProviderExtractor( + OfflineRegistryHive softwareHive, + OfflineRegistryHive systemHive, + OfflinePublisherCatalog catalog, + OfflineLegacyMessageFileResolver legacyResolver, + OfflineLegacyProviderBuilder legacyBuilder, + ITraceLogger? logger) + { + _softwareHive = softwareHive; + _systemHive = systemHive; + _catalog = catalog; + _legacyResolver = legacyResolver; + _legacyBuilder = legacyBuilder; + _logger = logger; + } + + /// + /// Loads the image's hives and wires the offline readers, or returns when either hive + /// cannot be loaded (logging the specific reason - not a hive, recovery failed, or recovery needs administrator). The + /// caller owns the returned extractor and must dispose it (which unloads both hives). Throws + /// if the image's hive paths escape the image root (a malformed + /// image whose config directory is a junction out of the image). + /// + public static OfflineImageProviderExtractor? TryCreate(OfflineImageRoot imageRoot, ITraceLogger? logger) + { + // Jail-check the hive paths BEFORE loading them: if Windows\System32\config is a junction that leaves the image, + // staging and loading the hive would read out-of-image (possibly host) registry data. Fail closed. + var guard = new OfflineRootGuard(imageRoot, logger); + guard.Assert(imageRoot.SoftwareHivePath, "SOFTWARE hive"); + guard.Assert(imageRoot.SystemHivePath, "SYSTEM hive"); + + OfflineRegistryHive? softwareHive = LoadHive(imageRoot.SoftwareHivePath, "SOFTWARE", logger); + + if (softwareHive is null) { return null; } + + OfflineRegistryHive? systemHive = LoadHive(imageRoot.SystemHivePath, "SYSTEM", logger); + + if (systemHive is null) + { + softwareHive.Dispose(); + + return null; + } + + var pathResolver = new OfflineImagePathResolver(new OfflineImagePathMapper(imageRoot, logger), guard); + var catalog = new OfflinePublisherCatalog(pathResolver, logger); + var legacyResolver = new OfflineLegacyMessageFileResolver(systemHive.Root, pathResolver, logger); + var legacyBuilder = new OfflineLegacyProviderBuilder(legacyResolver, logger); + + return new OfflineImageProviderExtractor(softwareHive, systemHive, catalog, legacyResolver, legacyBuilder, logger); + } + + public void Dispose() + { + _systemHive.Dispose(); + _softwareHive.Dispose(); + } + + /// Distinct legacy provider names registered under the image's SYSTEM hive. + public IReadOnlyList EnumerateLegacyProviderNames() => _legacyResolver.EnumerateProviderNames(); + + /// Reads the image's OS provenance from its SOFTWARE hive (never the host registry). + public SourceOsProvenance ReadImageProvenance() => SourceOsProvenance.ReadFromSoftwareHive(_softwareHive.Root, _logger); + + /// The modern (manifest) publisher registrations declared in the image's SOFTWARE hive. + public IReadOnlyList ReadModernRegistrations() => _catalog.ReadRegistrations(_softwareHive.Root); + + /// Builds a pure-legacy provider (no WEVT manifest) from the image's SYSTEM hive registration. + public ProviderDetails? TryBuildLegacyProvider(string providerName) => _legacyBuilder.TryBuild(providerName); + + /// + /// Builds a modern provider from its registration, resolving its WEVT manifest and message tables from the image. + /// Returns when the registration has no resource file or the manifest cannot be parsed. + /// + public ProviderDetails? TryBuildModernProvider(OfflinePublisherRegistration registration) + { + if (string.IsNullOrEmpty(registration.ResourceFilePath)) + { + _logger?.Debug($"{nameof(OfflineImageProviderExtractor)}: publisher {registration.ProviderName} has no resource file; skipping modern build."); + + return null; + } + + return OfflineWevtProviderReader.TryBuildProviderDetails( + registration.ResourceFilePath, + registration.MessageFilePaths, + registration.ParameterFilePath, + registration.PublisherGuid, + registration.ProviderName, + _legacyResolver, + _logger); + } + + // Loads one hive, logging the specific failure reason at Error so a non-elevated user reading a real (dirty) image + // sees the actionable "re-run as administrator" message rather than a silent generic failure. + private static OfflineRegistryHive? LoadHive(string hivePath, string hiveName, ITraceLogger? logger) + { + OfflineHiveLoadResult result = OfflineRegistryHive.TryLoad(hivePath, logger); + + switch (result.Status) + { + case OfflineHiveLoadStatus.Loaded: + return result.Hive; + case OfflineHiveLoadStatus.NeedsElevation: + logger?.Error($"The image's {hiveName} hive needs registry recovery, which requires running as administrator. Re-run elevated."); + + return null; + case OfflineHiveLoadStatus.RecoveryFailed: + logger?.Error($"The image's {hiveName} hive could not be recovered; it may be corrupt or missing its transaction logs."); + + return null; + default: + logger?.Error($"The image's {hiveName} hive is missing or is not a registry hive."); + + return null; + } + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineLegacyMessageFileResolver.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineLegacyMessageFileResolver.cs new file mode 100644 index 000000000..956758676 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineLegacyMessageFileResolver.cs @@ -0,0 +1,165 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; +using EventLogExpert.Logging.Abstractions; +using Microsoft.Win32; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// Offline counterpart of : resolves a legacy provider's +/// message/category/parameter files from a foreign image's SYSTEM hive instead of the host HKLM\SYSTEM. +/// A statically loaded hive has no CurrentControlSet symlink, so the active control set is resolved via +/// Select\Current -> ControlSet{NNN}. The extension filter and category-first ordering mirror +/// for parity with native-built databases; the ParameterMessageFile is resolved +/// separately via so the offline parameter table matches those same +/// native-built databases (the live reads it only to discard). Values are read without +/// host environment expansion and re-rooted onto the image. Unlike the live reader it does NOT skip the Security/State +/// admin channels - reading a hive file needs no elevation, so offline is intentionally more complete there. +/// +internal sealed class OfflineLegacyMessageFileResolver( + RegistryKey systemRoot, + OfflineImagePathResolver pathResolver, + ITraceLogger? logger) : ILegacyMessageFileResolver +{ + private static readonly string[] s_supportedExtensions = [".dll", ".exe"]; + + /// + /// Distinct legacy provider names registered under any channel that carry an EventMessageFile. Used to + /// discover legacy providers in the image. + /// + public IReadOnlyList EnumerateProviderNames() + { + using RegistryKey? eventLogKey = OpenEventLogKey(); + + if (eventLogKey is null) { return []; } + + var names = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string channelName in eventLogKey.GetSubKeyNames()) + { + using RegistryKey? channelKey = eventLogKey.OpenSubKey(channelName); + + if (channelKey is null) { continue; } + + foreach (string providerName in channelKey.GetSubKeyNames()) + { + using RegistryKey? providerKey = channelKey.OpenSubKey(providerName); + + if (providerKey is null) { continue; } + + if (ReadString(providerKey, "EventMessageFile") is { Length: > 0 } && seen.Add(providerName)) + { + names.Add(providerName); + } + } + } + + return names; + } + + public IReadOnlyList GetMessageFilesForLegacyProvider(string providerName) => + ResolveFromRegisteringChannel( + providerName, + (providerKey, eventMessageFile) => ResolveProviderFiles(eventMessageFile, ReadString(providerKey, "CategoryMessageFile"))); + + public IReadOnlyList GetParameterFilesForLegacyProvider(string providerName) => + ResolveFromRegisteringChannel( + providerName, + (providerKey, _) => ReadString(providerKey, "ParameterMessageFile") is { } parameterMessageFile + ? ResolveProviderFiles(parameterMessageFile, categoryMessageFile: null) + : []); + + // Read without host expansion so REG_EXPAND_SZ tokens reach the mapper as literal %tokens (no effect on REG_SZ). + private static string? ReadString(RegistryKey key, string name) => + key.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames) as string; + + private RegistryKey? OpenEventLogKey() + { + if (systemRoot.OpenSubKey("Select") is not { } selectKey) + { + logger?.Debug($"{nameof(OfflineLegacyMessageFileResolver)}: SYSTEM\\Select not found in the image hive."); + + return null; + } + + using (selectKey) + { + if (selectKey.GetValue("Current") is not int currentControlSet || currentControlSet <= 0) + { + logger?.Debug($"{nameof(OfflineLegacyMessageFileResolver)}: SYSTEM\\Select\\Current missing or invalid."); + + return null; + } + + string eventLogPath = $@"ControlSet{currentControlSet:D3}\Services\EventLog"; + RegistryKey? eventLogKey = systemRoot.OpenSubKey(eventLogPath); + + if (eventLogKey is null) + { + logger?.Debug($"{nameof(OfflineLegacyMessageFileResolver)}: {eventLogPath} not found in the image SYSTEM hive."); + } + + return eventLogKey; + } + } + + private IReadOnlyList ResolveFromRegisteringChannel( + string providerName, + Func> resolveFromProviderKey) + { + using RegistryKey? eventLogKey = OpenEventLogKey(); + + if (eventLogKey is null) { return []; } + + foreach (string channelName in eventLogKey.GetSubKeyNames()) + { + using RegistryKey? channelKey = eventLogKey.OpenSubKey(channelName); + using RegistryKey? providerKey = channelKey?.OpenSubKey(providerName); + + if (providerKey is null) { continue; } + + // Mirror RegistryProvider exactly (for parity with native-built databases): the first channel carrying an + // EventMessageFile wins, even if extension filtering later empties it. + if (ReadString(providerKey, "EventMessageFile") is not { } eventMessageFile) { continue; } + + return resolveFromProviderKey(providerKey, eventMessageFile); + } + + return []; + } + + private IReadOnlyList ResolveProviderFiles(string eventMessageFile, string? categoryMessageFile) + { + // Filter to .dll/.exe on the raw value, mirroring the live reader (FltMgr registers a .sys driver here that + // must not be loaded). + var messageFiles = eventMessageFile + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(path => s_supportedExtensions.Contains(Path.GetExtension(path).ToLowerInvariant())) + .ToList(); + + var orderedRawFiles = new List(); + + // `is not null` (not IsNullOrEmpty) mirrors the live reader's category-first ordering exactly. + if (categoryMessageFile is not null) + { + orderedRawFiles.Add(categoryMessageFile); + orderedRawFiles.AddRange(messageFiles.Where(path => !string.Equals(path, categoryMessageFile, StringComparison.Ordinal))); + } + else + { + orderedRawFiles.AddRange(messageFiles); + } + + var resolved = new List(); + + foreach (string rawFile in orderedRawFiles) + { + if (pathResolver.Resolve(rawFile, "legacy message file") is { } file) { resolved.Add(file); } + } + + return resolved; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineLegacyProviderBuilder.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineLegacyProviderBuilder.cs new file mode 100644 index 000000000..7ec81818e --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineLegacyProviderBuilder.cs @@ -0,0 +1,41 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// Builds a for a provider that has only a legacy (pre-manifest) registration - no +/// WEVT manifest - so it would otherwise be dropped (the modern reader's legacy population runs only AFTER a manifest +/// parse succeeds). Resolution goes through and +/// ; it deliberately does NOT touch , whose +/// static initializer enumerates host providers. +/// +internal sealed class OfflineLegacyProviderBuilder(OfflineLegacyMessageFileResolver legacyResolver, ITraceLogger? logger) +{ + public ProviderDetails? TryBuild(string providerName) + { + IReadOnlyList messageFiles = legacyResolver.GetMessageFilesForLegacyProvider(providerName); + + if (LegacyMessageFileSource.TryCreate(messageFiles, providerName, logger) is not { } messageSource) + { + logger?.Debug($"{nameof(OfflineLegacyProviderBuilder)}: no usable legacy message files for provider {providerName}."); + + return null; + } + + var details = new ProviderDetails { ProviderName = providerName }; + details.SetLazyMessageSource(messageSource); + + IReadOnlyList parameterFiles = legacyResolver.GetParameterFilesForLegacyProvider(providerName); + + if (LegacyMessageFileSource.TryCreate(parameterFiles, providerName, logger) is { } parameterSource) + { + details.SetLazyParameterSource(parameterSource); + } + + return details; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflinePublisherCatalog.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflinePublisherCatalog.cs new file mode 100644 index 000000000..58437f861 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflinePublisherCatalog.cs @@ -0,0 +1,70 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; +using EventLogExpert.Logging.Abstractions; +using Microsoft.Win32; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// One modern (manifest) publisher registered under the image's WINEVT\Publishers, with re-rooted file +/// paths. +/// +internal sealed record OfflinePublisherRegistration( + Guid PublisherGuid, + string ProviderName, + string? ResourceFilePath, + IReadOnlyList MessageFilePaths, + string? ParameterFilePath); + +/// +/// Reads the modern publisher registrations from a foreign image's SOFTWARE hive ( +/// Microsoft\Windows\CurrentVersion\WINEVT\Publishers). Each {guid} subkey carries the provider name as +/// its default value and the resource/message/parameter file paths as values, read without host environment expansion +/// and mapped onto the image. Malformed entries (non-GUID key, missing name) are skipped rather than failing the whole +/// read. +/// +internal sealed class OfflinePublisherCatalog(OfflineImagePathResolver pathResolver, ITraceLogger? logger) +{ + private const string PublishersKeyPath = @"Microsoft\Windows\CurrentVersion\WINEVT\Publishers"; + + public IReadOnlyList ReadRegistrations(RegistryKey softwareRoot) + { + using RegistryKey? publishers = softwareRoot.OpenSubKey(PublishersKeyPath); + + if (publishers is null) + { + logger?.Debug($"{nameof(OfflinePublisherCatalog)}: {PublishersKeyPath} not present in the image SOFTWARE hive."); + + return []; + } + + var registrations = new List(); + + foreach (string subKeyName in publishers.GetSubKeyNames()) + { + if (!Guid.TryParse(subKeyName, out Guid publisherGuid)) { continue; } + + using RegistryKey? publisherKey = publishers.OpenSubKey(subKeyName); + + if (publisherKey is null) { continue; } + + if (ReadString(publisherKey, name: null) is not { Length: > 0 } providerName) { continue; } + + registrations.Add(new OfflinePublisherRegistration( + publisherGuid, + providerName, + pathResolver.Resolve(ReadString(publisherKey, "ResourceFileName"), "publisher resource"), + pathResolver.ResolveMany(ReadString(publisherKey, "MessageFileName"), "publisher message file"), + pathResolver.Resolve(ReadString(publisherKey, "ParameterFileName"), "publisher parameter file"))); + } + + return registrations; + } + + // Read without host expansion: .NET otherwise expands REG_EXPAND_SZ against the HOST environment, so the literal + // %token must reach the mapper. (No effect on REG_SZ values, so this is safe for both kinds.) + private static string? ReadString(RegistryKey key, string? name) => + key.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames) as string; +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineRegistryHive.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineRegistryHive.cs new file mode 100644 index 000000000..72b0897f4 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineRegistryHive.cs @@ -0,0 +1,375 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using Microsoft.Win32; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// The result of : the outcome plus the loaded hive on success. +internal readonly record struct OfflineHiveLoadResult(OfflineHiveLoadStatus Status, OfflineRegistryHive? Hive) +{ + internal static OfflineHiveLoadResult Failed(OfflineHiveLoadStatus status) => new(status, null); +} + +/// +/// Loads an offline image registry hive file (an image's SOFTWARE / SYSTEM) and exposes its root as +/// a managed . The hive is first STAGED to a writable temp copy (the source image may be +/// read-only and a dirty hive is modified during recovery). A clean hive loads via RegLoadAppKey (no admin). A +/// dirty hive captured from a live/imaged system carries pending dual-log (.LOG1/.LOG2) data that +/// RegLoadAppKey cannot replay (it fails with ); such a hive is +/// recovered via RegLoadKey under backup/restore privileges (administrator). The recovery mount lives under +/// HKLM\ELX_<pid>_<guid> and is tracked by a machine-global ownership mutex so a crashed process's +/// orphaned mount is reclaimed by the next run. The staged copy and any mount are released on . +/// +internal sealed class OfflineRegistryHive : IDisposable +{ + private static readonly byte[] s_hiveSignature = "regf"u8.ToArray(); + private static readonly Lock s_sweepGate = new(); + + private static bool s_sweptOrphanedMounts; + + private readonly ITraceLogger? _logger; + private readonly string? _mountSubKey; + private readonly IOfflineHiveNativeApi _nativeApi; + private readonly Mutex? _ownershipMutex; + private readonly string _stagingDirectory; + + private bool _disposed; + + private OfflineRegistryHive( + RegistryKey root, + string stagingDirectory, + string? mountSubKey, + Mutex? ownershipMutex, + IOfflineHiveNativeApi nativeApi, + ITraceLogger? logger) + { + Root = root; + _stagingDirectory = stagingDirectory; + _mountSubKey = mountSubKey; + _ownershipMutex = ownershipMutex; + _nativeApi = nativeApi; + _logger = logger; + } + + /// Root key of the loaded hive; navigate with . + public RegistryKey Root { get; } + + public static OfflineHiveLoadResult TryLoad(string hiveFilePath, ITraceLogger? logger, IOfflineHiveNativeApi? nativeApi = null) + { + nativeApi ??= OfflineHiveNativeApi.Instance; + + if (!File.Exists(hiveFilePath)) + { + logger?.Debug($"{nameof(OfflineRegistryHive)}: hive file not found: {hiveFilePath}."); + + return OfflineHiveLoadResult.Failed(OfflineHiveLoadStatus.NotAHive); + } + + string mountSubKey = $"ELX_{Environment.ProcessId}_{Guid.NewGuid():N}"; + string stagingDirectory = Path.Combine(Path.GetTempPath(), mountSubKey); + + try + { + Directory.CreateDirectory(stagingDirectory); + + string stagedHive = StageHive(hiveFilePath, stagingDirectory); + + // A non-hive file must never reach the privileged recovery path; a real hive starts with the "regf" signature. + // The probe stream is fully closed before any native load so it cannot cause a sharing violation. + if (!HasHiveSignature(stagedHive)) + { + logger?.Debug($"{nameof(OfflineRegistryHive)}: {hiveFilePath} is not a registry hive (no regf signature)."); + TryDeleteDirectory(stagingDirectory); + + return OfflineHiveLoadResult.Failed(OfflineHiveLoadStatus.NotAHive); + } + + int appKeyResult = nativeApi.LoadApplicationHive(stagedHive, out var appKeyHandle); + + if (appKeyResult == Win32ErrorCodes.ERROR_SUCCESS && appKeyHandle is not null) + { + // Clean hive: the handle owns the hive (closing it auto-unloads), so there is no mount to track. + RegistryKey root = RegistryKey.FromHandle(appKeyHandle); + + return new OfflineHiveLoadResult( + OfflineHiveLoadStatus.Loaded, + new OfflineRegistryHive(root, stagingDirectory, mountSubKey: null, ownershipMutex: null, nativeApi, logger)); + } + + appKeyHandle?.Dispose(); + + // It IS a hive (regf) but RegLoadAppKey failed: a dirty hive needing dual-log recovery via RegLoadKey. + return RecoverDirtyHive(stagedHive, stagingDirectory, mountSubKey, nativeApi, logger, appKeyResult); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + logger?.Debug($"{nameof(OfflineRegistryHive)}: failed to load hive {hiveFilePath}: {ex}"); + + TryDeleteDirectory(stagingDirectory); + + return OfflineHiveLoadResult.Failed(OfflineHiveLoadStatus.RecoveryFailed); + } + } + + public void Dispose() + { + if (_disposed) { return; } + + _disposed = true; + + Root.Dispose(); + + // A recovery mount (named subtree under HKLM) must be explicitly unloaded; a clean app-hive handle already + // unloaded when Root closed above. + if (_mountSubKey is not null) + { + UnloadMountedHive(_nativeApi, _mountSubKey, _logger); + _ownershipMutex?.Dispose(); + } + + TryDeleteDirectory(_stagingDirectory); + } + + private static void CopyWritable(string source, string destination) + { + File.Copy(source, destination, overwrite: true); + + // File.Copy preserves the source attributes; clear read-only so the staged hive is writable for the loaders. + File.SetAttributes(destination, FileAttributes.Normal); + } + + private static bool HasHiveSignature(string filePath) + { + Span header = stackalloc byte[s_hiveSignature.Length]; + + using (FileStream stream = File.OpenRead(filePath)) + { + if (stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false) < header.Length) { return false; } + } + + return header.SequenceEqual(s_hiveSignature); + } + + private static bool IsMountOwnerAlive(string mountSubKey) + { + try + { + using Mutex owner = Mutex.OpenExisting($"Global\\{mountSubKey}"); + + return true; + } + catch (WaitHandleCannotBeOpenedException) + { + return false; + } + catch (UnauthorizedAccessException) + { + // The beacon exists but is not openable by us; treat as alive (do not reclaim someone else's live mount). + return true; + } + } + + private static OfflineHiveLoadResult RecoverDirtyHive( + string stagedHive, + string stagingDirectory, + string mountSubKey, + IOfflineHiveNativeApi nativeApi, + ITraceLogger? logger, + int appKeyResult) + { + logger?.Debug($"{nameof(OfflineRegistryHive)}: RegLoadAppKey failed (error {appKeyResult}); attempting dual-log recovery via RegLoadKey."); + + SweepOrphanedMountsOnce(nativeApi, logger); + + // Publish the ownership beacon BEFORE RegLoadKey makes the ELX_ mount visible under HKLM. Were the beacon + // created after the mount, a concurrent sibling process's orphan sweep could observe the new mount before its + // beacon exists, judge it ownerless, and unload it mid-load. The beacon is held only as an open handle, so a + // hard crash auto-releases it and the next run's sweep reclaims this mount. + Mutex? ownershipMutex = TryCreateOwnershipBeacon(mountSubKey, logger); + + if (ownershipMutex is null) + { + // Fail closed: without a beacon a concurrent sibling sweep would judge this mount ownerless and could unload + // it mid-use, so abort recovery rather than create an unprotected mount. + logger?.Error($"{nameof(OfflineRegistryHive)}: cannot publish the ownership beacon for {mountSubKey}; aborting recovery to avoid a concurrent sweep unloading a live mount."); + TryDeleteDirectory(stagingDirectory); + + return OfflineHiveLoadResult.Failed(OfflineHiveLoadStatus.RecoveryFailed); + } + + using (IDisposable? privilege = nativeApi.TryEnterRecoveryPrivilege(logger)) + { + if (privilege is null) + { + ownershipMutex?.Dispose(); + TryDeleteDirectory(stagingDirectory); + + return OfflineHiveLoadResult.Failed(OfflineHiveLoadStatus.NeedsElevation); + } + + int loadResult = nativeApi.LoadHiveForRecovery(mountSubKey, stagedHive); + + if (loadResult != Win32ErrorCodes.ERROR_SUCCESS) + { + logger?.Debug($"{nameof(OfflineRegistryHive)}: RegLoadKey recovery failed for {mountSubKey} (error {loadResult})."); + + ownershipMutex?.Dispose(); + TryDeleteDirectory(stagingDirectory); + + return OfflineHiveLoadResult.Failed(OfflineHiveLoadStatus.RecoveryFailed); + } + } + + // The mount succeeded and its ownership beacon is already published. Open the root OUTSIDE the privileged section. + try + { + RegistryKey? root = nativeApi.OpenMountedRoot(mountSubKey); + + if (root is null) + { + logger?.Debug($"{nameof(OfflineRegistryHive)}: recovery mount {mountSubKey} opened no root."); + UnloadMountedHive(nativeApi, mountSubKey, logger); + ownershipMutex?.Dispose(); + TryDeleteDirectory(stagingDirectory); + + return OfflineHiveLoadResult.Failed(OfflineHiveLoadStatus.RecoveryFailed); + } + + return new OfflineHiveLoadResult( + OfflineHiveLoadStatus.Loaded, + new OfflineRegistryHive(root, stagingDirectory, mountSubKey, ownershipMutex, nativeApi, logger)); + } + catch + { + UnloadMountedHive(nativeApi, mountSubKey, logger); + ownershipMutex?.Dispose(); + TryDeleteDirectory(stagingDirectory); + + throw; + } + } + + // Copies the hive plus any .LOG/.LOG1/.LOG2 sidecars (needed for dirty-hive dual-log replay) into the staging + // directory, clearing the read-only attribute the source may carry from read-only media so the loaders can open it. + private static string StageHive(string hiveFilePath, string stagingDirectory) + { + string fileName = Path.GetFileName(hiveFilePath); + string stagedHive = Path.Combine(stagingDirectory, fileName); + CopyWritable(hiveFilePath, stagedHive); + + string[] logSuffixes = [".LOG", ".LOG1", ".LOG2"]; + + foreach (string suffix in logSuffixes) + { + string sidecar = hiveFilePath + suffix; + + if (File.Exists(sidecar)) + { + CopyWritable(sidecar, Path.Combine(stagingDirectory, fileName + suffix)); + } + } + + return stagedHive; + } + + // Reclaims HKLM mounts left by a crashed prior run: a mount is orphaned when its Global ownership mutex no longer + // exists (the owner died and the OS released the handle). A live owner's mutex still opens, so a recycled PID or a + // concurrent sibling app (CLI vs UI) is never reclaimed out from under a live process. + private static void SweepOrphanedMountsOnce(IOfflineHiveNativeApi nativeApi, ITraceLogger? logger) + { + lock (s_sweepGate) + { + if (s_sweptOrphanedMounts) { return; } + + s_sweptOrphanedMounts = true; + } + + try + { + foreach (string mountSubKey in nativeApi.EnumerateHklmSubKeyNames()) + { + if (!mountSubKey.StartsWith("ELX_", StringComparison.Ordinal) || IsMountOwnerAlive(mountSubKey)) { continue; } + + logger?.Debug($"{nameof(OfflineRegistryHive)}: reclaiming orphaned recovery mount {mountSubKey}."); + UnloadMountedHive(nativeApi, mountSubKey, logger); + TryDeleteDirectory(Path.Combine(Path.GetTempPath(), mountSubKey)); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + logger?.Debug($"{nameof(OfflineRegistryHive)}: orphan-mount sweep failed: {ex.Message}"); + + // Re-arm so a later load in this process can retry: a transient failure should not leak orphaned mounts + // until the next process run. + lock (s_sweepGate) { s_sweptOrphanedMounts = false; } + } + } + + // A machine-global ownership beacon (an open, unowned named mutex) so the next run's sweep can tell this mount has a + // live owner. Returns null if it cannot be created; recovery then fails closed (an unprotected mount could be unloaded + // mid-use by a concurrent sweep) rather than proceeding without crash-reclaim protection. + private static Mutex? TryCreateOwnershipBeacon(string mountSubKey, ITraceLogger? logger) + { + try + { + return new Mutex(initiallyOwned: false, name: $"Global\\{mountSubKey}"); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + logger?.Debug($"{nameof(OfflineRegistryHive)}: could not create ownership beacon for {mountSubKey}: {ex.Message}"); + + return null; + } + } + + private static void TryDeleteDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) { Directory.Delete(directory, recursive: true); } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup of the staging copy; a leftover temp dir is not fatal. + } + } + + /// + /// The SINGLE caller of RegUnLoadKey: enters the backup/restore privilege, unmounts the hive (retrying + /// with a GC between attempts to release any registry key a caller failed to dispose), then reverts the privilege. + /// Used by , the recovery partial-failure path, and the orphan sweep, so every unmount is + /// privileged and bounded. + /// + private static void UnloadMountedHive(IOfflineHiveNativeApi nativeApi, string mountSubKey, ITraceLogger? logger) + { + using IDisposable? privilege = nativeApi.TryEnterRecoveryPrivilege(logger); + + if (privilege is null) + { + logger?.Error($"{nameof(OfflineRegistryHive)}: cannot unmount {mountSubKey} - backup/restore privilege is unavailable; mount leaked until reclaimed."); + + return; + } + + for (var attempt = 1; ; attempt++) + { + int result = nativeApi.UnloadHive(mountSubKey); + + if (result == Win32ErrorCodes.ERROR_SUCCESS) { return; } + + if (attempt >= 3) + { + logger?.Error($"{nameof(OfflineRegistryHive)}: failed to unmount {mountSubKey} after {attempt} attempts (error {result}); it will be reclaimed on a later run."); + + return; + } + + // A still-open key under the mount blocks the unload; force finalization of any leaked RegistryKey, then retry. + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineWimExtractStatus.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineWimExtractStatus.cs new file mode 100644 index 000000000..53458e852 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineWimExtractStatus.cs @@ -0,0 +1,16 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// The outcome of extracting an image from a WIM via . +public enum OfflineWimExtractStatus +{ + Extracted, + NotAWim, + IndexOutOfRange, + NeedsElevation, + ApplyFailed, + InsufficientSpace, + Cancelled +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineWimImage.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineWimImage.cs new file mode 100644 index 000000000..fc8fb74c1 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/OfflineWimImage.cs @@ -0,0 +1,265 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// +/// The result of +/// : the outcome +/// plus the extracted (disposable) image on success. +/// +public readonly record struct OfflineWimExtractResult(OfflineWimExtractStatus Status, OfflineWimImage? Image) +{ + internal static OfflineWimExtractResult Failed(OfflineWimExtractStatus status) => new(status, null); +} + +/// +/// Extracts a single image from a foreign Windows .wim/.esd to a temp folder so the existing +/// offline Directory source can read it. A DISM/WIM filter MOUNT holds the registry hives so no file IO can copy them; +/// this EXTRACTS via WIMApplyImage instead, producing a plain tree whose \Windows\System32\config\... +/// layout matches a mounted volume. The extracted copy is deleted on . The static entry points +/// NEVER throw for a bad / corrupt / locked / non-WIM image - they return a typed +/// . +/// +public sealed class OfflineWimImage : IDisposable +{ + private const FileAttributes UndeletableAttributes = FileAttributes.ReadOnly | FileAttributes.System | FileAttributes.Hidden; + + private readonly ITraceLogger? _logger; + private bool _disposed; + + private OfflineWimImage(string extractedRoot, ITraceLogger? logger) + { + ExtractedRoot = extractedRoot; + _logger = logger; + } + + /// Root of the extracted image; pass to the offline Directory source. Valid until . + public string ExtractedRoot { get; } + + /// + /// Reads the image-index metadata from so a caller can list / validate + /// --wim-index. Needs no elevation and never throws for a bad image. + /// + public static WimImageList ReadIndexList(string wimPath, ITraceLogger? logger) => + ReadIndexList(wimPath, WimNativeApi.Instance, logger); + + /// + /// Extracts the 1-based of into a fresh folder under + /// and returns it as an . The apply requires + /// administrator privileges; index validation does not. + /// + public static Task TryExtractAsync( + string wimPath, int imageIndex, string tempParent, ITraceLogger? logger, CancellationToken cancellationToken) => + TryExtractAsync(wimPath, imageIndex, tempParent, WimNativeApi.Instance, logger, cancellationToken); + + public void Dispose() + { + if (_disposed) { return; } + + _disposed = true; + + TryDeleteExtraction(ExtractedRoot, _logger); + } + + internal static WimImageList ReadIndexList(string wimPath, IWimNativeApi nativeApi, ITraceLogger? logger) + { + if (File.Exists(wimPath)) + { + return nativeApi.ReadImageList(wimPath, logger); + } + + logger?.Debug($"{nameof(OfflineWimImage)}: WIM file not found: {wimPath}."); + + return WimImageList.NotAWim; + + } + + internal static async Task TryExtractAsync( + string wimPath, + int imageIndex, + string tempParent, + IWimNativeApi nativeApi, + ITraceLogger? logger, + CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.Cancelled); + } + + WimImageList imageList = ReadIndexList(wimPath, nativeApi, logger); + + if (imageList.Status != WimImageListStatus.Ok) + { + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.NotAWim); + } + + WimImageEntry? entry = imageList.Images.FirstOrDefault(image => image.Index == imageIndex); + + if (entry is null) + { + logger?.Debug($"{nameof(OfflineWimImage)}: image index {imageIndex} is not in {wimPath} (it has {imageList.Images.Count})."); + + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.IndexOutOfRange); + } + + if (entry.TotalBytes is { } requiredBytes && !HasEnoughFreeSpace(tempParent, requiredBytes, logger)) + { + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.InsufficientSpace); + } + + if (!nativeApi.IsProcessElevated()) + { + logger?.Debug($"{nameof(OfflineWimImage)}: extracting a WIM image requires administrator privileges."); + + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.NeedsElevation); + } + + // A SHORT root keeps the deep WinSxS tree under MAX_PATH; the GUID makes it unique across concurrent runs. + string extractRoot = Path.Combine(tempParent, $"ELX_WIM_{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(extractRoot); + + // The apply is a long blocking native call; run it off the current thread. Cancellation is honored INSIDE the + // apply via the message-callback abort (mapped to ERROR_REQUEST_ABORTED), so Task.Run itself is not cancelled. + int applyResult = await Task.Run( + () => nativeApi.ApplyImage(wimPath, imageIndex, extractRoot, tempParent, cancellationToken, logger), + CancellationToken.None); + + switch (applyResult) + { + case Win32ErrorCodes.ERROR_SUCCESS: + return new OfflineWimExtractResult( + OfflineWimExtractStatus.Extracted, new OfflineWimImage(extractRoot, logger)); + case Win32ErrorCodes.ERROR_REQUEST_ABORTED: + TryDeleteExtraction(extractRoot, logger); + + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.Cancelled); + case Win32ErrorCodes.ERROR_DISK_FULL: + TryDeleteExtraction(extractRoot, logger); + + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.InsufficientSpace); + default: + logger?.Debug($"{nameof(OfflineWimImage)}: WIMApplyImage failed for index {imageIndex} of {wimPath} (error {applyResult})."); + TryDeleteExtraction(extractRoot, logger); + + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.ApplyFailed); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + logger?.Debug($"{nameof(OfflineWimImage)}: extracting index {imageIndex} of {wimPath} failed: {ex.Message}"); + TryDeleteExtraction(extractRoot, logger); + + return OfflineWimExtractResult.Failed(OfflineWimExtractStatus.ApplyFailed); + } + } + + // An extracted Windows tree contains JUNCTIONS (e.g. the legacy "Users\All Users" -> ProgramData with deny-list + // ACLs); a reparse point is deleted as a LINK and NEVER recursed into, so the walk neither follows a junction out of + // the tree nor trips its deny ACL. The read-only/system/hidden attributes WIMApplyImage restores are cleared first + // (Directory.Delete throws on a read-only file). Children are materialized before deletion to avoid mutating a live + // enumerator. + private static void DeleteDirectoryTree(DirectoryInfo directory) + { + if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) + { + directory.Delete(); + + return; + } + + foreach (FileInfo file in directory.GetFiles()) + { + if ((file.Attributes & UndeletableAttributes) != 0) { file.Attributes &= ~UndeletableAttributes; } + + file.Delete(); + } + + foreach (DirectoryInfo subDirectory in directory.GetDirectories()) + { + DeleteDirectoryTree(subDirectory); + } + + if ((directory.Attributes & FileAttributes.ReadOnly) != 0) { directory.Attributes &= ~FileAttributes.ReadOnly; } + + directory.Delete(); + } + + private static bool HasEnoughFreeSpace(string tempParent, long requiredBytes, ITraceLogger? logger) + { + try + { + if (requiredBytes < 0) { return false; } + + string root = Path.GetPathRoot(Path.GetFullPath(tempParent)) ?? tempParent; + long available = new DriveInfo(root).AvailableFreeSpace; + + // Overflow-safe: a malformed/huge TotalBytes must FAIL the check, not wrap past long.MaxValue into a pass. + // available - requiredBytes cannot overflow once available >= requiredBytes (both non-negative). + long headroom = requiredBytes / 10; + + if (available >= requiredBytes && available - requiredBytes >= headroom) { return true; } + + logger?.Debug($"{nameof(OfflineWimImage)}: {root} has {available} bytes free, needs ~{requiredBytes} plus headroom."); + + return false; + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + // If free space cannot be determined, do not block on the precheck; the apply's own ERROR_DISK_FULL backstops. + logger?.Debug($"{nameof(OfflineWimImage)}: could not check free space for {tempParent}: {ex.Message}"); + + return true; + } + } + + // Mirrors DeleteDirectoryTree: never recurse into reparse points - a junction in an extracted Windows tree would + // otherwise loop, leave the root, or throw access-denied - so the leaked-size estimate stays best-effort and bounded. + private static long SumFilesSkippingReparsePoints(DirectoryInfo directory) + { + if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) { return 0; } + + long total = 0; + + foreach (FileInfo file in directory.GetFiles()) { total += file.Length; } + + foreach (DirectoryInfo subDirectory in directory.GetDirectories()) { total += SumFilesSkippingReparsePoints(subDirectory); } + + return total; + } + + // Recursively deletes an extracted image, logging a Warning naming the leaked path + size on failure so a multi-GB + // temp is visible rather than silently abandoned. + private static void TryDeleteExtraction(string extractRoot, ITraceLogger? logger) + { + if (!Directory.Exists(extractRoot)) { return; } + + try + { + DeleteDirectoryTree(new DirectoryInfo(extractRoot)); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + logger?.Warning($"{nameof(OfflineWimImage)}: could not delete extracted image at {extractRoot} (~{TryGetDirectorySize(extractRoot)} bytes leaked): {ex.Message}"); + } + } + + private static long TryGetDirectorySize(string directory) + { + try + { + return SumFilesSkippingReparsePoints(new DirectoryInfo(directory)); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + return -1; + } + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/WimImageList.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/WimImageList.cs new file mode 100644 index 000000000..ea8717399 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/WimImageList.cs @@ -0,0 +1,32 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// Whether a WIM's image-index metadata could be read. +public enum WimImageListStatus +{ + /// The metadata was read; lists every image. + Ok, + + /// The path is not a readable WIM/ESD image (missing, corrupt, locked, or access-denied). + NotAWim +} + +/// One image inside a WIM, parsed from its <IMAGE> metadata. +/// 1-based image index passed to --wim-index. +/// Display name (e.g. "Windows Server 2019 Standard (Desktop Experience)"). +/// Edition id (e.g. "ServerStandard"), or empty when the metadata omits it. +/// Extracted size in bytes, or when the metadata omits it. +public sealed record WimImageEntry(int Index, string Name, string Edition, long? TotalBytes); + +/// +/// The images contained in a WIM file (or when the file could not be +/// read). Produced by +/// so a caller +/// can list the available --wim-index choices. +/// +public sealed record WimImageList(WimImageListStatus Status, IReadOnlyList Images) +{ + internal static WimImageList NotAWim { get; } = new(WimImageListStatus.NotAWim, []); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Offline/WimNativeApi.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/WimNativeApi.cs new file mode 100644 index 000000000..1eacaba87 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Offline/WimNativeApi.cs @@ -0,0 +1,165 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Xml; +using System.Xml.Linq; + +namespace EventLogExpert.Eventing.PublisherMetadata.Offline; + +/// The production , calling the real wimgapi.dll exports. +internal sealed class WimNativeApi : IWimNativeApi +{ + internal static WimNativeApi Instance { get; } = new(); + + public int ApplyImage( + string wimPath, + int imageIndex, + string destinationDirectory, + string scratchDirectory, + CancellationToken cancellationToken, + ITraceLogger? logger) + { + using WimFileSafeHandle wim = NativeMethods.WIMCreateFile( + wimPath, NativeMethods.WIM_GENERIC_READ, NativeMethods.WIM_OPEN_EXISTING, 0, NativeMethods.WIM_COMPRESS_NONE, out _); + + if (wim.IsInvalid || !NativeMethods.WIMSetTemporaryPath(wim, scratchDirectory)) { return Marshal.GetLastWin32Error(); } + + using WimImageSafeHandle image = NativeMethods.WIMLoadImage(wim, (uint)imageIndex); + + if (image.IsInvalid) { return Marshal.GetLastWin32Error(); } + + // Cancellation is opt-in: the CLI passes a non-cancellable token, so no callback is marshalled at all. When a + // cancellable token IS supplied, the delegate MUST stay rooted for the whole apply (it fires per-message for + // minutes); a collected/moved delegate would be an intermittent AccessViolationException. + NativeMethods.WimMessageCallback? abortCallback = null; + IntPtr callbackPointer = IntPtr.Zero; + uint registeredCallback = NativeMethods.WIM_INVALID_CALLBACK_VALUE; + + if (cancellationToken.CanBeCanceled) + { + abortCallback = (_, _, _, _) => + cancellationToken.IsCancellationRequested ? NativeMethods.WIM_MSG_ABORT_IMAGE : NativeMethods.WIM_MSG_SUCCESS; + callbackPointer = Marshal.GetFunctionPointerForDelegate(abortCallback); + registeredCallback = NativeMethods.WIMRegisterMessageCallback(wim, callbackPointer, IntPtr.Zero); + + if (registeredCallback == NativeMethods.WIM_INVALID_CALLBACK_VALUE) + { + logger?.Debug($"{nameof(WimNativeApi)}: WIMRegisterMessageCallback failed (error {Marshal.GetLastWin32Error()}); apply will not be cancellable."); + } + } + + try + { + bool applied = NativeMethods.WIMApplyImage( + image, destinationDirectory, NativeMethods.WIM_FLAG_NO_FILEACL | NativeMethods.WIM_FLAG_NO_DIRACL); + + return applied ? 0 : Marshal.GetLastWin32Error(); + } + finally + { + if (registeredCallback != NativeMethods.WIM_INVALID_CALLBACK_VALUE) + { + NativeMethods.WIMUnregisterMessageCallback(wim, callbackPointer); + } + + // Root the delegate across the entire apply + unregister; only now may it be collected. + GC.KeepAlive(abortCallback); + } + } + + public bool IsProcessElevated() + { + using var identity = WindowsIdentity.GetCurrent(); + + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + + public WimImageList ReadImageList(string wimPath, ITraceLogger? logger) + { + try + { + using WimFileSafeHandle wim = NativeMethods.WIMCreateFile( + wimPath, NativeMethods.WIM_GENERIC_READ, NativeMethods.WIM_OPEN_EXISTING, 0, NativeMethods.WIM_COMPRESS_NONE, out _); + + if (wim.IsInvalid) + { + logger?.Debug($"{nameof(WimNativeApi)}: WIMCreateFile failed for {wimPath} (error {Marshal.GetLastWin32Error()})."); + + return WimImageList.NotAWim; + } + + if (!NativeMethods.WIMGetImageInformation(wim, out IntPtr imageInfo, out uint imageInfoBytes) || imageInfo == IntPtr.Zero) + { + logger?.Debug($"{nameof(WimNativeApi)}: WIMGetImageInformation failed for {wimPath} (error {Marshal.GetLastWin32Error()})."); + + return WimImageList.NotAWim; + } + + try + { + string xml = Marshal.PtrToStringUni(imageInfo, (int)(imageInfoBytes / sizeof(char))) ?? string.Empty; + + return new WimImageList(WimImageListStatus.Ok, ParseImageEntries(xml)); + } + finally + { + NativeMethods.LocalFree(imageInfo); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + logger?.Debug($"{nameof(WimNativeApi)}: reading WIM image list for {wimPath} failed: {ex.Message}"); + + return WimImageList.NotAWim; + } + } + + private static IReadOnlyList ParseImageEntries(string xml) + { + // The buffer is UTF-16 and may carry a BOM; XDocument rejects a leading BOM character. + string trimmed = xml.TrimStart('\uFEFF', '\u200B').Trim(); + + if (trimmed.Length == 0) { return []; } + + XDocument document; + + try + { + document = XDocument.Parse(trimmed); + } + catch (XmlException) + { + return []; + } + + var entries = new List(); + + foreach (XElement image in document.Descendants("IMAGE")) + { + if (!int.TryParse((string?)image.Attribute("INDEX"), NumberStyles.Integer, CultureInfo.InvariantCulture, out int index)) + { + continue; + } + + string name = (string?)image.Element("NAME") ?? (string?)image.Element("DISPLAYNAME") ?? string.Empty; + string edition = (string?)image.Element("FLAGS") + ?? (string?)image.Element("WINDOWS")?.Element("EDITIONID") + ?? string.Empty; + long? totalBytes = long.TryParse( + (string?)image.Element("TOTALBYTES"), NumberStyles.Integer, CultureInfo.InvariantCulture, out long bytes) + ? bytes + : null; + + entries.Add(new WimImageEntry(index, name.Trim(), edition.Trim(), totalBytes)); + } + + entries.Sort(static (left, right) => left.Index.CompareTo(right.Index)); + + return entries; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/OfflineImageProviderSource.cs b/src/EventLogExpert.Eventing/PublisherMetadata/OfflineImageProviderSource.cs new file mode 100644 index 000000000..7da764d90 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/OfflineImageProviderSource.cs @@ -0,0 +1,139 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline; +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; +using System.Text.RegularExpressions; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +/// +/// Loads for every provider in a mounted or extracted foreign Windows image, fully +/// offline. Modern (manifest) providers come from the image's WINEVT catalog and pure-legacy providers from its +/// SYSTEM\Services\EventLog registrations; each is built with file paths re-rooted onto the image and stamped +/// with the IMAGE's OS provenance - the host registry and host files are never read. Mirrors the live +/// shape (yields, skips empty, de-dups by name). The yielded +/// carry LAZY message/parameter sources that reopen the re-rooted DLL files on demand, +/// so the IMAGE must remain mounted/readable until the caller materializes or persists them - the same lifetime +/// contract as the live and MTA sources. +/// +public static class OfflineImageProviderSource +{ + /// + /// Enumerates every provider in the image at (either the image root or its + /// Windows directory), optionally filtered by / + /// on the provider name. Yields nothing (after a logged error) when the path + /// is not a readable Windows image or its hives cannot be safely loaded; it never throws for a bad or hostile image. + /// + public static IEnumerable LoadProviders( + string imageRootPath, + ITraceLogger logger, + Regex? regex = null, + IReadOnlySet? excludeProviderNames = null) + { + // The `using` spans the whole enumeration, so the hives are loaded while enumerating and unloaded when + // iteration completes (or the caller breaks/throws); nothing is loaded if the caller never enumerates. + using IOfflineImageProviderExtractor? extractor = TryCreateExtractor(imageRootPath, logger); + + if (extractor is null) { yield break; } + + foreach (ProviderDetails details in Enumerate(extractor, regex, excludeProviderNames)) + { + yield return details; + } + } + + /// + /// The enumeration / de-dup / provenance-stamping orchestration, separated from hive loading so it can be + /// unit-tested with a fake . A modern provider wins over a pure-legacy + /// provider of the same name (the modern build already populated the legacy tables); empty providers are skipped, and + /// a name is marked seen only after a non-empty provider is yielded. + /// + internal static IEnumerable Enumerate( + IOfflineImageProviderExtractor extractor, + Regex? regex, + IReadOnlySet? excludeProviderNames) + { + SourceOsProvenance provenance = extractor.ReadImageProvenance(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (OfflinePublisherRegistration registration in extractor.ReadModernRegistrations()) + { + if (seen.Contains(registration.ProviderName) || IsFilteredOut(registration.ProviderName, regex, excludeProviderNames)) { continue; } + + if (extractor.TryBuildModernProvider(registration) is not { IsEmpty: false } details) { continue; } + + if (!seen.Add(registration.ProviderName)) { continue; } + + Stamp(details, provenance); + + yield return details; + } + + foreach (string providerName in extractor.EnumerateLegacyProviderNames()) + { + if (seen.Contains(providerName) || IsFilteredOut(providerName, regex, excludeProviderNames)) { continue; } + + if (extractor.TryBuildLegacyProvider(providerName) is not { IsEmpty: false } details) { continue; } + + if (!seen.Add(providerName)) { continue; } + + Stamp(details, provenance); + + yield return details; + } + } + + private static bool IsFilteredOut(string providerName, Regex? regex, IReadOnlySet? excludeProviderNames) => + (regex is not null && !regex.IsMatch(providerName)) || + (excludeProviderNames is not null && excludeProviderNames.Contains(providerName)); + + private static void Stamp(ProviderDetails details, SourceOsProvenance provenance) + { + details.SourceOsBuild = provenance.Build; + details.SourceOsRevision = provenance.Revision; + details.SourceOsEdition = provenance.Edition; + details.SourceOsDisplayVersion = provenance.DisplayVersion; + } + + /// + /// Resolves and loads the image's hives, logging and returning for every "not a usable + /// image" outcome - an unreadable path, hives that cannot be loaded, or hive paths that escape the image root (a + /// malformed or hostile image, surfaced by as an + /// ). Kept out of the iterator so it can use a try/catch, which lets + /// the public facade translate that fail-closed signal into its documented "logged error + yield nothing" contract + /// instead of throwing from deep inside a lazy enumeration. + /// + private static IOfflineImageProviderExtractor? TryCreateExtractor(string imageRootPath, ITraceLogger logger) + { + if (OfflineImageRoot.TryCreate(imageRootPath, logger) is not { } imageRoot) + { + logger.Error($"'{imageRootPath}' is not a readable Windows image (no SOFTWARE/SYSTEM hive found)."); + + return null; + } + + try + { + IOfflineImageProviderExtractor? extractor = OfflineImageProviderExtractor.TryCreate(imageRoot, logger); + + if (extractor is null) + { + // OfflineImageProviderExtractor.TryCreate already logged the specific reason (not a hive / recovery + // failed / needs administrator) at Error; this is just the diagnostic trail. + logger.Debug($"Could not load the SOFTWARE/SYSTEM hives from '{imageRootPath}'."); + } + + return extractor; + } + catch (OfflineRootGuardViolationException ex) + { + logger.Error( + $"The image at '{imageRootPath}' has hive paths that escape the image root; refusing to read it. {ex.Message}"); + + return null; + } + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderDetailsFactory.cs b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderDetailsFactory.cs new file mode 100644 index 000000000..7cbb8f31c --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderDetailsFactory.cs @@ -0,0 +1,290 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Wevt; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +internal static class ProviderDetailsFactory +{ + internal static ProviderDetails Create(RawProviderContent content, ITraceLogger? logger) => + Create(content, preParsedTemplates: null, logger); + + internal static ProviderDetails Create( + RawProviderContent content, + WevtTemplateData? preParsedTemplates, + ITraceLogger? logger) + { + var provider = new ProviderDetails { ProviderName = content.ProviderName }; + + try + { + provider.Events = BuildEvents(content); + } + catch (Exception ex) + { + logger?.Debug($"Failed to load Events for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + + try + { + provider.Keywords = BuildNamedDictionary(content.Keywords, content.ResolveMessage, static value => unchecked((long)value)); + } + catch (Exception ex) + { + logger?.Debug($"Failed to load Keywords for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + + try + { + provider.Opcodes = BuildNamedDictionary(content.Opcodes, content.ResolveMessage, static value => unchecked((int)((uint)value >> 16))); + } + catch (Exception ex) + { + logger?.Debug($"Failed to load Opcodes for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + + try + { + provider.Tasks = BuildNamedDictionary(content.Tasks, content.ResolveMessage, static value => unchecked((int)(uint)value)); + } + catch (Exception ex) + { + logger?.Debug($"Failed to load Tasks for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + + PopulateValueMaps(provider, content, preParsedTemplates, logger); + + return provider; + } + + internal static string InjectMapAttribute(string template, string fieldName, string mapName) + { + // The field name is escaped the same way the template writer escaped it, so a name containing '&' or '<' still + // matches the emitted name= attribute instead of silently missing. + string nameAttribute = $"name=\"{WevtTemplateWriter.EscapeXmlAttribute(fieldName)}\""; + int searchStart = 0; + + while (true) + { + int dataIndex = template.IndexOf(" node. + if (delimiter is not (' ' or '\t' or '\r' or '\n' or '>' or '/')) + { + searchStart = afterTag; + + continue; + } + + int elementEnd = template.IndexOf('>', afterTag); + + if (elementEnd < 0) { return template; } + + int nameIndex = template.IndexOf(nameAttribute, dataIndex, StringComparison.OrdinalIgnoreCase); + + if (nameIndex >= 0 && nameIndex < elementEnd) + { + return template.Insert(nameIndex + nameAttribute.Length, $" map=\"{WevtTemplateWriter.EscapeXmlAttribute(mapName)}\""); + } + + searchStart = elementEnd + 1; + } + } + + private static EventModel[] BuildEvents(RawProviderContent content) + { + var events = new EventModel[content.Events.Count]; + + for (int i = 0; i < content.Events.Count; i++) + { + RawProviderEvent raw = content.Events[i]; + + events[i] = new EventModel + { + // No-message events resolve to string.Empty (not null) to match the native path; the encoder hashes + // null and empty differently. + Description = raw.MessageId == uint.MaxValue + ? string.Empty + : content.ResolveMessage(raw.MessageId)?.TrimEnd('\0', '\r', '\n', '\t', ' ') ?? string.Empty, + Id = raw.Id, + Keywords = ExpandKeywords(raw.KeywordsMask), + Level = raw.Level, + LogName = content.Channels.GetValueOrDefault(raw.ChannelId), + Opcode = raw.Opcode, + Task = raw.Task, + Template = raw.Template, + Version = raw.Version + }; + } + + return events; + } + + private static Dictionary BuildNamedDictionary( + IReadOnlyList entries, + Func resolveMessage, + Func keyProjector) + where TKey : notnull + { + var dictionary = new Dictionary(entries.Count); + + foreach (RawNamedValue entry in entries) + { + // Message-id wins over the inline name when a real id exists (mirrors the native getters); the resolver + // coalesce only guards the offline message-table resolver, which can return null - native never does. + string? resolvedName = entry.MessageId == uint.MaxValue + ? entry.InlineName + : resolveMessage(entry.MessageId) ?? string.Empty; + + // Trailing control characters are trimmed so the two sources collapse to the same VersionKey: native + // FormatMessage names carry a trailing '\0', offline message-table names carry a trailing '\r\n'. + dictionary.TryAdd(keyProjector(entry.Value), resolvedName?.TrimEnd('\0', '\r', '\n', '\t', ' ') ?? string.Empty); + } + + return dictionary; + } + + /// Expands a u64 keyword mask MSB-first into individual set-bit values, matching the live event Keywords. + private static long[] ExpandKeywords(ulong keywordsMask) + { + List keywords = []; + + ulong mask = 0x8000000000000000; + + for (int i = 0; i < 64; i++) + { + if ((keywordsMask & mask) > 0) + { + keywords.Add(unchecked((long)mask)); + } + + mask >>= 1; + } + + return keywords.ToArray(); + } + + private static void InjectMapAttributes( + IReadOnlyList events, + IReadOnlyDictionary> eventFieldMaps, + IReadOnlyDictionary decodedMaps) + { + if (eventFieldMaps.Count == 0) { return; } + + foreach (EventModel model in events) + { + if (string.IsNullOrEmpty(model.Template)) { continue; } + + if (!eventFieldMaps.TryGetValue( + new WevtEventKey((uint)model.Id, model.Version), + out IReadOnlyDictionary? fieldMaps)) + { + continue; + } + + string template = model.Template; + + foreach ((string fieldName, string mapName) in fieldMaps) + { + if (decodedMaps.ContainsKey(mapName)) + { + template = InjectMapAttribute(template, fieldName, mapName); + } + } + + model.Template = template; + } + } + + private static void PopulateValueMaps( + ProviderDetails provider, + RawProviderContent content, + WevtTemplateData? preParsed, + ITraceLogger? logger) + { + try + { + // The offline path supplies the already-parsed maps (single load); the native path reads them on demand. + // Both then run the same ResolveMap + InjectMapAttributes below. + WevtTemplateData? templateData = preParsed ?? TryReadTemplateData(content, logger); + + if (templateData is null || templateData.Maps.Count == 0) { return; } + + Dictionary decodedMaps = new(StringComparer.Ordinal); + + foreach ((string mapName, WevtRawMap rawMap) in templateData.Maps) + { + ValueMapDefinition? definition = ResolveMap(rawMap, content.ResolveMessage, content.ProviderName, logger); + + if (definition is not null) + { + decodedMaps[mapName] = definition; + } + } + + if (decodedMaps.Count == 0) { return; } + + provider.Maps = decodedMaps; + + InjectMapAttributes(provider.Events, templateData.EventFieldMaps, decodedMaps); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + logger?.Debug($"Failed to populate value maps for modern provider: {content.ProviderName}. Exception:\n{ex}"); + } + } + + private static ValueMapDefinition? ResolveMap( + WevtRawMap rawMap, + Func resolveMessage, + string providerName, + ITraceLogger? logger) + { + List entries = new(rawMap.Entries.Count); + + foreach (WevtRawMapEntry entry in rawMap.Entries) + { + if (entry.MessageId == uint.MaxValue) { continue; } + + string? name; + + try + { + name = resolveMessage(entry.MessageId); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + logger?.Debug($"Failed to resolve map message {entry.MessageId} for provider {providerName}: {ex.Message}"); + + continue; + } + + if (string.IsNullOrEmpty(name)) { continue; } + + entries.Add(new ValueMapEntry(entry.Value, name.TrimEnd('\0', '\r', '\n', '\t', ' '))); + } + + return entries.Count > 0 ? new ValueMapDefinition(rawMap.IsBitMap, entries) : null; + } + + private static WevtTemplateData? TryReadTemplateData(RawProviderContent content, ITraceLogger? logger) + { + if (content.PublisherGuid == Guid.Empty) { return null; } + + if (string.IsNullOrEmpty(content.ResourceFilePath)) { return null; } + + return WevtTemplateReader.TryRead(content.ResourceFilePath, content.PublisherGuid, logger); + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs index ffd08b6e2..24450f450 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs @@ -12,19 +12,13 @@ namespace EventLogExpert.Eventing.PublisherMetadata; /// Provides metadata about an event log provider. /// /// This class does not implement . The underlying is a -/// that cleans itself up via its own finalizer. Instances are cached in -/// and are intended to be long-lived. +/// that cleans itself up via its own finalizer. Instances are short-lived: each one is +/// created for a single provider load, consumed once through , and then discarded. /// internal sealed class ProviderMetadata { - private readonly Lock _providerLock = new(); private readonly EvtHandle _publisherMetadataHandle; - private ReadOnlyDictionary? _channels; - private ReadOnlyDictionary? _keywords; - private ReadOnlyDictionary? _opcodes; - private ReadOnlyDictionary? _tasks; - private ProviderMetadata(string providerName, string? metadataPath = null) { _publisherMetadataHandle = NativeMethods.EvtOpenPublisherMetadata(EventLogSession.GlobalSession.Handle, providerName, metadataPath, 0, 0); @@ -36,252 +30,10 @@ private ProviderMetadata(string providerName, string? metadataPath = null) } } - public IDictionary Channels - { - get - { - if (_channels is not null) { return _channels; } - - _providerLock.Enter(); - - try - { - using EvtHandle channelRefHandle = - GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.ChannelReferences); - - int size = NativeMethods.GetObjectArraySize(channelRefHandle); - - Dictionary channels = new(size); - - for (int i = 0; i < size; i++) - { - uint channelId = (uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.ChannelReferenceID); - - string channelName = (string)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.ChannelReferencePath); - - channels.TryAdd(channelId, channelName); - } - - _channels = channels.AsReadOnly(); - - return _channels; - } - finally - { - _providerLock.Exit(); - } - } - } - - public IEnumerable Events - { - get - { - List events = []; - - using EvtHandle handle = NativeMethods.EvtOpenEventMetadataEnum(_publisherMetadataHandle, 0); - int error = Marshal.GetLastWin32Error(); - - if (handle.IsInvalid) - { - Error = NativeErrorResolver.GetErrorMessage((uint)HResultConverter.HResultFromWin32(error)); - - return events.AsReadOnly(); - } - - while (true) - { - using EvtHandle? metadataHandle = NextEventMetadata(handle, 0); - - if (metadataHandle is null) { break; } - - uint id = (uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.ID); - byte version = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Version); - byte channelId = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Channel); - byte level = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Level); - byte opcode = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Opcode); - short task = (short)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Task); - long keywords = (long)(ulong)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Keyword); - string template = (string)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Template); - int messageId = (int)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.MessageID); - - string message = messageId == -1 ? - string.Empty : - NativeMethods.FormatMessage(_publisherMetadataHandle, (uint)messageId); - - events.Add(new EventMetadata(id, version, channelId, level, opcode, task, keywords, template, message, this)); - } - - return events.AsReadOnly(); - } - } - - public IDictionary Keywords - { - get - { - if (_keywords is not null) { return _keywords; } - - _providerLock.Enter(); - - try - { - using EvtHandle channelRefHandle = - GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.Keywords); - - int size = NativeMethods.GetObjectArraySize(channelRefHandle); - - Dictionary keywords = new(size); - - for (int i = 0; i < size; i++) - { - string name = (string)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.KeywordName); - - long value = (long)(ulong)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.KeywordValue); - - int messageId = (int)(uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.KeywordMessageID); - - string displayName = messageId == -1 ? - name : - NativeMethods.FormatMessage(_publisherMetadataHandle, (uint)messageId); - - keywords.TryAdd(value, displayName); - } - - _keywords = keywords.AsReadOnly(); - - return _keywords; - } - finally - { - _providerLock.Exit(); - } - } - } - public string MessageFilePath => Environment.ExpandEnvironmentVariables(GetPublisherMetadataProperty(EvtPublisherMetadataPropertyId.MessageFilePath)); - public IDictionary Opcodes - { - get - { - if (_opcodes is not null) { return _opcodes; } - - _providerLock.Enter(); - - try - { - using EvtHandle channelRefHandle = - GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.Opcodes); - - int size = NativeMethods.GetObjectArraySize(channelRefHandle); - - Dictionary opcodes = new(size); - - for (int i = 0; i < size; i++) - { - string name = (string)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.OpcodeName); - - uint value = (uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.OpcodeValue); - - int messageId = (int)(uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.OpcodeMessageID); - - string displayName = messageId == -1 ? - name : - NativeMethods.FormatMessage(_publisherMetadataHandle, (uint)messageId); - - opcodes.TryAdd((int)(value >> 16), displayName); - } - - _opcodes = opcodes.AsReadOnly(); - - return _opcodes; - } - finally - { - _providerLock.Exit(); - } - } - } - public string ParameterFilePath => Environment.ExpandEnvironmentVariables(GetPublisherMetadataProperty(EvtPublisherMetadataPropertyId.ParameterFilePath)); - public IDictionary Tasks - { - get - { - if (_tasks is not null) { return _tasks; } - - _providerLock.Enter(); - - try - { - using EvtHandle channelRefHandle = - GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.Tasks); - - int size = NativeMethods.GetObjectArraySize(channelRefHandle); - - Dictionary tasks = new(size); - - for (int i = 0; i < size; i++) - { - string name = (string)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.TaskName); - - int value = (int)(uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.TaskValue); - - int messageId = (int)(uint)NativeMethods.GetObjectArrayProperty( - channelRefHandle, - i, - EvtPublisherMetadataPropertyId.TaskMessageID); - - string displayName = messageId == -1 ? - name : - NativeMethods.FormatMessage(_publisherMetadataHandle, (uint)messageId); - - tasks.TryAdd(value, displayName); - } - - _tasks = tasks.AsReadOnly(); - - return _tasks; - } - finally - { - _providerLock.Exit(); - } - } - } - internal string? Error { get; private set; } internal bool IsLocaleMetadata { get; private init; } @@ -365,6 +117,88 @@ and not StackOverflowException internal string FormatMessageById(uint messageId) => NativeMethods.FormatMessage(_publisherMetadataHandle, messageId); + internal RawProviderContent ToRawContent(string providerName, ITraceLogger? logger) + { + List keywords; + + try + { + keywords = ReadNamedValues( + EvtPublisherMetadataPropertyId.Keywords, + EvtPublisherMetadataPropertyId.KeywordName, + EvtPublisherMetadataPropertyId.KeywordValue, + EvtPublisherMetadataPropertyId.KeywordMessageID, + static value => (ulong)value); + } + catch (Exception ex) + { + logger?.Debug($"Failed to read Keywords for provider {providerName}. Exception:\n{ex}"); + keywords = []; + } + + List opcodes; + + try + { + opcodes = ReadNamedValues( + EvtPublisherMetadataPropertyId.Opcodes, + EvtPublisherMetadataPropertyId.OpcodeName, + EvtPublisherMetadataPropertyId.OpcodeValue, + EvtPublisherMetadataPropertyId.OpcodeMessageID, + static value => (uint)value); + } + catch (Exception ex) + { + logger?.Debug($"Failed to read Opcodes for provider {providerName}. Exception:\n{ex}"); + opcodes = []; + } + + List tasks; + + try + { + tasks = ReadNamedValues( + EvtPublisherMetadataPropertyId.Tasks, + EvtPublisherMetadataPropertyId.TaskName, + EvtPublisherMetadataPropertyId.TaskValue, + EvtPublisherMetadataPropertyId.TaskMessageID, + static value => (uint)value); + } + catch (Exception ex) + { + logger?.Debug($"Failed to read Tasks for provider {providerName}. Exception:\n{ex}"); + tasks = []; + } + + IReadOnlyDictionary channels; + IReadOnlyList events; + + try + { + channels = ReadChannelsRaw(); + events = ReadEventsRaw(); + } + catch (Exception ex) + { + logger?.Debug($"Failed to read Events for provider {providerName}. Exception:\n{ex}"); + channels = ReadOnlyDictionary.Empty; + events = []; + } + + return new RawProviderContent + { + ProviderName = providerName, + PublisherGuid = PublisherGuid, + ResourceFilePath = ResourceFilePath, + ResolveMessage = FormatMessageById, + Channels = channels, + Events = events, + Keywords = keywords, + Opcodes = opcodes, + Tasks = tasks + }; + } + private static object GetEventMetadataProperty(EvtHandle metadataHandle, EvtEventMetadataPropertyId propertyId) { IntPtr buffer = IntPtr.Zero; @@ -560,4 +394,86 @@ private EvtHandle GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropert Marshal.FreeHGlobal(buffer); } } + + private Dictionary ReadChannelsRaw() + { + using EvtHandle channelRefHandle = + GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropertyId.ChannelReferences); + + int size = NativeMethods.GetObjectArraySize(channelRefHandle); + + Dictionary channels = new(size); + + for (int i = 0; i < size; i++) + { + uint channelId = (uint)NativeMethods.GetObjectArrayProperty( + channelRefHandle, + i, + EvtPublisherMetadataPropertyId.ChannelReferenceID); + + string channelName = (string)NativeMethods.GetObjectArrayProperty( + channelRefHandle, + i, + EvtPublisherMetadataPropertyId.ChannelReferencePath); + + channels.TryAdd(channelId, channelName); + } + + return channels; + } + + private List ReadEventsRaw() + { + List events = []; + + using EvtHandle handle = NativeMethods.EvtOpenEventMetadataEnum(_publisherMetadataHandle, 0); + + if (handle.IsInvalid) { return events; } + + while (true) + { + using EvtHandle? metadataHandle = NextEventMetadata(handle, 0); + + if (metadataHandle is null) { break; } + + uint id = (uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.ID); + byte version = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Version); + byte channelId = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Channel); + byte level = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Level); + byte opcode = (byte)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Opcode); + short task = (short)(uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Task); + ulong keywords = (ulong)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Keyword); + string template = (string)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.Template); + uint messageId = (uint)GetEventMetadataProperty(metadataHandle, EvtEventMetadataPropertyId.MessageID); + + events.Add(new RawProviderEvent(id, version, channelId, level, opcode, task, keywords, template, messageId)); + } + + return events; + } + + private List ReadNamedValues( + EvtPublisherMetadataPropertyId tableId, + EvtPublisherMetadataPropertyId nameId, + EvtPublisherMetadataPropertyId valueId, + EvtPublisherMetadataPropertyId messageIdId, + Func unboxValue) + { + using EvtHandle handle = GetPublisherMetadataPropertyHandle(tableId); + + int size = NativeMethods.GetObjectArraySize(handle); + + List entries = new(size); + + for (int i = 0; i < size; i++) + { + string name = (string)NativeMethods.GetObjectArrayProperty(handle, i, nameId); + ulong value = unboxValue(NativeMethods.GetObjectArrayProperty(handle, i, valueId)); + uint messageId = (uint)NativeMethods.GetObjectArrayProperty(handle, i, messageIdId); + + entries.Add(new RawNamedValue(value, messageId, name)); + } + + return entries; + } } diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/RawProviderContent.cs b/src/EventLogExpert.Eventing/PublisherMetadata/RawProviderContent.cs new file mode 100644 index 000000000..ecb633df9 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/RawProviderContent.cs @@ -0,0 +1,42 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections.ObjectModel; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +internal sealed record RawProviderEvent( + uint Id, + byte Version, + byte ChannelId, + byte Level, + byte Opcode, + short Task, + ulong KeywordsMask, + string Template, + uint MessageId); + +internal sealed record RawNamedValue(ulong Value, uint MessageId, string? InlineName); + +internal sealed class RawProviderContent +{ + /// Channel reference id to log name; the per-event channel byte is looked up here for the event's log name. + public IReadOnlyDictionary Channels { get; init; } = ReadOnlyDictionary.Empty; + + public IReadOnlyList Events { get; init; } = []; + + public IReadOnlyList Keywords { get; init; } = []; + + public IReadOnlyList Opcodes { get; init; } = []; + + public required string ProviderName { get; init; } + + public required Guid PublisherGuid { get; init; } + + /// Resolves a message id to its text, or null when unresolved (native FormatMessage never returns null). + public required Func ResolveMessage { get; init; } + + public required string ResourceFilePath { get; init; } + + public IReadOnlyList Tasks { get; init; } = []; +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/RegistryProvider.cs b/src/EventLogExpert.Eventing/PublisherMetadata/RegistryProvider.cs index 817dcb802..6b2ada518 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/RegistryProvider.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/RegistryProvider.cs @@ -7,7 +7,7 @@ namespace EventLogExpert.Eventing.PublisherMetadata; -internal sealed class RegistryProvider(ITraceLogger? logger = null) +internal sealed class RegistryProvider(ITraceLogger? logger = null) : ILegacyMessageFileResolver { private readonly ITraceLogger? _logger = logger; diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/SourceOsProvenance.cs b/src/EventLogExpert.Eventing/PublisherMetadata/SourceOsProvenance.cs new file mode 100644 index 000000000..5a3601824 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/SourceOsProvenance.cs @@ -0,0 +1,88 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using Microsoft.Win32; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +/// +/// Provenance of the OS a provider database is built from - the host (live build) or a foreign image (offline +/// build) - read from …\Microsoft\Windows NT\CurrentVersion. Recorded per provider row so resolution can +/// prefer the newest source (the recency tiebreak) without relying on the database file name. All fields are null +/// when the key cannot be read; resolution degrades gracefully to completeness + load order. +/// +public sealed record SourceOsProvenance(int? Build, int? Revision, string? Edition, string? DisplayVersion) +{ + public static SourceOsProvenance Empty { get; } = new(null, null, null, null); + + /// Reads the host OS provenance from the local HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion. + public static SourceOsProvenance Read(ITraceLogger? logger = null) + { + try + { + // Open an owned base key (do NOT use Registry.LocalMachine - that's a shared static), matching + // RegistryProvider so concurrent instances dispose independently. + using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Default); + using var currentVersion = hklm.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + + return ParseCurrentVersion(currentVersion, logger); + } + catch (Exception ex) + { + logger?.Debug($"{nameof(SourceOsProvenance)}: failed to read host OS provenance. Exception:\n{ex}"); + + return Empty; + } + } + + /// + /// Reads a foreign image's OS provenance from its already-loaded SOFTWARE hive root (the + /// Microsoft\Windows NT\CurrentVersion subkey), so an offline image build stamps rows with the IMAGE's OS + /// rather than the host's. Never touches the host registry. Internal: the offline extraction path is the only + /// caller; cross-assembly consumers use the host overload. + /// + internal static SourceOsProvenance ReadFromSoftwareHive(RegistryKey softwareRoot, ITraceLogger? logger = null) + { + try + { + using var currentVersion = softwareRoot.OpenSubKey(@"Microsoft\Windows NT\CurrentVersion"); + + return ParseCurrentVersion(currentVersion, logger); + } + catch (Exception ex) + { + logger?.Debug($"{nameof(SourceOsProvenance)}: failed to read image OS provenance. Exception:\n{ex}"); + + return Empty; + } + } + + // Read the raw stored strings WITHOUT environment expansion: .NET's RegistryKey.GetValue expands a + // REG_EXPAND_SZ value against the HOST environment by default, so a foreign or malformed image SOFTWARE hive + // that stored these as REG_EXPAND_SZ would otherwise contaminate offline provenance with host data (and the + // literal stored value is what provenance wants regardless). The host Read() path is unaffected - these are + // REG_SZ there - and UBR is a DWORD, so expansion never applies to it. + private static SourceOsProvenance ParseCurrentVersion(RegistryKey? currentVersion, ITraceLogger? logger) + { + if (currentVersion is null) + { + logger?.Debug($"{nameof(SourceOsProvenance)}: CurrentVersion key not found; provenance unavailable."); + + return Empty; + } + + var build = int.TryParse(ReadRawString(currentVersion, "CurrentBuildNumber"), out var parsedBuild) + ? parsedBuild + : (int?)null; + + var revision = currentVersion.GetValue("UBR") is int rawRevision ? rawRevision : (int?)null; + var edition = ReadRawString(currentVersion, "EditionID"); + var displayVersion = ReadRawString(currentVersion, "DisplayVersion"); + + return new SourceOsProvenance(build, revision, edition, displayVersion); + } + + private static string? ReadRawString(RegistryKey key, string valueName) => + key.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames) as string; +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/MessageTableSession.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/MessageTableSession.cs new file mode 100644 index 000000000..8ef77baf1 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/MessageTableSession.cs @@ -0,0 +1,77 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; + +namespace EventLogExpert.Eventing.PublisherMetadata.Wevt; + +/// +/// Opens a provider's RT_MESSAGETABLE resources (MUI-aware) and resolves message ids to strings offline, without +/// EvtFormatMessage. Holds native module handles for its lifetime, so it is ; the resolver +/// delegate must not be invoked after disposal. +/// +internal sealed class MessageTableSession : IDisposable +{ + private readonly string _providerName; + private readonly List<(LibraryHandle Handle, nint Memory, uint Size)> _tables = []; + + private MessageTableSession(string providerName) => _providerName = providerName; + + public void Dispose() + { + foreach ((LibraryHandle handle, _, _) in _tables) + { + handle.Dispose(); + } + + _tables.Clear(); + } + + internal static MessageTableSession Open(string providerName, IEnumerable candidateFiles, ITraceLogger? logger) + { + MessageTableSession session = new(providerName); + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + + foreach (string file in candidateFiles) + { + if (string.IsNullOrEmpty(file) || !seen.Add(file)) + { + continue; + } + + if (MessageTableReader.TryOpen(file, logger, out LibraryHandle handle, out nint memory, out uint size)) + { + session._tables.Add((handle, memory, size)); + } + } + + return session; + } + + internal string? Resolve(uint messageId) + { + if (messageId == uint.MaxValue) + { + return null; + } + + // FindFirstByRawId reads block lowId/highId as signed Int32 and iterates them as long, so a message id with the + // high bit set (common for WEVT: 0x9xxxxxxx / 0xDxxxxxxx) must be sign-extended to match. Zero-extending a uint + // would compare a positive long against a negative block range and never hit. + long signExtendedId = unchecked((int)messageId); + + foreach ((_, nint memory, uint size) in _tables) + { + MessageModel? model = MessageTableReader.FindFirstByRawId(memory, size, signExtendedId, _providerName); + + if (model is not null) + { + return model.Text; + } + } + + return null; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/OfflineWevtProviderReader.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/OfflineWevtProviderReader.cs new file mode 100644 index 000000000..1ebcee832 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/OfflineWevtProviderReader.cs @@ -0,0 +1,248 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; +using System.Buffers; +using System.Text.RegularExpressions; + +namespace EventLogExpert.Eventing.PublisherMetadata.Wevt; + +/// +/// Builds a purely from a provider's WEVT_TEMPLATE and RT_MESSAGETABLE resources, +/// with no EvtOpenPublisherMetadata call. The parsed tables are mapped to in the +/// same representation the native path produces, so the shared resolves both +/// sources identically. +/// +internal static partial class OfflineWevtProviderReader +{ + /// + /// Maps the parsed provider tables to . Pure and host-independent: the unit + /// tests drive it directly from crafted bytes plus a fake . + /// + internal static RawProviderContent MapToRawContent( + WevtProviderData data, + Guid publisherGuid, + string providerName, + string resourceFilePath, + Func resolveMessage) + { + // Levels are parsed for fidelity but intentionally not mapped: the native RawProviderContent carries no level + // table (event Level is the raw byte on each event), so omitting them keeps parity with the native path. + return new RawProviderContent + { + ProviderName = providerName, + PublisherGuid = publisherGuid, + ResourceFilePath = resourceFilePath, + ResolveMessage = resolveMessage, + Channels = BuildChannels(data.Channels), + Events = BuildEvents(data.Events), + Keywords = BuildKeywords(data.Keywords), + Opcodes = BuildOpcodes(data.Opcodes), + Tasks = BuildTasks(data.Tasks) + }; + } + + internal static string ResolveParameterReferences(string description, Func resolveParameterText) => + ParameterReferenceRegex().Replace(description, match => + { + string token = match.Value; + + if (!token.StartsWith("%%", StringComparison.Ordinal) || + !long.TryParse(token.AsSpan(2), out long parameterId)) + { + return token; + } + + string? parameterText = resolveParameterText(unchecked((int)parameterId)); + + // An unresolved reference stays the literal %%NNNN so the render-time DescriptionFormatter can still resolve + // it (including its system-message-table fallback, which the offline db-create path must not bake in here). + if (string.IsNullOrEmpty(parameterText)) { return token; } + + return parameterText.EndsWith("%0", StringComparison.Ordinal) ? parameterText[..^2] : parameterText; + }).TrimEnd('\0', '\r', '\n', '\t', ' '); + + internal static ProviderDetails? TryBuildProviderDetails( + string resourceFilePath, + IReadOnlyList messageFilePaths, + string? parameterFilePath, + Guid publisherGuid, + string providerName, + ILegacyMessageFileResolver legacyResolver, + ITraceLogger? logger) + { + byte[]? rented = WevtTemplateReader.TryRentWevtResource(resourceFilePath, logger, out int resourceSize); + + if (rented is null) + { + return null; + } + + try + { + WevtProviderData? data = WevtTemplateReader.TryParseProvider( + rented.AsSpan(0, resourceSize), + publisherGuid, + logger); + + if (data is null) + { + logger?.Debug($"{nameof(OfflineWevtProviderReader)}: provider {publisherGuid} not found in {resourceFilePath}."); + + return null; + } + + List candidateFiles = [.. messageFilePaths, resourceFilePath]; + + using MessageTableSession session = MessageTableSession.Open(providerName, candidateFiles, logger); + + RawProviderContent content = MapToRawContent( + data, + publisherGuid, + providerName, + resourceFilePath, + messageId => session.Resolve(messageId) is { } raw ? WevtMessageFormatter.Format(raw) : null); + + ProviderDetails details = ProviderDetailsFactory.Create(content, data.Templates, logger); + + PopulateLegacyTables(details, messageFilePaths, parameterFilePath, providerName, legacyResolver, logger); + + ResolveParameterReferences(details); + + return details; + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private static IReadOnlyDictionary BuildChannels(IReadOnlyList channels) + { + Dictionary result = []; + + foreach (WevtChannelEntry channel in channels) + { + // Inline-only, keyed by the channel reference id, first row wins - matching the native channel dictionary, + // which uses the inline ChannelReferencePath with no message resolution. + if (channel.InlineName is { Length: > 0 } name) + { + result.TryAdd(channel.ReferenceId, name); + } + } + + return result; + } + + private static IReadOnlyList BuildEvents(IReadOnlyList events) + { + RawProviderEvent[] result = new RawProviderEvent[events.Count]; + + for (int index = 0; index < events.Count; index++) + { + WevtProviderEvent source = events[index]; + + // An unrepresentable template (an unknown inType/outType byte, a non-reference length, or a malformed struct + // member range) makes the writer return null, which yields an empty string. + string template = source.Template is null + ? string.Empty + : WevtTemplateWriter.Write(source.Template.Nodes, source.Template.Descriptors) ?? string.Empty; + + result[index] = new RawProviderEvent( + source.Id, + source.Version, + source.Channel, + source.Level, + source.Opcode, + unchecked((short)source.Task), + source.Keywords, + template, + source.MessageId); + } + + return result; + } + + private static IReadOnlyList BuildKeywords(IReadOnlyList keywords) + { + RawNamedValue[] result = new RawNamedValue[keywords.Count]; + + for (int index = 0; index < keywords.Count; index++) + { + WevtKeywordEntry keyword = keywords[index]; + + result[index] = new RawNamedValue(keyword.Mask, keyword.MessageId, keyword.InlineName); + } + + return result; + } + + private static IReadOnlyList BuildOpcodes(IReadOnlyList opcodes) + { + RawNamedValue[] result = new RawNamedValue[opcodes.Count]; + + for (int index = 0; index < opcodes.Count; index++) + { + WevtIdentifiedEntry opcode = opcodes[index]; + + result[index] = new RawNamedValue(opcode.Id, opcode.MessageId, opcode.InlineName); + } + + return result; + } + + private static IReadOnlyList BuildTasks(IReadOnlyList tasks) + { + RawNamedValue[] result = new RawNamedValue[tasks.Count]; + + for (int index = 0; index < tasks.Count; index++) + { + WevtIdentifiedEntry task = tasks[index]; + + result[index] = new RawNamedValue(task.Id, task.MessageId, task.InlineName); + } + + return result; + } + + [GeneratedRegex("%+[0-9]+")] + private static partial Regex ParameterReferenceRegex(); + + private static void PopulateLegacyTables( + ProviderDetails details, + IReadOnlyList modernMessageFilePaths, + string? parameterFilePath, + string providerName, + ILegacyMessageFileResolver legacyResolver, + ITraceLogger? logger) + { + IReadOnlyList legacyFiles = legacyResolver.GetMessageFilesForLegacyProvider(providerName); + + LegacyMessageFileSource? messages = LegacyMessageFileSource.TryCreate(legacyFiles, providerName, logger) + ?? LegacyMessageFileSource.TryCreate(modernMessageFilePaths, providerName, logger); + + if (messages is not null) { details.SetLazyMessageSource(messages); } + + if (string.IsNullOrEmpty(parameterFilePath)) { return; } + + LegacyMessageFileSource? parameters = LegacyMessageFileSource.TryCreate([parameterFilePath], providerName, logger); + + if (parameters is not null) { details.SetLazyParameterSource(parameters); } + } + + private static void ResolveParameterReferences(ProviderDetails details) + { + foreach (EventModel model in details.Events) + { + string? description = model.Description; + + if (string.IsNullOrEmpty(description) || !description.Contains("%%", StringComparison.Ordinal)) + { + continue; + } + + model.Description = ResolveParameterReferences(description, rawId => details.GetParameterByRawId(rawId)?.Text); + } + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtMessageFormatter.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtMessageFormatter.cs new file mode 100644 index 000000000..85c07aedd --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtMessageFormatter.cs @@ -0,0 +1,144 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Buffers; + +namespace EventLogExpert.Eventing.PublisherMetadata.Wevt; + +internal static class WevtMessageFormatter +{ + private const int MaxStackAllocChars = 4096; + + internal static string Format(string raw) + { + if (raw.AsSpan().IndexOfAny('%', '\r', '\n') < 0) + { + return raw; + } + + char[]? rented = null; + + Span buffer = raw.Length <= MaxStackAllocChars + ? stackalloc char[raw.Length] + : (rented = ArrayPool.Shared.Rent(raw.Length)); + + try + { + int length = 0; + bool firstNumberedInsertSeen = false; + + for (int index = 0; index < raw.Length; index++) + { + char current = raw[index]; + + // MAX_WIDTH fold: a literal CRLF or lone CR/LF collapses to one space (a %n-emitted CRLF is not re-folded). + if (current == '\r') + { + if (index + 1 < raw.Length && raw[index + 1] == '\n') + { + index++; + } + + buffer[length++] = ' '; + + continue; + } + + if (current == '\n') + { + buffer[length++] = ' '; + + continue; + } + + if (current != '%') + { + buffer[length++] = current; + + continue; + } + + if (index + 1 >= raw.Length) + { + buffer[length++] = '%'; + + continue; + } + + char escape = raw[index + 1]; + + switch (escape) + { + case 'n': + buffer[length++] = '\r'; + buffer[length++] = '\n'; + index++; + break; + case 't': + buffer[length++] = '\t'; + index++; + break; + case 'b': + buffer[length++] = ' '; + index++; + break; + case 'r': + buffer[length++] = '\r'; + index++; + break; + case '%': + // %% stays doubled (IGNORE_INSERTS) so DescriptionFormatter can later resolve %%nnnn parameter inserts. + buffer[length++] = '%'; + buffer[length++] = '%'; + index++; + break; + case '0': + return new string(buffer[..length]); + case >= '1' and <= '9': + // A numbered FormatMessage insert (%1-%99): emit the %N placeholder unchanged (IGNORE_INSERTS + // semantics) for the runtime DescriptionFormatter to substitute. Native EvtFormatMessage strips the + // !S! / !s! string spec from the FIRST numbered insert ONLY, keeping numeric specs (!d!, !I64x!, ...) + // and every later insert's spec - an empirically-observed quirk replicated so offline-resolved + // messages collapse to the same VersionKey as native. + buffer[length++] = '%'; + buffer[length++] = escape; + index++; + + // Consume an optional second digit (%10-%99) before testing for the spec. + if (index + 1 < raw.Length && raw[index + 1] is >= '0' and <= '9') + { + buffer[length++] = raw[index + 1]; + index++; + } + + if (!firstNumberedInsertSeen) + { + firstNumberedInsertSeen = true; + + if (index + 1 < raw.Length && raw[index + 1] == '!') + { + int specClose = raw.IndexOf('!', index + 2); + bool singleCharStringSpec = specClose == index + 3 && raw[index + 2] is 'S' or 's'; + + if (singleCharStringSpec) { index = specClose; } + } + } + + break; + default: + buffer[length++] = '%'; + break; + } + } + + return new string(buffer[..length]); + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTemplateReader.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTemplateReader.cs new file mode 100644 index 000000000..e09e85d29 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTemplateReader.cs @@ -0,0 +1,943 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using System.Buffers; +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; + +namespace EventLogExpert.Eventing.PublisherMetadata.Wevt; + +internal readonly record struct WevtRawMapEntry(uint Value, uint MessageId); + +internal sealed record WevtRawMap(bool IsBitMap, IReadOnlyList Entries); + +internal readonly record struct WevtEventKey(uint Id, byte Version); + +internal sealed class WevtTemplateData +{ + public required IReadOnlyDictionary> EventFieldMaps { get; init; } + + public required IReadOnlyDictionary Maps { get; init; } +} + +internal abstract record WevtTemplateNode(string Name, uint Flags, ushort ArrayCount); + +internal sealed record WevtLeafNode(string Name, byte InType, byte OutType, uint Flags, ushort ArrayCount, ushort Length) + : WevtTemplateNode(Name, Flags, ArrayCount); + +internal sealed record WevtStructNode(string Name, uint Flags, ushort ArrayCount, IReadOnlyList Members) + : WevtTemplateNode(Name, Flags, ArrayCount); + +internal readonly record struct WevtRawDescriptor(string Name, bool IsStruct); + +internal sealed class WevtParsedTemplate +{ + public required IReadOnlyList Descriptors { get; init; } + + public required IReadOnlyList Nodes { get; init; } +} + +/// A provider event read in full from the EVNT table, before any name/keyword resolution. +internal sealed record WevtProviderEvent( + uint Id, + byte Version, + byte Channel, + byte Level, + byte Opcode, + ushort Task, + ulong Keywords, + uint MessageId, + WevtParsedTemplate? Template); + +/// A CHAN table row. (the @8 aux field) is what an event's channel byte references. +internal readonly record struct WevtChannelEntry(uint Id, uint ReferenceId, uint MessageId, string? InlineName); + +/// A LEVL / OPCO / TASK table row keyed by its numeric id. +internal readonly record struct WevtIdentifiedEntry(uint Id, uint MessageId, string? InlineName); + +/// A KEYW table row keyed by its 64-bit bit mask. +internal readonly record struct WevtKeywordEntry(ulong Mask, uint MessageId, string? InlineName); + +/// +/// The full set of tables parsed from one provider's WEVT_TEMPLATE in a single pass: the value-map data the +/// shipped map API exposes plus the events / channels / levels / opcodes / tasks / keywords the offline provider +/// reader maps to . +/// +internal sealed class WevtProviderData +{ + public required IReadOnlyList Channels { get; init; } + + public required IReadOnlyList Events { get; init; } + + public required IReadOnlyList Keywords { get; init; } + + public required IReadOnlyList Levels { get; init; } + + public required IReadOnlyList Opcodes { get; init; } + + public required IReadOnlyList Tasks { get; init; } + + public required WevtTemplateData Templates { get; init; } +} + +/// +/// Reads the binary WEVT_TEMPLATE resource embedded in a provider DLL. A single pass extracts the value-map / +/// bitMap definitions and per-event field-to-map associations (the shipped map API) as well as the full event, +/// channel, level, opcode, task, and keyword tables the offline provider reader consumes. +/// +/// +/// EvtOpenPublisherMetadata exposes no valueMap / bitMap tables and strips the map attribute from the +/// template XML, so the decoded names (for example a bus type of 10 shown as SAS) are recovered by +/// parsing the compiled resource directly. Every offset is bounds-checked against the actual resource size; a +/// malformed resource yields null for the map API and an empty table for the full parse. +/// +internal static class WevtTemplateReader +{ + private const string BmapSignature = "BMAP"; + private const int ChannelAuxOffset = 8; + private const int ChannelEntrySize = 16; + private const int ChannelMessageIdOffset = 12; + private const int ChannelNameDataOffset = 4; + private const string ChanSignature = "CHAN"; + private const uint ClassicEventIdCustomerBit = 0x20000000; + private const int CrimProviderCountOffset = 12; + private const int CrimProviderDescriptorArrayOffset = 16; + private const int CrimProviderDescriptorSize = 20; + private const string CrimSignature = "CRIM"; + private const int EventChannelOffset = 3; + private const int EventClassicTrailingFieldOffset = 44; + private const int EventDefinitionSize = 48; + private const int EventDefinitionTemplateOffset = 20; + private const int EventDefinitionVersionOffset = 2; + private const int EventKeywordsOffset = 8; + private const int EventLevelOffset = 4; + private const int EventMessageIdOffset = 16; + private const int EventOpcodeOffset = 5; + private const int EventOpcodeTableReferenceOffset = 24; + private const int EventTableArrayOffset = 16; + private const int EventTableCountOffset = 8; + private const int EventTaskOffset = 6; + private const string EvntSignature = "EVNT"; + private const int IdentifiedEntrySize = 12; + private const int IdentifiedMessageIdOffset = 4; + private const int IdentifiedNameDataOffset = 8; + private const int KeywordEntrySize = 16; + private const int KeywordMessageIdOffset = 8; + private const int KeywordNameDataOffset = 12; + private const string KeywSignature = "KEYW"; + private const string LevlSignature = "LEVL"; + private const int MapEntryArrayOffset = 20; + private const int MapEntrySize = 8; + private const int MapNameOffset = 8; + private const int MapValueCountOffset = 16; + private const uint MaxElementCount = 4096; + private const uint MaxEventCount = 65536; + private const uint MaxMapEntryCount = 65536; + private const uint MaxNameByteLength = 4096; + private const uint MaxProviderCount = 4096; + private const long MaxResourceSize = 64L * 1024 * 1024; + private const uint MaxTableEntryCount = 65536; + private const uint MaxTemplateItemCount = 4096; + private const int MinResourceSize = 16; + private const string OpcoSignature = "OPCO"; + private const int TableEntryArrayOffset = 12; + private const int TableEntryCountOffset = 8; + private const int TaskEntryNameDataOffset = 24; + private const int TaskEntrySize = 28; + private const string TaskSignature = "TASK"; + private const int TemplateItemArrayCountOffset = 12; + private const int TemplateItemCountOffset = 8; + private const int TemplateItemFlagsOffset = 0; + private const int TemplateItemInTypeOffset = 4; + private const int TemplateItemLengthOffset = 14; + private const int TemplateItemMapOffset = 8; + private const int TemplateItemMemberCountOffset = 6; + private const int TemplateItemNameOffset = 16; + private const int TemplateItemOutTypeOffset = 5; + private const int TemplateItemSize = 20; + private const int TemplateItemsPointerOffset = 16; + private const int TemplateNameCountOffset = 12; + private const string TempSignature = "TEMP"; + private const string VmapSignature = "VMAP"; + private const int WevtElementArrayOffset = 20; + private const int WevtElementCountOffset = 12; + private const int WevtElementDescriptorSize = 8; + private const string WevtResourceName = "#1"; + private const string WevtResourceType = "WEVT_TEMPLATE"; + private const string WevtSignature = "WEVT"; + + /// + /// Parses the map API view of a provider: the value-map / bitMap definitions and per-event field-to-map + /// associations. Returns null when the provider has no value maps (legacy contract), even though the full parse + /// may still carry events and tables. + /// + internal static WevtTemplateData? TryParse(ReadOnlySpan data, Guid publisherGuid, ITraceLogger? logger) + { + WevtProviderData? provider = TryParseProvider(data, publisherGuid, logger); + + return provider is null || provider.Templates.Maps.Count == 0 ? null : provider.Templates; + } + + /// Parses the full provider tables in a single pass. Name strings are materialized; no span escapes. + internal static WevtProviderData? TryParseProvider(ReadOnlySpan data, Guid publisherGuid, ITraceLogger? logger) + { + if (!TryReadSignature(data, 0, out string signature) || signature != CrimSignature) + { + return null; + } + + if (!TryReadUInt32(data, CrimProviderCountOffset, out uint providerCount)) + { + return null; + } + + if (!TryFindProviderOffset(data, providerCount, publisherGuid, out uint providerOffset)) + { + logger?.Debug($"{nameof(WevtTemplateReader)}: provider {publisherGuid} not found in WEVT_TEMPLATE."); + + return null; + } + + if (!TryReadSignature(data, (int)providerOffset, out string providerSignature) || + providerSignature != WevtSignature) + { + return null; + } + + if (!TryReadUInt32(data, (int)providerOffset + WevtElementCountOffset, out uint elementCount) || + elementCount > MaxElementCount) + { + return null; + } + + uint eventTableOffset = 0; + uint channelTableOffset = 0; + uint levelTableOffset = 0; + uint opcodeTableOffset = 0; + uint taskTableOffset = 0; + uint keywordTableOffset = 0; + + for (uint elementIndex = 0; elementIndex < elementCount; elementIndex++) + { + int descriptorOffset = + (int)providerOffset + WevtElementArrayOffset + (int)(elementIndex * WevtElementDescriptorSize); + + if (!TryReadUInt32(data, descriptorOffset, out uint elementOffset)) + { + break; + } + + if (!TryReadSignature(data, (int)elementOffset, out string elementSignature)) + { + continue; + } + + switch (elementSignature) + { + case EvntSignature: eventTableOffset = elementOffset; break; + case ChanSignature: channelTableOffset = elementOffset; break; + case LevlSignature: levelTableOffset = elementOffset; break; + case OpcoSignature: opcodeTableOffset = elementOffset; break; + case TaskSignature: taskTableOffset = elementOffset; break; + case KeywSignature: keywordTableOffset = elementOffset; break; + } + } + + Dictionary maps = new(StringComparer.Ordinal); + Dictionary> eventFieldMaps = []; + + IReadOnlyList events = eventTableOffset == 0 + ? [] + : ParseEvents(data, eventTableOffset, maps, eventFieldMaps, logger); + + return new WevtProviderData + { + Templates = new WevtTemplateData { Maps = maps, EventFieldMaps = eventFieldMaps }, + Events = events, + Channels = channelTableOffset == 0 ? [] : ParseChannels(data, channelTableOffset), + Levels = levelTableOffset == 0 ? [] : ParseIdentifiedTable(data, levelTableOffset, IdentifiedEntrySize), + Opcodes = opcodeTableOffset == 0 ? [] : ParseIdentifiedTable(data, opcodeTableOffset, IdentifiedEntrySize), + Tasks = taskTableOffset == 0 ? [] : ParseTasks(data, taskTableOffset), + Keywords = keywordTableOffset == 0 ? [] : ParseKeywords(data, keywordTableOffset) + }; + } + + internal static WevtTemplateData? TryRead(string resourceFilePath, Guid publisherGuid, ITraceLogger? logger) + { + byte[]? resourceBytes = TryLoadWevtResource(resourceFilePath, logger); + + if (resourceBytes is null || resourceBytes.Length < MinResourceSize) + { + return null; + } + + try + { + return TryParse(resourceBytes, publisherGuid, logger); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException) + { + logger?.Debug( + $"{nameof(WevtTemplateReader)}: failed to parse WEVT_TEMPLATE from {resourceFilePath}: {ex.Message}"); + + return null; + } + } + + /// + /// Rents an buffer holding the WEVT_TEMPLATE resource. The caller owns the buffer and + /// MUST return it; only the first bytes are valid (the rented buffer is oversized). + /// + internal static byte[]? TryRentWevtResource(string resourceFilePath, ITraceLogger? logger, out int resourceSize) => + TryCopyWevtResource(resourceFilePath, logger, static size => ArrayPool.Shared.Rent(size), out resourceSize); + + private static List ParseChannels(ReadOnlySpan data, uint tableOffset) + { + List channels = []; + + if (!TryReadUInt32(data, (int)tableOffset + TableEntryCountOffset, out uint count) || count > MaxTableEntryCount) + { + return channels; + } + + for (uint index = 0; index < count; index++) + { + int entryOffset = (int)tableOffset + TableEntryArrayOffset + (int)(index * ChannelEntrySize); + + if (!TryReadUInt32(data, entryOffset, out uint id) || + !TryReadUInt32(data, entryOffset + ChannelNameDataOffset, out uint nameDataOffset) || + !TryReadUInt32(data, entryOffset + ChannelAuxOffset, out uint referenceId) || + !TryReadUInt32(data, entryOffset + ChannelMessageIdOffset, out uint messageId)) + { + break; + } + + channels.Add(new WevtChannelEntry(id, referenceId, messageId, ReadInlineName(data, nameDataOffset))); + } + + return channels; + } + + private static List ParseEvents( + ReadOnlySpan data, + uint eventTableOffset, + Dictionary maps, + Dictionary> eventFieldMaps, + ITraceLogger? logger) + { + List events = []; + + if (!TryReadUInt32(data, (int)eventTableOffset + EventTableCountOffset, out uint eventCount) || + eventCount > MaxEventCount) + { + return events; + } + + Dictionary mapNamesByOffset = []; + Dictionary?> fieldMapsByTemplateOffset = []; + Dictionary templatesByOffset = []; + + for (uint eventIndex = 0; eventIndex < eventCount; eventIndex++) + { + int eventDefinitionOffset = + (int)eventTableOffset + EventTableArrayOffset + (int)(eventIndex * EventDefinitionSize); + + if (!TryReadUInt16(data, eventDefinitionOffset, out ushort eventId) || + !TryReadByte(data, eventDefinitionOffset + EventDefinitionVersionOffset, out byte version) || + !TryReadByte(data, eventDefinitionOffset + EventChannelOffset, out byte channel) || + !TryReadByte(data, eventDefinitionOffset + EventLevelOffset, out byte level) || + !TryReadByte(data, eventDefinitionOffset + EventOpcodeOffset, out byte opcode) || + !TryReadUInt16(data, eventDefinitionOffset + EventTaskOffset, out ushort task) || + !TryReadUInt64(data, eventDefinitionOffset + EventKeywordsOffset, out ulong keywords) || + !TryReadUInt32(data, eventDefinitionOffset + EventMessageIdOffset, out uint messageId) || + !TryReadUInt32(data, eventDefinitionOffset + EventDefinitionTemplateOffset, out uint templateOffset)) + { + continue; + } + + bool isClassicEvent = + TryReadUInt32(data, + eventDefinitionOffset + EventOpcodeTableReferenceOffset, + out uint opcodeTableReference) && + opcodeTableReference == 0 && + TryReadUInt32(data, + eventDefinitionOffset + EventClassicTrailingFieldOffset, + out uint classicTrailingField) && + classicTrailingField == 0 && + (messageId & ClassicEventIdCustomerBit) == 0; + + uint nativeId = eventId; + byte nativeVersion = version; + byte nativeOpcode = opcode; + + if (isClassicEvent) + { + nativeId = ((uint)opcode << 24) | ((uint)version << 16) | eventId; + nativeVersion = 0; + nativeOpcode = 0; + } + + WevtParsedTemplate? template = null; + + if (templateOffset != 0) + { + if (!fieldMapsByTemplateOffset.TryGetValue(templateOffset, out Dictionary? fieldMaps)) + { + fieldMaps = ParseTemplate(data, templateOffset, maps, mapNamesByOffset, logger); + fieldMapsByTemplateOffset[templateOffset] = fieldMaps; + } + + if (fieldMaps is { Count: > 0 }) + { + eventFieldMaps[new WevtEventKey(nativeId, nativeVersion)] = fieldMaps; + } + + if (!templatesByOffset.TryGetValue(templateOffset, out template)) + { + template = ParseTemplateItems(data, templateOffset); + templatesByOffset[templateOffset] = template; + } + } + + events.Add(new WevtProviderEvent(nativeId, nativeVersion, channel, level, nativeOpcode, task, keywords, messageId, template)); + } + + return events; + } + + private static List ParseIdentifiedTable(ReadOnlySpan data, uint tableOffset, int entrySize) + { + List entries = []; + + if (!TryReadUInt32(data, (int)tableOffset + TableEntryCountOffset, out uint count) || count > MaxTableEntryCount) + { + return entries; + } + + for (uint index = 0; index < count; index++) + { + int entryOffset = (int)tableOffset + TableEntryArrayOffset + (int)(index * entrySize); + + if (!TryReadUInt32(data, entryOffset, out uint id) || + !TryReadUInt32(data, entryOffset + IdentifiedMessageIdOffset, out uint messageId) || + !TryReadUInt32(data, entryOffset + IdentifiedNameDataOffset, out uint nameDataOffset)) + { + break; + } + + entries.Add(new WevtIdentifiedEntry(id, messageId, ReadInlineName(data, nameDataOffset))); + } + + return entries; + } + + private static List ParseKeywords(ReadOnlySpan data, uint tableOffset) + { + List keywords = []; + + if (!TryReadUInt32(data, (int)tableOffset + TableEntryCountOffset, out uint count) || count > MaxTableEntryCount) + { + return keywords; + } + + for (uint index = 0; index < count; index++) + { + int entryOffset = (int)tableOffset + TableEntryArrayOffset + (int)(index * KeywordEntrySize); + + if (!TryReadUInt64(data, entryOffset, out ulong mask) || + !TryReadUInt32(data, entryOffset + KeywordMessageIdOffset, out uint messageId) || + !TryReadUInt32(data, entryOffset + KeywordNameDataOffset, out uint nameDataOffset)) + { + break; + } + + keywords.Add(new WevtKeywordEntry(mask, messageId, ReadInlineName(data, nameDataOffset))); + } + + return keywords; + } + + private static List ParseTasks(ReadOnlySpan data, uint tableOffset) + { + List tasks = []; + + if (!TryReadUInt32(data, (int)tableOffset + TableEntryCountOffset, out uint count) || count > MaxTableEntryCount) + { + return tasks; + } + + for (uint index = 0; index < count; index++) + { + int entryOffset = (int)tableOffset + TableEntryArrayOffset + (int)(index * TaskEntrySize); + + if (!TryReadUInt32(data, entryOffset, out uint id) || + !TryReadUInt32(data, entryOffset + IdentifiedMessageIdOffset, out uint messageId) || + !TryReadUInt32(data, entryOffset + TaskEntryNameDataOffset, out uint nameDataOffset)) + { + break; + } + + tasks.Add(new WevtIdentifiedEntry(id, messageId, ReadInlineName(data, nameDataOffset))); + } + + return tasks; + } + + private static Dictionary? ParseTemplate( + ReadOnlySpan data, + uint templateOffset, + Dictionary maps, + Dictionary mapNamesByOffset, + ITraceLogger? logger) + { + if (!TryReadSignature(data, (int)templateOffset, out string signature) || signature != TempSignature) + { + return null; + } + + if (!TryReadUInt32(data, (int)templateOffset + TemplateItemCountOffset, out uint itemCount) || + !TryReadUInt32(data, (int)templateOffset + TemplateItemsPointerOffset, out uint itemsOffset) || + itemCount > MaxTemplateItemCount) + { + return null; + } + + Dictionary? fieldMaps = null; + + for (uint itemIndex = 0; itemIndex < itemCount; itemIndex++) + { + int itemOffset = (int)itemsOffset + (int)(itemIndex * TemplateItemSize); + + if (!TryReadUInt32(data, itemOffset + TemplateItemMapOffset, out uint mapOffset) || + !TryReadUInt32(data, itemOffset + TemplateItemNameOffset, out uint nameOffset)) + { + continue; + } + + if (mapOffset == 0) + { + continue; + } + + if (!TryReadName(data, nameOffset, out string fieldName) || fieldName.Length == 0) + { + continue; + } + + string? mapName = ResolveMapName(data, mapOffset, maps, mapNamesByOffset, logger); + + if (mapName is null) + { + continue; + } + + fieldMaps ??= new Dictionary(StringComparer.Ordinal); + fieldMaps[fieldName] = mapName; + } + + return fieldMaps; + } + + private static WevtParsedTemplate? ParseTemplateItems(ReadOnlySpan data, uint templateOffset) + { + if (!TryReadSignature(data, (int)templateOffset, out string signature) || signature != TempSignature) + { + return null; + } + + if (!TryReadUInt32(data, (int)templateOffset + TemplateItemCountOffset, out uint itemCount) || + !TryReadUInt32(data, (int)templateOffset + TemplateNameCountOffset, out uint nameCount) || + !TryReadUInt32(data, (int)templateOffset + TemplateItemsPointerOffset, out uint itemsOffset) || + itemCount > MaxTemplateItemCount || + nameCount > MaxTemplateItemCount || + nameCount < itemCount) + { + return null; + } + + RawTemplateDescriptor[] raw = new RawTemplateDescriptor[nameCount]; + WevtRawDescriptor[] descriptors = new WevtRawDescriptor[nameCount]; + + for (uint index = 0; index < nameCount; index++) + { + int itemOffset = (int)itemsOffset + (int)(index * TemplateItemSize); + + if (!TryReadUInt32(data, itemOffset + TemplateItemFlagsOffset, out uint flags) || + !TryReadByte(data, itemOffset + TemplateItemInTypeOffset, out byte inType) || + !TryReadByte(data, itemOffset + TemplateItemOutTypeOffset, out byte outType) || + !TryReadUInt16(data, itemOffset + TemplateItemMemberCountOffset, out ushort memberCount) || + !TryReadUInt16(data, itemOffset + TemplateItemInTypeOffset, out ushort memberStart) || + !TryReadUInt16(data, itemOffset + TemplateItemArrayCountOffset, out ushort arrayCount) || + !TryReadUInt16(data, itemOffset + TemplateItemLengthOffset, out ushort length) || + !TryReadUInt32(data, itemOffset + TemplateItemNameOffset, out uint nameOffset) || + !TryReadName(data, nameOffset, out string name)) + { + return null; + } + + raw[index] = new RawTemplateDescriptor(name, inType, outType, memberCount, memberStart, flags, arrayCount, length); + descriptors[index] = new WevtRawDescriptor(name, memberCount > 0); + } + + List nodes = new((int)itemCount); + bool[] memberConsumed = new bool[nameCount]; + + for (uint index = 0; index < itemCount; index++) + { + RawTemplateDescriptor descriptor = raw[index]; + + if (descriptor.MemberCount == 0) + { + nodes.Add(ToLeafNode(descriptor)); + + continue; + } + + long memberEnd = (long)descriptor.MemberStart + descriptor.MemberCount; + + if (descriptor.MemberStart < itemCount || memberEnd > nameCount) + { + return null; + } + + List members = new(descriptor.MemberCount); + + for (int member = descriptor.MemberStart; member < memberEnd; member++) + { + // A member that is itself a struct (nested) or already claimed by another struct is unrepresentable. + if (raw[member].MemberCount != 0 || memberConsumed[member]) + { + return null; + } + + memberConsumed[member] = true; + members.Add(ToLeafNode(raw[member])); + } + + nodes.Add(new WevtStructNode(descriptor.Name, descriptor.Flags, descriptor.ArrayCount, members)); + } + + for (int index = (int)itemCount; index < nameCount; index++) + { + // Every appended member descriptor must be claimed by exactly one struct, or the template is malformed. + if (!memberConsumed[index]) + { + return null; + } + } + + return new WevtParsedTemplate { Nodes = nodes, Descriptors = descriptors }; + } + + private static string? ReadInlineName(ReadOnlySpan data, uint nameDataOffset) + { + if (nameDataOffset == 0 || !TryReadName(data, nameDataOffset, out string name) || name.Length == 0) + { + return null; + } + + return name; + } + + private static string? ResolveMapName( + ReadOnlySpan data, + uint mapOffset, + Dictionary maps, + Dictionary mapNamesByOffset, + ITraceLogger? logger) + { + if (mapNamesByOffset.TryGetValue(mapOffset, out string? cachedName)) + { + return cachedName; + } + + if (!TryParseMap(data, mapOffset, out string mapName, out WevtRawMap? rawMap) || rawMap is null) + { + logger?.Debug($"{nameof(WevtTemplateReader)}: failed to parse map at offset {mapOffset}."); + + return null; + } + + mapNamesByOffset[mapOffset] = mapName; + maps.TryAdd(mapName, rawMap); + + return mapName; + } + + private static WevtLeafNode ToLeafNode(RawTemplateDescriptor descriptor) => + new(descriptor.Name, descriptor.InType, descriptor.OutType, descriptor.Flags, descriptor.ArrayCount, descriptor.Length); + + private static byte[]? TryCopyWevtResource( + string resourceFilePath, + ITraceLogger? logger, + Func allocate, + out int resourceSize) + { + resourceSize = 0; + + if (string.IsNullOrEmpty(resourceFilePath) || + !Path.IsPathFullyQualified(resourceFilePath) || + !File.Exists(resourceFilePath)) + { + return null; + } + + LibraryHandle module = NativeMethods.LoadLibraryExW( + resourceFilePath, + IntPtr.Zero, + LoadLibraryFlags.LOAD_LIBRARY_AS_DATAFILE); + + if (module.IsInvalid) + { + module.Dispose(); + logger?.Debug($"{nameof(WevtTemplateReader)}: LoadLibraryExW failed for {resourceFilePath}."); + + return null; + } + + try + { + IntPtr resourceInfo = NativeMethods.FindResourceW(module, WevtResourceName, WevtResourceType); + + if (resourceInfo == IntPtr.Zero) + { + return null; + } + + IntPtr resourceData = NativeMethods.LoadResource(module, resourceInfo); + + if (resourceData == IntPtr.Zero) + { + return null; + } + + IntPtr resourcePointer = NativeMethods.LockResource(resourceData); + + if (resourcePointer == IntPtr.Zero) + { + return null; + } + + uint size = NativeMethods.SizeofResource(module, resourceInfo); + + if (size is 0 || size > MaxResourceSize) + { + return null; + } + + byte[] buffer = allocate((int)size); + Marshal.Copy(resourcePointer, buffer, 0, (int)size); + resourceSize = (int)size; + + return buffer; + } + finally + { + module.Dispose(); + } + } + + private static bool TryFindProviderOffset( + ReadOnlySpan data, + uint providerCount, + Guid publisherGuid, + out uint providerOffset) + { + providerOffset = 0; + + if (providerCount > MaxProviderCount) + { + return false; + } + + for (uint providerIndex = 0; providerIndex < providerCount; providerIndex++) + { + int descriptorOffset = + CrimProviderDescriptorArrayOffset + (int)(providerIndex * CrimProviderDescriptorSize); + + if (!TryReadGuid(data, descriptorOffset, out Guid guid) || + !TryReadUInt32(data, descriptorOffset + 16, out uint dataOffset)) + { + return false; + } + + if (guid == publisherGuid) + { + providerOffset = dataOffset; + + return true; + } + } + + return false; + } + + private static byte[]? TryLoadWevtResource(string resourceFilePath, ITraceLogger? logger) => + TryCopyWevtResource(resourceFilePath, logger, static size => new byte[size], out _); + + private static bool TryParseMap(ReadOnlySpan data, uint mapOffset, out string mapName, out WevtRawMap? rawMap) + { + mapName = string.Empty; + rawMap = null; + + if (!TryReadSignature(data, (int)mapOffset, out string signature) || + (signature != VmapSignature && signature != BmapSignature)) + { + return false; + } + + if (!TryReadUInt32(data, (int)mapOffset + MapNameOffset, out uint nameOffset) || + !TryReadUInt32(data, (int)mapOffset + MapValueCountOffset, out uint valueCount) || + valueCount > MaxMapEntryCount) + { + return false; + } + + if (!TryReadName(data, nameOffset, out mapName) || mapName.Length == 0) + { + return false; + } + + List entries = new((int)valueCount); + + for (uint entryIndex = 0; entryIndex < valueCount; entryIndex++) + { + int entryOffset = (int)mapOffset + MapEntryArrayOffset + (int)(entryIndex * MapEntrySize); + + if (!TryReadUInt32(data, entryOffset, out uint value) || + !TryReadUInt32(data, entryOffset + 4, out uint messageId)) + { + return false; + } + + entries.Add(new WevtRawMapEntry(value, messageId)); + } + + rawMap = new WevtRawMap(signature == BmapSignature, entries); + + return true; + } + + private static bool TryReadByte(ReadOnlySpan data, int offset, out byte value) + { + if (offset < 0 || offset >= data.Length) + { + value = 0; + + return false; + } + + value = data[offset]; + + return true; + } + + private static bool TryReadGuid(ReadOnlySpan data, int offset, out Guid value) + { + if (offset < 0 || offset > data.Length - 16) + { + value = Guid.Empty; + + return false; + } + + value = new Guid(data.Slice(offset, 16)); + + return true; + } + + private static bool TryReadName(ReadOnlySpan data, uint nameOffset, out string name) + { + name = string.Empty; + + if (!TryReadUInt32(data, (int)nameOffset, out uint totalByteSize) || + totalByteSize < 4 || + totalByteSize > MaxNameByteLength) + { + return false; + } + + int stringByteLength = (int)totalByteSize - 4; + int stringStart = (int)nameOffset + 4; + + if (stringStart < 0 || stringStart > data.Length - stringByteLength) + { + return false; + } + + name = Encoding.Unicode.GetString(data.Slice(stringStart, stringByteLength)).TrimEnd('\0'); + + return true; + } + + private static bool TryReadSignature(ReadOnlySpan data, int offset, out string signature) + { + if (offset < 0 || offset > data.Length - 4) + { + signature = string.Empty; + + return false; + } + + signature = Encoding.ASCII.GetString(data.Slice(offset, 4)); + + return true; + } + + private static bool TryReadUInt16(ReadOnlySpan data, int offset, out ushort value) + { + if (offset < 0 || offset > data.Length - 2) + { + value = 0; + + return false; + } + + value = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(offset, 2)); + + return true; + } + + private static bool TryReadUInt32(ReadOnlySpan data, int offset, out uint value) + { + if (offset < 0 || offset > data.Length - 4) + { + value = 0; + + return false; + } + + value = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offset, 4)); + + return true; + } + + private static bool TryReadUInt64(ReadOnlySpan data, int offset, out ulong value) + { + if (offset < 0 || offset > data.Length - 8) + { + value = 0; + + return false; + } + + value = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8)); + + return true; + } + + private readonly record struct RawTemplateDescriptor( + string Name, + byte InType, + byte OutType, + ushort MemberCount, + ushort MemberStart, + uint Flags, + ushort ArrayCount, + ushort Length); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTemplateWriter.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTemplateWriter.cs new file mode 100644 index 000000000..1a8a17055 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTemplateWriter.cs @@ -0,0 +1,240 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Globalization; +using System.Text; + +namespace EventLogExpert.Eventing.PublisherMetadata.Wevt; + +/// +/// Writes the manifest <template> XML for the parsed WEVT_TEMPLATE node tree: flat +/// <data> leaves and <struct> wrappers around their member leaves. Each leaf carries only +/// name / inType / outType / length / count; the map attribute is injected separately by +/// so a map is never written twice. The whole template fails +/// closed (a null return) when any field is unrepresentable - an unknown inType or outType byte, a length that is +/// neither the pinned field-name reference nor a fixed length on a variable-length type, or a count reference that is +/// out of range or points at a struct - so the reader never emits a guessed or partial template. +/// +internal static class WevtTemplateWriter +{ + private const byte AnsiStringInType = 0x02; + + private const byte BinaryInType = 0x0e; + + private const uint FixedCountArrayFlag = 0x8; + + private const uint FixedLengthFlag = 0x2; + + private const uint LengthFieldReferenceFlag = 0x4; + + private const string TemplateNamespace = "http://schemas.microsoft.com/win/2004/08/events"; + + private const byte UnicodeStringInType = 0x01; + + private const uint VariableCountArrayFlag = 0x10; + + /// + /// Escapes a value for an XML attribute exactly as writes field names, so the factory's map + /// injection searches for the same escaped name it emitted (otherwise injection silently misses). + /// + internal static string EscapeXmlAttribute(string value) => + value.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """).Replace("'", "'"); + + /// + /// Decodes an attribute value produced by back to its literal text, so a render + /// consumer can match a parsed map / field name against the unescaped keys it was stored under. The exact inverse of + /// : the &amp; pass runs last so the body of an already-decoded entity is + /// not re-decoded a second time. + /// + internal static string UnescapeXmlAttribute(ReadOnlySpan value) + { + if (value.IndexOf('&') < 0) { return new string(value); } + + return new string(value) + .Replace("<", "<") + .Replace(">", ">") + .Replace(""", "\"") + .Replace("'", "'") + .Replace("&", "&"); + } + + internal static string? Write( + IReadOnlyList nodes, + IReadOnlyList descriptors) + { + StringBuilder builder = new(); + builder.Append(""); + + return builder.ToString(); + } + + private static bool IsFixedLengthBearingInType(byte inType) => + inType is UnicodeStringInType or AnsiStringInType or BinaryInType; + + private static bool TryAppendLeaf( + StringBuilder builder, + WevtLeafNode leaf, + IReadOnlyList descriptors) + { + if (!WevtTypeNames.TryGetInType(leaf.InType, out string? inType) || + !WevtTypeNames.TryGetOutType(leaf.InType, leaf.OutType, out string? outType) || + !TryResolveLength(leaf, descriptors, out string? lengthValue) || + !TryResolveCount(leaf.Flags, leaf.ArrayCount, leaf.InType, descriptors, out string? countValue)) + { + return false; + } + + builder.Append(""); + + return true; + } + + private static bool TryAppendNodes( + StringBuilder builder, + IReadOnlyList nodes, + IReadOnlyList descriptors) + { + foreach (WevtTemplateNode node in nodes) + { + bool appended = node switch + { + WevtLeafNode leaf => TryAppendLeaf(builder, leaf, descriptors), + WevtStructNode structNode => TryAppendStruct(builder, structNode, descriptors), + _ => false + }; + + if (!appended) { return false; } + } + + return true; + } + + private static bool TryAppendStruct( + StringBuilder builder, + WevtStructNode structNode, + IReadOnlyList descriptors) + { + if (!TryResolveCount(structNode.Flags, structNode.ArrayCount, inType: 0, descriptors, out string? countValue)) + { + return false; + } + + builder.Append("'); + + foreach (WevtLeafNode member in structNode.Members) + { + if (!TryAppendLeaf(builder, member, descriptors)) + { + return false; + } + } + + builder.Append(""); + + return true; + } + + private static bool TryResolveCount( + uint flags, + ushort arrayCount, + byte inType, + IReadOnlyList descriptors, + out string? countValue) + { + countValue = null; + + if ((flags & VariableCountArrayFlag) != 0) + { + if (arrayCount >= descriptors.Count || descriptors[arrayCount].IsStruct) + { + return false; + } + + string referencedName = descriptors[arrayCount].Name; + + if (referencedName.Length == 0) { return false; } + + countValue = referencedName; + + return true; + } + + if ((flags & FixedCountArrayFlag) != 0) + { + countValue = arrayCount.ToString(CultureInfo.InvariantCulture); + + return true; + } + + if ((inType & WevtTypeNames.ArrayFlag) == 0) { return true; } + + countValue = (arrayCount == 0 ? 1 : arrayCount).ToString(CultureInfo.InvariantCulture); + + return true; + } + + private static bool TryResolveLength( + WevtLeafNode leaf, + IReadOnlyList descriptors, + out string? lengthValue) + { + lengthValue = null; + + if ((leaf.Flags & LengthFieldReferenceFlag) != 0) + { + if (leaf.Length >= descriptors.Count || descriptors[leaf.Length].IsStruct) + { + return false; + } + + string referencedName = descriptors[leaf.Length].Name; + + if (referencedName.Length == 0) { return false; } + + lengthValue = referencedName; + + return true; + } + + if ((leaf.Flags & FixedLengthFlag) == 0) { return true; } + + if (leaf.Length == 0 || !IsFixedLengthBearingInType(leaf.InType)) + { + return false; + } + + lengthValue = leaf.Length.ToString(CultureInfo.InvariantCulture); + + return true; + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTypeNames.cs b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTypeNames.cs new file mode 100644 index 000000000..609bd1c4a --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/Wevt/WevtTypeNames.cs @@ -0,0 +1,122 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace EventLogExpert.Eventing.PublisherMetadata.Wevt; + +/// +/// Maps a WEVT_TEMPLATE item's inType / outType byte to its manifest attribute string. inType values are the +/// winmeta in-types; the non-zero outType values are validated against live templates. A zero outType byte means "use +/// the inType's winmeta default outType": the live API always emits an outType attribute, even when it equals the +/// default, so a zero byte resolves through the default table rather than being omitted. +/// +/// +/// Entries 0x0e-0x11 of the non-zero outType table were corrected from positional alignment against live +/// templates (common guesses were wrong): 0x0e=xs:GUID, 0x0f=xs:hexBinary, 0x10=win:HexInt8, 0x11=win:HexInt16. 0x10 +/// is rendering-critical: the wrong guess (win:Pointer) is in the description formatter's display-as-hex set, so it +/// rendered HexInt8 fields as 0x.. instead of decimal. 0x1f matches live casing exactly ("win:NTStatus", not +/// "win:NTSTATUS"). The inType -> default outType table is the canonical winmeta default for each in-type, +/// cross-checked against the explicit outType every real provider field carries: no real provider field stores a zero +/// outType byte, so that table is defensive and exercised only by crafted input. An inType or non-zero outType byte +/// absent from these tables is unrepresentable, and the caller fails the whole template closed rather than emit a +/// guessed token. +/// +internal static class WevtTypeNames +{ + internal const byte ArrayFlag = 0x80; + + private static readonly Dictionary s_defaultOutTypes = new() + { + [0x01] = "xs:string", // win:UnicodeString + [0x02] = "xs:string", // win:AnsiString + [0x03] = "xs:byte", // win:Int8 + [0x04] = "xs:unsignedByte", // win:UInt8 + [0x05] = "xs:short", // win:Int16 + [0x06] = "xs:unsignedShort", // win:UInt16 + [0x07] = "xs:int", // win:Int32 + [0x08] = "xs:unsignedInt", // win:UInt32 + [0x09] = "xs:long", // win:Int64 + [0x0a] = "xs:unsignedLong", // win:UInt64 + [0x0b] = "xs:float", // win:Float (canonical default; unobserved in the probe corpus) + [0x0c] = "xs:double", // win:Double + [0x0d] = "xs:boolean", // win:Boolean + [0x0e] = "xs:hexBinary", // win:Binary + [0x0f] = "xs:GUID", // win:GUID + [0x10] = "win:HexInt64", // win:Pointer + [0x11] = "xs:dateTime", // win:FILETIME + [0x12] = "xs:dateTime", // win:SYSTEMTIME + [0x13] = "xs:string", // win:SID + [0x14] = "win:HexInt32", // win:HexInt32 + [0x15] = "win:HexInt64" // win:HexInt64 + }; + + private static readonly Dictionary s_inTypes = new() + { + [0x01] = "win:UnicodeString", + [0x02] = "win:AnsiString", + [0x03] = "win:Int8", + [0x04] = "win:UInt8", + [0x05] = "win:Int16", + [0x06] = "win:UInt16", + [0x07] = "win:Int32", + [0x08] = "win:UInt32", + [0x09] = "win:Int64", + [0x0a] = "win:UInt64", + [0x0b] = "win:Float", + [0x0c] = "win:Double", + [0x0d] = "win:Boolean", + [0x0e] = "win:Binary", + [0x0f] = "win:GUID", + [0x10] = "win:Pointer", + [0x11] = "win:FILETIME", + [0x12] = "win:SYSTEMTIME", + [0x13] = "win:SID", + [0x14] = "win:HexInt32", + [0x15] = "win:HexInt64" + }; + + private static readonly Dictionary s_outTypes = new() + { + [0x01] = "xs:string", + [0x02] = "xs:dateTime", + [0x03] = "xs:byte", + [0x04] = "xs:unsignedByte", + [0x05] = "xs:short", + [0x06] = "xs:unsignedShort", + [0x07] = "xs:int", + [0x08] = "xs:unsignedInt", + [0x09] = "xs:long", + [0x0a] = "xs:unsignedLong", + [0x0b] = "xs:float", + [0x0c] = "xs:double", + [0x0d] = "xs:boolean", + [0x0e] = "xs:GUID", + [0x0f] = "xs:hexBinary", + [0x10] = "win:HexInt8", + [0x11] = "win:HexInt16", + [0x12] = "win:HexInt32", + [0x13] = "win:HexInt64", + [0x14] = "win:PID", + [0x15] = "win:TID", + [0x16] = "win:Port", + [0x17] = "win:IPv4", + [0x18] = "win:IPv6", + [0x19] = "win:SocketAddress", + [0x1a] = "win:CIMDateTime", + [0x1b] = "win:ETWTIME", + [0x1c] = "win:Xml", + [0x1d] = "win:ErrorCode", + [0x1e] = "win:Win32Error", + [0x1f] = "win:NTStatus", + [0x20] = "win:Hresult" + }; + + internal static bool TryGetInType(byte inType, [MaybeNullWhen(false)] out string value) => + s_inTypes.TryGetValue((byte)(inType & ~ArrayFlag), out value); + + internal static bool TryGetOutType(byte inType, byte outType, [MaybeNullWhen(false)] out string value) => + outType == 0 + ? s_defaultOutTypes.TryGetValue((byte)(inType & ~ArrayFlag), out value) + : s_outTypes.TryGetValue(outType, out value); +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/WevtTemplateReader.cs b/src/EventLogExpert.Eventing/PublisherMetadata/WevtTemplateReader.cs deleted file mode 100644 index efeabd26d..000000000 --- a/src/EventLogExpert.Eventing/PublisherMetadata/WevtTemplateReader.cs +++ /dev/null @@ -1,515 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.Interop; -using EventLogExpert.Logging.Abstractions; -using System.Buffers.Binary; -using System.Runtime.InteropServices; -using System.Text; - -namespace EventLogExpert.Eventing.PublisherMetadata; - -internal readonly record struct WevtRawMapEntry(uint Value, uint MessageId); - -internal sealed record WevtRawMap(bool IsBitMap, IReadOnlyList Entries); - -internal readonly record struct WevtEventKey(uint Id, byte Version); - -internal sealed class WevtTemplateData -{ - public required IReadOnlyDictionary> EventFieldMaps { get; init; } - - public required IReadOnlyDictionary Maps { get; init; } -} - -/// -/// Reads the binary WEVT_TEMPLATE resource embedded in a provider DLL and extracts its valueMap / bitMap -/// definitions and per-event field-to-map associations. -/// -/// -/// EvtOpenPublisherMetadata exposes no valueMap / bitMap tables and strips the map attribute from the -/// template XML, so the decoded names (for example a bus type of 10 shown as SAS) are recovered by -/// parsing the compiled resource directly. Every offset is bounds-checked; a malformed resource yields null. -/// -internal static class WevtTemplateReader -{ - private const string BmapSignature = "BMAP"; - private const int CrimProviderCountOffset = 12; - private const int CrimProviderDescriptorArrayOffset = 16; - private const int CrimProviderDescriptorSize = 20; - private const string CrimSignature = "CRIM"; - private const int EventDefinitionSize = 48; - private const int EventDefinitionTemplateOffset = 20; - private const int EventDefinitionVersionOffset = 2; - private const int EventTableArrayOffset = 16; - private const int EventTableCountOffset = 8; - private const string EvntSignature = "EVNT"; - private const int MapEntryArrayOffset = 20; - private const int MapEntrySize = 8; - private const int MapNameOffset = 8; - private const int MapValueCountOffset = 16; - private const uint MaxElementCount = 4096; - private const uint MaxEventCount = 65536; - private const uint MaxMapEntryCount = 65536; - private const uint MaxNameByteLength = 4096; - private const uint MaxProviderCount = 4096; - private const long MaxResourceSize = 64L * 1024 * 1024; - private const uint MaxTemplateItemCount = 4096; - private const int MinResourceSize = 16; - private const int TemplateItemCountOffset = 8; - private const int TemplateItemMapOffset = 8; - private const int TemplateItemNameOffset = 16; - private const int TemplateItemSize = 20; - private const int TemplateItemsPointerOffset = 16; - private const string TempSignature = "TEMP"; - private const string VmapSignature = "VMAP"; - private const int WevtElementArrayOffset = 20; - private const int WevtElementCountOffset = 12; - private const int WevtElementDescriptorSize = 8; - private const string WevtResourceName = "#1"; - private const string WevtResourceType = "WEVT_TEMPLATE"; - private const string WevtSignature = "WEVT"; - - internal static WevtTemplateData? TryParse(byte[] data, Guid publisherGuid, ITraceLogger? logger) - { - if (!TryReadSignature(data, 0, out string signature) || signature != CrimSignature) - { - return null; - } - - if (!TryReadUInt32(data, CrimProviderCountOffset, out uint providerCount)) - { - return null; - } - - if (!TryFindProviderOffset(data, providerCount, publisherGuid, out uint providerOffset)) - { - logger?.Debug($"{nameof(WevtTemplateReader)}: provider {publisherGuid} not found in WEVT_TEMPLATE."); - - return null; - } - - return ParseProvider(data, providerOffset, logger); - } - - internal static WevtTemplateData? TryRead(string resourceFilePath, Guid publisherGuid, ITraceLogger? logger) - { - if (string.IsNullOrEmpty(resourceFilePath) || - !Path.IsPathFullyQualified(resourceFilePath) || - !File.Exists(resourceFilePath)) - { - return null; - } - - byte[]? resourceBytes = TryLoadWevtResource(resourceFilePath, logger); - - if (resourceBytes is null || resourceBytes.Length < MinResourceSize) - { - return null; - } - - try - { - return TryParse(resourceBytes, publisherGuid, logger); - } - catch (Exception ex) when (ex is not OutOfMemoryException - and not StackOverflowException - and not AccessViolationException) - { - logger?.Debug( - $"{nameof(WevtTemplateReader)}: failed to parse WEVT_TEMPLATE from {resourceFilePath}: {ex.Message}"); - - return null; - } - } - - private static WevtTemplateData? ParseProvider(byte[] data, uint providerOffset, ITraceLogger? logger) - { - if (!TryFindEventTableOffset(data, providerOffset, out uint eventTableOffset)) - { - return null; - } - - if (!TryReadUInt32(data, (int)eventTableOffset + EventTableCountOffset, out uint eventCount) || - eventCount > MaxEventCount) - { - return null; - } - - Dictionary maps = new(StringComparer.Ordinal); - Dictionary mapNamesByOffset = []; - Dictionary?> fieldMapsByTemplateOffset = []; - Dictionary> eventFieldMaps = []; - - for (uint eventIndex = 0; eventIndex < eventCount; eventIndex++) - { - int eventDefinitionOffset = - (int)eventTableOffset + EventTableArrayOffset + (int)(eventIndex * EventDefinitionSize); - - if (!TryReadUInt16(data, eventDefinitionOffset, out ushort eventId) || - !TryReadByte(data, eventDefinitionOffset + EventDefinitionVersionOffset, out byte version) || - !TryReadUInt32(data, eventDefinitionOffset + EventDefinitionTemplateOffset, out uint templateOffset)) - { - continue; - } - - if (templateOffset == 0) - { - continue; - } - - if (!fieldMapsByTemplateOffset.TryGetValue(templateOffset, out Dictionary? fieldMaps)) - { - fieldMaps = ParseTemplate(data, templateOffset, maps, mapNamesByOffset, logger); - fieldMapsByTemplateOffset[templateOffset] = fieldMaps; - } - - if (fieldMaps is { Count: > 0 }) - { - eventFieldMaps[new WevtEventKey(eventId, version)] = fieldMaps; - } - } - - return maps.Count == 0 ? null : new WevtTemplateData { Maps = maps, EventFieldMaps = eventFieldMaps }; - } - - private static Dictionary? ParseTemplate( - byte[] data, - uint templateOffset, - Dictionary maps, - Dictionary mapNamesByOffset, - ITraceLogger? logger) - { - if (!TryReadSignature(data, (int)templateOffset, out string signature) || signature != TempSignature) - { - return null; - } - - if (!TryReadUInt32(data, (int)templateOffset + TemplateItemCountOffset, out uint itemCount) || - !TryReadUInt32(data, (int)templateOffset + TemplateItemsPointerOffset, out uint itemsOffset) || - itemCount > MaxTemplateItemCount) - { - return null; - } - - Dictionary? fieldMaps = null; - - for (uint itemIndex = 0; itemIndex < itemCount; itemIndex++) - { - int itemOffset = (int)itemsOffset + (int)(itemIndex * TemplateItemSize); - - if (!TryReadUInt32(data, itemOffset + TemplateItemMapOffset, out uint mapOffset) || - !TryReadUInt32(data, itemOffset + TemplateItemNameOffset, out uint nameOffset)) - { - continue; - } - - if (mapOffset == 0) - { - continue; - } - - if (!TryReadName(data, nameOffset, out string fieldName) || fieldName.Length == 0) - { - continue; - } - - string? mapName = ResolveMapName(data, mapOffset, maps, mapNamesByOffset, logger); - - if (mapName is null) - { - continue; - } - - fieldMaps ??= new Dictionary(StringComparer.Ordinal); - fieldMaps[fieldName] = mapName; - } - - return fieldMaps; - } - - private static string? ResolveMapName( - byte[] data, - uint mapOffset, - Dictionary maps, - Dictionary mapNamesByOffset, - ITraceLogger? logger) - { - if (mapNamesByOffset.TryGetValue(mapOffset, out string? cachedName)) - { - return cachedName; - } - - if (!TryParseMap(data, mapOffset, out string mapName, out WevtRawMap? rawMap) || rawMap is null) - { - logger?.Debug($"{nameof(WevtTemplateReader)}: failed to parse map at offset {mapOffset}."); - - return null; - } - - mapNamesByOffset[mapOffset] = mapName; - maps.TryAdd(mapName, rawMap); - - return mapName; - } - - private static bool TryFindEventTableOffset(byte[] data, uint providerOffset, out uint eventTableOffset) - { - eventTableOffset = 0; - - if (!TryReadSignature(data, (int)providerOffset, out string signature) || signature != WevtSignature) - { - return false; - } - - if (!TryReadUInt32(data, (int)providerOffset + WevtElementCountOffset, out uint elementCount) || - elementCount > MaxElementCount) - { - return false; - } - - for (uint elementIndex = 0; elementIndex < elementCount; elementIndex++) - { - int descriptorOffset = - (int)providerOffset + WevtElementArrayOffset + (int)(elementIndex * WevtElementDescriptorSize); - - if (!TryReadUInt32(data, descriptorOffset, out uint elementOffset)) - { - return false; - } - - if (TryReadSignature(data, (int)elementOffset, out string elementSignature) && - elementSignature == EvntSignature) - { - eventTableOffset = elementOffset; - - return true; - } - } - - return false; - } - - private static bool TryFindProviderOffset(byte[] data, uint providerCount, Guid publisherGuid, out uint providerOffset) - { - providerOffset = 0; - - if (providerCount > MaxProviderCount) - { - return false; - } - - for (uint providerIndex = 0; providerIndex < providerCount; providerIndex++) - { - int descriptorOffset = - CrimProviderDescriptorArrayOffset + (int)(providerIndex * CrimProviderDescriptorSize); - - if (!TryReadGuid(data, descriptorOffset, out Guid guid) || - !TryReadUInt32(data, descriptorOffset + 16, out uint dataOffset)) - { - return false; - } - - if (guid == publisherGuid) - { - providerOffset = dataOffset; - - return true; - } - } - - return false; - } - - private static byte[]? TryLoadWevtResource(string resourceFilePath, ITraceLogger? logger) - { - LibraryHandle module = NativeMethods.LoadLibraryExW( - resourceFilePath, - IntPtr.Zero, - LoadLibraryFlags.LOAD_LIBRARY_AS_DATAFILE); - - if (module.IsInvalid) - { - logger?.Debug($"{nameof(WevtTemplateReader)}: LoadLibraryExW failed for {resourceFilePath}."); - - return null; - } - - try - { - IntPtr resourceInfo = NativeMethods.FindResourceW(module, WevtResourceName, WevtResourceType); - - if (resourceInfo == IntPtr.Zero) - { - return null; - } - - IntPtr resourceData = NativeMethods.LoadResource(module, resourceInfo); - - if (resourceData == IntPtr.Zero) - { - return null; - } - - IntPtr resourcePointer = NativeMethods.LockResource(resourceData); - - if (resourcePointer == IntPtr.Zero) - { - return null; - } - - uint resourceSize = NativeMethods.SizeofResource(module, resourceInfo); - - if (resourceSize is 0 || resourceSize > MaxResourceSize) - { - return null; - } - - byte[] buffer = new byte[resourceSize]; - Marshal.Copy(resourcePointer, buffer, 0, (int)resourceSize); - - return buffer; - } - finally - { - module.Dispose(); - } - } - - private static bool TryParseMap(byte[] data, uint mapOffset, out string mapName, out WevtRawMap? rawMap) - { - mapName = string.Empty; - rawMap = null; - - if (!TryReadSignature(data, (int)mapOffset, out string signature) || - (signature != VmapSignature && signature != BmapSignature)) - { - return false; - } - - if (!TryReadUInt32(data, (int)mapOffset + MapNameOffset, out uint nameOffset) || - !TryReadUInt32(data, (int)mapOffset + MapValueCountOffset, out uint valueCount) || - valueCount > MaxMapEntryCount) - { - return false; - } - - if (!TryReadName(data, nameOffset, out mapName) || mapName.Length == 0) - { - return false; - } - - List entries = new((int)valueCount); - - for (uint entryIndex = 0; entryIndex < valueCount; entryIndex++) - { - int entryOffset = (int)mapOffset + MapEntryArrayOffset + (int)(entryIndex * MapEntrySize); - - if (!TryReadUInt32(data, entryOffset, out uint value) || - !TryReadUInt32(data, entryOffset + 4, out uint messageId)) - { - return false; - } - - entries.Add(new WevtRawMapEntry(value, messageId)); - } - - rawMap = new WevtRawMap(signature == BmapSignature, entries); - - return true; - } - - private static bool TryReadByte(byte[] data, int offset, out byte value) - { - if (offset < 0 || offset >= data.Length) - { - value = 0; - - return false; - } - - value = data[offset]; - - return true; - } - - private static bool TryReadGuid(byte[] data, int offset, out Guid value) - { - if (offset < 0 || offset + 16 > data.Length) - { - value = Guid.Empty; - - return false; - } - - value = new Guid(data.AsSpan(offset, 16)); - - return true; - } - - private static bool TryReadName(byte[] data, uint nameOffset, out string name) - { - name = string.Empty; - - if (!TryReadUInt32(data, (int)nameOffset, out uint totalByteSize) || - totalByteSize < 4 || - totalByteSize > MaxNameByteLength) - { - return false; - } - - int stringByteLength = (int)totalByteSize - 4; - int stringStart = (int)nameOffset + 4; - - if (stringStart < 0 || stringStart + stringByteLength > data.Length) - { - return false; - } - - name = Encoding.Unicode.GetString(data, stringStart, stringByteLength).TrimEnd('\0'); - - return true; - } - - private static bool TryReadSignature(byte[] data, int offset, out string signature) - { - if (offset < 0 || offset + 4 > data.Length) - { - signature = string.Empty; - - return false; - } - - signature = Encoding.ASCII.GetString(data, offset, 4); - - return true; - } - - private static bool TryReadUInt16(byte[] data, int offset, out ushort value) - { - if (offset < 0 || offset + 2 > data.Length) - { - value = 0; - - return false; - } - - value = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset, 2)); - - return true; - } - - private static bool TryReadUInt32(byte[] data, int offset, out uint value) - { - if (offset < 0 || offset + 4 > data.Length) - { - value = 0; - - return false; - } - - value = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(offset, 4)); - - return true; - } -} diff --git a/src/EventLogExpert.Eventing/Resolvers/DescriptionFormatter.cs b/src/EventLogExpert.Eventing/Resolvers/DescriptionFormatter.cs index 36812a4f2..11c2f4a86 100644 --- a/src/EventLogExpert.Eventing/Resolvers/DescriptionFormatter.cs +++ b/src/EventLogExpert.Eventing/Resolvers/DescriptionFormatter.cs @@ -511,7 +511,7 @@ private string FormatDescription( // Some parameters exceed int size and need to be cast from long to int // because they are actually negative numbers ReadOnlySpan parameterMessage = - parameterSource?.GetParameterByRawId((int)parameterId)?.Text ?? string.Empty; + parameterSource?.GetParameterByRawId(unchecked((int)parameterId))?.Text ?? string.Empty; // Fallback to the cached system message table when the provider's parameter table has no // entry. Caching hits AND misses keeps foreign/uninstalled providers (whose codes never diff --git a/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs b/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs index 13a0b7586..c8e16c12f 100644 --- a/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs +++ b/src/EventLogExpert.Eventing/Resolvers/TemplateAnalyzer.cs @@ -1,6 +1,8 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Eventing.PublisherMetadata.Wevt; +using EventLogExpert.Provider.Resolution; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Runtime.InteropServices; @@ -180,81 +182,30 @@ private static TemplateMetadata BuildMetadata( return new TemplateMetadata(visibleCount, allOutTypes, visibleOutTypes, allMaps, visibleMaps); } - private static string? ExtractAttribute(ReadOnlySpan element, ReadOnlySpan attributePrefix) - { - int index = element.IndexOf(attributePrefix, StringComparison.Ordinal); - - if (index == -1) { return null; } - - index += attributePrefix.Length; - int endIndex = element[index..].IndexOf('"'); - - return endIndex != -1 ? new string(element.Slice(index, endIndex)) : null; - } - private static TemplateMetadata Parse(ReadOnlySpan template) { List<(string name, string outType, string map)> elements = []; HashSet lengthProviderNames = new(StringComparer.OrdinalIgnoreCase); - ReadOnlySpan dataTag = " nameAttr = "name=\""; - ReadOnlySpan outTypeAttr = "outType=\""; - ReadOnlySpan lengthAttr = "length=\""; - ReadOnlySpan mapAttr = "map=\""; - - int searchStart = 0; - - while (searchStart < template.Length) + foreach (TemplateField field in new TemplateFieldReader(template)) { - int dataIndex = template[searchStart..].IndexOf(dataTag, StringComparison.OrdinalIgnoreCase); - - if (dataIndex == -1) { break; } - - dataIndex += searchStart; - - // Verify the character after "' - // to avoid matching tags like ""); + elements.Add(( + field.Name.IsEmpty ? string.Empty : WevtTemplateWriter.UnescapeXmlAttribute(field.Name), + field.OutType.IsEmpty ? string.Empty : WevtTemplateWriter.UnescapeXmlAttribute(field.OutType), + field.Map.IsEmpty ? string.Empty : WevtTemplateWriter.UnescapeXmlAttribute(field.Map))); - if (elementEnd == -1) + if (!field.Length.IsEmpty) { - elementEnd = template[dataIndex..].IndexOf('>'); + lengthProviderNames.Add(WevtTemplateWriter.UnescapeXmlAttribute(field.Length)); } - - if (elementEnd == -1) { break; } - - elementEnd += dataIndex; - - ReadOnlySpan element = template[dataIndex..elementEnd]; - - string name = ExtractAttribute(element, nameAttr) ?? string.Empty; - string outType = ExtractAttribute(element, outTypeAttr) ?? string.Empty; - string map = ExtractAttribute(element, mapAttr) ?? string.Empty; - elements.Add((name, outType, map)); - - string? lengthRef = ExtractAttribute(element, lengthAttr); - - if (lengthRef is not null) - { - lengthProviderNames.Add(lengthRef); - } - - searchStart = elementEnd + 1; } return BuildMetadata(elements, lengthProviderNames); diff --git a/src/EventLogExpert.Provider.Database/Hashing/ProviderContentCanonicalizer.cs b/src/EventLogExpert.Provider.Database/Hashing/ProviderContentEncoder.cs similarity index 92% rename from src/EventLogExpert.Provider.Database/Hashing/ProviderContentCanonicalizer.cs rename to src/EventLogExpert.Provider.Database/Hashing/ProviderContentEncoder.cs index 7c2a6932f..21e32a3e3 100644 --- a/src/EventLogExpert.Provider.Database/Hashing/ProviderContentCanonicalizer.cs +++ b/src/EventLogExpert.Provider.Database/Hashing/ProviderContentEncoder.cs @@ -22,20 +22,16 @@ namespace EventLogExpert.ProviderDatabase.Hashing; /// order is unspecified); event keyword lists are sorted AND de-duplicated (the merger compares them as a set); event, /// message, and parameter entries are encoded to self-delimiting blobs that are sorted ordinally with exact duplicates /// dropped (manifest list order is not a stability contract); ValueMap entries keep their ORIGINAL order (bitmap -/// decoding is order-dependent, so order is content). Strings are preserved EXACTLY - no Unicode or whitespace -/// normalization - so the hash stays injective over the persisted bytes; the database's fail-hard rule requires that -/// identical hashes imply identical content. +/// decoding is order-dependent, so order is content). Strings are preserved EXACTLY, except an event's Template, which +/// is encoded by as render-relevant field tuples so templates that render identically +/// collapse across live and offline builds. /// -internal static class ProviderContentCanonicalizer +internal static class ProviderContentEncoder { - /// - /// Bumping this re-keys every provider on purpose (e.g. after a canonicalization fix). Pair it with the - /// vk1: tag in so providers hashed under different schemes never silently - /// share a key. - /// + // Bump to deliberately re-key every provider after a canonicalization change. private const byte SchemeVersion = 1; - public static byte[] Canonicalize(ProviderDetails provider) + internal static byte[] Encode(ProviderDetails provider) { var buffer = new ArrayBufferWriter(); @@ -72,7 +68,7 @@ private static byte[] EncodeEvent(EventModel model) foreach (var keyword in keywords) { WriteInt64(buffer, keyword); } - WriteString(buffer, model.Template); + TemplateSignature.AppendTo(buffer, model.Template.AsSpan()); WriteString(buffer, model.Description); WriteString(buffer, model.LogName); diff --git a/src/EventLogExpert.Provider.Database/Hashing/VersionKeyCalculator.cs b/src/EventLogExpert.Provider.Database/Hashing/VersionKeyCalculator.cs index 6ef13c9f0..8227a84fe 100644 --- a/src/EventLogExpert.Provider.Database/Hashing/VersionKeyCalculator.cs +++ b/src/EventLogExpert.Provider.Database/Hashing/VersionKeyCalculator.cs @@ -9,8 +9,8 @@ namespace EventLogExpert.ProviderDatabase.Hashing; /// /// Computes a provider's content : a hash of its canonical rendering -/// payload (). Two providers with identical payloads - across machines or -/// OS builds - get the same key and collapse to one database row; genuinely different payloads get different keys and +/// payload (). Two providers with identical payloads - across machines or OS +/// builds - get the same key and collapse to one database row; genuinely different payloads get different keys and /// coexist as separate versions of the same provider name. Stamped when a provider is first ingested from a live scan /// (CreateDatabaseOperation); the merge and diff operations copy already-stamped rows unchanged. The composite /// (ProviderName, VersionKey) primary key can therefore hold distinct versions of one name. @@ -27,7 +27,7 @@ public static class VersionKeyCalculator public static string Compute(ProviderDetails provider) { - var canonical = ProviderContentCanonicalizer.Canonicalize(provider); + var canonical = ProviderContentEncoder.Encode(provider); var hash = SHA256.HashData(canonical); return SchemePrefix + ToBase32Lower(hash); diff --git a/src/EventLogExpert.Provider/Resolution/ProviderContentMerge.cs b/src/EventLogExpert.Provider/Resolution/ProviderContentMerge.cs index c3244f5f2..aa54c8e59 100644 --- a/src/EventLogExpert.Provider/Resolution/ProviderContentMerge.cs +++ b/src/EventLogExpert.Provider/Resolution/ProviderContentMerge.cs @@ -63,7 +63,7 @@ public static bool EventsAreEquivalent(EventModel left, EventModel right) => left.Opcode == right.Opcode && left.Task == right.Task && KeywordsEqual(left.Keywords, right.Keywords) && - string.Equals(left.Template, right.Template, StringComparison.Ordinal) && + TemplateSignature.Equal(left.Template.AsSpan(), right.Template.AsSpan()) && string.Equals(left.Description, right.Description, StringComparison.Ordinal); /// Extracts the of a message row. diff --git a/src/EventLogExpert.Provider/Resolution/TemplateField.cs b/src/EventLogExpert.Provider/Resolution/TemplateField.cs new file mode 100644 index 000000000..215f02556 --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/TemplateField.cs @@ -0,0 +1,55 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Provider.Resolution; + +/// +/// One template <data> node as spans: parsed attributes, or a raw element span when it cannot be +/// canonically parsed. +/// +public readonly ref struct TemplateField +{ + private TemplateField(ReadOnlySpan raw) + { + IsRaw = true; + Raw = raw; + } + + private TemplateField( + ReadOnlySpan name, + ReadOnlySpan inType, + ReadOnlySpan outType, + ReadOnlySpan length, + ReadOnlySpan map) + { + Name = name; + InType = inType; + OutType = outType; + Length = length; + Map = map; + } + + public bool IsRaw { get; } + + public ReadOnlySpan InType { get; } + + public ReadOnlySpan Length { get; } + + public ReadOnlySpan Map { get; } + + public ReadOnlySpan Name { get; } + + public ReadOnlySpan OutType { get; } + + public ReadOnlySpan Raw { get; } + + public static TemplateField Parsed( + ReadOnlySpan name, + ReadOnlySpan inType, + ReadOnlySpan outType, + ReadOnlySpan length, + ReadOnlySpan map) => + new(name, inType, outType, length, map); + + public static TemplateField RawElement(ReadOnlySpan element) => new(element); +} diff --git a/src/EventLogExpert.Provider/Resolution/TemplateFieldReader.cs b/src/EventLogExpert.Provider/Resolution/TemplateFieldReader.cs new file mode 100644 index 000000000..65771da02 --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/TemplateFieldReader.cs @@ -0,0 +1,203 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Provider.Resolution; + +/// +/// Allocation-free enumerator over a template's <data> nodes - the shared decomposition used by the hash, +/// the merge, and the resolver. Non-canonical elements fail closed to a raw node; count and <struct> are +/// excluded as render-dead. +/// +public ref struct TemplateFieldReader(ReadOnlySpan template) +{ + private const string DataTag = " _remaining = template; + + public TemplateField Current { get; private set; } + + public readonly TemplateFieldReader GetEnumerator() => this; + + public bool MoveNext() + { + ReadOnlySpan span = _remaining; + int searchStart = 0; + + while (searchStart < span.Length) + { + int relative = span[searchStart..].IndexOf(DataTag, StringComparison.OrdinalIgnoreCase); + + if (relative == -1) { break; } + + int dataIndex = searchStart + relative; + int afterTag = dataIndex + DataTag.Length; + + // Reject "'. + if (afterTag < span.Length) + { + char next = span[afterTag]; + + if (next is not (' ' or '\t' or '\r' or '\n' or '/' or '>')) + { + searchStart = afterTag; + + continue; + } + } + + ReadOnlySpan fromData = span[dataIndex..]; + int closeIndex = FindElementEnd(fromData); + + if (closeIndex == -1) + { + // Unterminated element: fail closed to a raw node rather than dropping the remainder. + Current = TemplateField.RawElement(fromData); + _remaining = default; + + return true; + } + + int elementEnd = closeIndex > 0 && fromData[closeIndex - 1] == '/' ? closeIndex - 1 : closeIndex; + Current = ParseElement(fromData[..elementEnd]); + _remaining = span[(dataIndex + closeIndex + 1)..]; + + return true; + } + + _remaining = default; + + return false; + } + + private static int FindElementEnd(ReadOnlySpan fromData) + { + char openQuote = '\0'; + + for (int i = DataTag.Length; i < fromData.Length; i++) + { + char c = fromData[i]; + + if (openQuote != '\0') + { + if (c == openQuote) { openQuote = '\0'; } + } + else if (c is '"' or '\'') + { + openQuote = c; + } + else if (c == '>') + { + return i; + } + } + + return -1; + } + + private static bool IsSignatureAttribute(ReadOnlySpan attributeName) => + attributeName.Equals("name", StringComparison.OrdinalIgnoreCase) || + attributeName.Equals("inType", StringComparison.OrdinalIgnoreCase) || + attributeName.Equals("outType", StringComparison.OrdinalIgnoreCase) || + attributeName.Equals("length", StringComparison.OrdinalIgnoreCase) || + attributeName.Equals("map", StringComparison.OrdinalIgnoreCase); + + private static TemplateField ParseElement(ReadOnlySpan element) + { + ReadOnlySpan name = default; + ReadOnlySpan inType = default; + ReadOnlySpan outType = default; + ReadOnlySpan length = default; + ReadOnlySpan map = default; + + // Single forward pass over the element's attributes. + int pos = DataTag.Length; + + while (pos < element.Length) + { + if (element[pos] is ' ' or '\t' or '\r' or '\n' or '/') + { + pos++; + + continue; + } + + int nameStart = pos; + + while (pos < element.Length && element[pos] is not ('=' or ' ' or '\t' or '\r' or '\n' or '/')) { pos++; } + + ReadOnlySpan attributeName = element[nameStart..pos]; + bool signature = IsSignatureAttribute(attributeName); + + while (pos < element.Length && element[pos] is ' ' or '\t' or '\r' or '\n') { pos++; } + + if (pos >= element.Length || element[pos] != '=') + { + // A signature attribute with no value is non-canonical - fail closed. + if (signature) { return TemplateField.RawElement(element); } + + continue; + } + + pos++; + + while (pos < element.Length && element[pos] is ' ' or '\t' or '\r' or '\n') { pos++; } + + if (pos >= element.Length || element[pos] != '"') + { + // Single-quoted / unquoted / missing value: fail closed for a signature attribute, otherwise skip it. + if (signature) { return TemplateField.RawElement(element); } + + pos = SkipValue(element, pos); + + continue; + } + + pos++; + int valueStart = pos; + + while (pos < element.Length && element[pos] != '"') { pos++; } + + ReadOnlySpan value = element[valueStart..pos]; + + if (pos < element.Length) { pos++; } + + if (!signature) { continue; } + + if (attributeName.Equals("name", StringComparison.OrdinalIgnoreCase)) { name = value; } + else if (attributeName.Equals("inType", StringComparison.OrdinalIgnoreCase)) { inType = value; } + else if (attributeName.Equals("outType", StringComparison.OrdinalIgnoreCase)) { outType = value; } + else if (attributeName.Equals("length", StringComparison.OrdinalIgnoreCase)) { length = value; } + else if (attributeName.Equals("map", StringComparison.OrdinalIgnoreCase)) { map = value; } + } + + // An element with no non-empty signature value (name/inType/outType/length/map all absent or empty) is + // non-canonical; fail closed to a raw node so two such elements stay distinct instead of collapsing to one + // all-empty parsed signature. + if (name.IsEmpty && inType.IsEmpty && outType.IsEmpty && length.IsEmpty && map.IsEmpty) + { + return TemplateField.RawElement(element); + } + + return TemplateField.Parsed(name, inType, outType, length, map); + } + + private static int SkipValue(ReadOnlySpan element, int pos) + { + if (pos >= element.Length) { return pos; } + + char quote = element[pos]; + + if (quote is '"' or '\'') + { + pos++; + + while (pos < element.Length && element[pos] != quote) { pos++; } + + return pos < element.Length ? pos + 1 : pos; + } + + while (pos < element.Length && element[pos] is not (' ' or '\t' or '\r' or '\n')) { pos++; } + + return pos; + } +} diff --git a/src/EventLogExpert.Provider/Resolution/TemplateSignature.cs b/src/EventLogExpert.Provider/Resolution/TemplateSignature.cs new file mode 100644 index 000000000..ae9b43046 --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/TemplateSignature.cs @@ -0,0 +1,137 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Buffers; +using System.Buffers.Binary; +using System.Text; + +namespace EventLogExpert.Provider.Resolution; + +/// +/// Canonical byte encoding of a template's render-relevant fields; the content hash and the merge compare +/// templates by this same encoding (insensitive to whitespace, attribute order, and serialization) so they cannot +/// drift. +/// +public static class TemplateSignature +{ + private const byte ParsedNode = 0; + + private const byte RawNode = 1; + + public static void AppendTo(IBufferWriter buffer, ReadOnlySpan template) + { + var counter = new TemplateFieldReader(template); + int count = 0; + + while (counter.MoveNext()) { count++; } + + WriteInt32(buffer, count); + + foreach (TemplateField field in new TemplateFieldReader(template)) + { + if (field.IsRaw) + { + WriteByte(buffer, RawNode); + WriteString(buffer, field.Raw); + + continue; + } + + WriteByte(buffer, ParsedNode); + WriteString(buffer, field.Name); + WriteString(buffer, field.InType); + WriteString(buffer, field.OutType); + WriteString(buffer, field.Length); + WriteString(buffer, field.Map); + } + } + + public static bool Equal(ReadOnlySpan left, ReadOnlySpan right) + { + // Streaming equivalent of comparing the two AppendTo encodings: the byte encoding writes a node count followed by + // each node's framed UTF-8 fields, so two templates are equal iff they yield the same nodes in order with the same + // per-field UTF-8 bytes. Streaming the ref-struct readers in lockstep with an equal-fields fast path avoids the two + // intermediate byte buffers AppendTo+SequenceEqual allocated on the merge hot path (EventsAreEquivalent). + var leftReader = new TemplateFieldReader(left); + var rightReader = new TemplateFieldReader(right); + + while (true) + { + bool leftMoved = leftReader.MoveNext(); + bool rightMoved = rightReader.MoveNext(); + + // A differing node count (the int32 the buffer encoding writes first) fails fast. + if (leftMoved != rightMoved) { return false; } + + if (!leftMoved) { return true; } + + if (!FieldsEqual(leftReader.Current, rightReader.Current)) { return false; } + } + } + + private static bool FieldsEqual(TemplateField left, TemplateField right) + { + if (left.IsRaw != right.IsRaw) { return false; } + + if (left.IsRaw) { return Utf8Equal(left.Raw, right.Raw); } + + return Utf8Equal(left.Name, right.Name) && + Utf8Equal(left.InType, right.InType) && + Utf8Equal(left.OutType, right.OutType) && + Utf8Equal(left.Length, right.Length) && + Utf8Equal(left.Map, right.Map); + } + + // Compares two field spans by the SAME UTF-8 encoding AppendTo's WriteString uses, so merge equality stays byte-exact + // with the content hash and the two cannot drift - including for malformed UTF-16, which UTF-8 collapses to the + // replacement character. Identical UTF-16 always encodes identically (the allocation-free hot path); only differing + // spans of equal encoded length fall through to the byte-level check. + private static bool Utf8Equal(ReadOnlySpan left, ReadOnlySpan right) + { + if (left.SequenceEqual(right)) { return true; } + + int byteCount = Encoding.UTF8.GetByteCount(left); + + if (byteCount != Encoding.UTF8.GetByteCount(right)) { return false; } + + byte[] buffer = ArrayPool.Shared.Rent(byteCount * 2); + + try + { + Span leftBytes = buffer.AsSpan(0, byteCount); + Span rightBytes = buffer.AsSpan(byteCount, byteCount); + Encoding.UTF8.GetBytes(left, leftBytes); + Encoding.UTF8.GetBytes(right, rightBytes); + + return leftBytes.SequenceEqual(rightBytes); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static void WriteByte(IBufferWriter buffer, byte value) + { + Span span = buffer.GetSpan(1); + span[0] = value; + buffer.Advance(1); + } + + private static void WriteInt32(IBufferWriter buffer, int value) + { + BinaryPrimitives.WriteInt32LittleEndian(buffer.GetSpan(sizeof(int)), value); + buffer.Advance(sizeof(int)); + } + + private static void WriteString(IBufferWriter buffer, ReadOnlySpan value) + { + int byteCount = Encoding.UTF8.GetByteCount(value); + WriteInt32(buffer, byteCount); + + if (byteCount == 0) { return; } + + Encoding.UTF8.GetBytes(value, buffer.GetSpan(byteCount)); + buffer.Advance(byteCount); + } +} diff --git a/tests/Integration/EventLogExpert.DatabaseTools.IntegrationTests/Operations/CreateDatabaseOperationTests.cs b/tests/Integration/EventLogExpert.DatabaseTools.IntegrationTests/Operations/CreateDatabaseOperationTests.cs index 4c75feeac..5aa4eae3a 100644 --- a/tests/Integration/EventLogExpert.DatabaseTools.IntegrationTests/Operations/CreateDatabaseOperationTests.cs +++ b/tests/Integration/EventLogExpert.DatabaseTools.IntegrationTests/Operations/CreateDatabaseOperationTests.cs @@ -70,6 +70,22 @@ public async Task CreateDatabase_StampsContentHashVersionKey() Assert.Equal(VersionKeyCalculator.Compute(created), created.VersionKey); } + [Fact] + public async Task CreateDatabase_WhenBothSourceAndOfflineImageGiven_LogsErrorAndDoesNotCreateFile() + { + // Source and offline image are mutually exclusive. The mutual-exclusivity check must fire BEFORE source + // validation, so the user gets the clear "one or the other" message rather than a confusing "source not found". + var path = CreateTempPath(); + var logger = Substitute.For(); + + await new CreateDatabaseOperation(new CreateDatabaseRequest( + path, SourcePath: @"C:\src.db", FilterRegex: null, SkipProvidersInFile: null, OfflineImagePath: @"X:\")) + .ExecuteAsync(logger, null, CancellationToken.None); + + Assert.False(File.Exists(path), "No file should be written when a source and an offline image are both given."); + logger.Received(1).Error(Arg.Is(h => h.ToString().Contains("source OR an offline image"))); + } + [Fact] public async Task CreateDatabase_WhenExtensionNotDb_LogsErrorAndDoesNotCreateFile() { @@ -297,6 +313,38 @@ public async Task CreateDatabase_WhenTargetFileAlreadyExists_LogsErrorAndDoesNot h.ToString().Contains("file already exists") && h.ToString().Contains(path))); } + [Fact] + public async Task CreateDatabase_WhenWimImageFileMissing_LogsErrorAndDoesNotCreateFile() + { + // WIM extraction is supported, but a .wim path that does not exist is rejected up front (before any extraction + // or elevation prompt), so no .db is written. + var path = CreateTempPath(); + var logger = Substitute.For(); + + await new CreateDatabaseOperation(new CreateDatabaseRequest( + path, SourcePath: null, FilterRegex: null, SkipProvidersInFile: null, OfflineImagePath: @"X:\missing.wim", + ImageKind: OfflineImageKind.Wim, WimIndex: 1)).ExecuteAsync(logger, null, CancellationToken.None); + + Assert.False(File.Exists(path), "A missing WIM file is rejected; no file should be written."); + logger.Received(1).Error(Arg.Is(h => h.ToString().Contains("WIM image file not found"))); + } + + [Fact] + public async Task CreateDatabase_WhenWimIndexGivenForDirectoryImage_LogsErrorAndDoesNotCreateFile() + { + // WimIndex only means anything for a WIM image; supplying it for a directory image is rejected rather than + // silently ignored, so the request can't quietly do something other than what the caller asked. + var path = CreateTempPath(); + var logger = Substitute.For(); + + await new CreateDatabaseOperation(new CreateDatabaseRequest( + path, SourcePath: null, FilterRegex: null, SkipProvidersInFile: null, OfflineImagePath: @"X:\", + WimIndex: 1)).ExecuteAsync(logger, null, CancellationToken.None); + + Assert.False(File.Exists(path), "WimIndex applies only to WIM images; no file should be written."); + logger.Received(1).Error(Arg.Is(h => h.ToString().Contains("--wim-index applies only to"))); + } + public void Dispose() { foreach (var path in _tempPaths) diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/EventMessageProviderIntegrationTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/EventMessageProviderIntegrationTests.cs index 554766384..f2032ca7e 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/EventMessageProviderIntegrationTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/EventMessageProviderIntegrationTests.cs @@ -69,22 +69,6 @@ public void LoadProviderDetails_ShouldLogProviderLoadingAttempt() mockLogger.Received().Debug(Arg.Any()); } - [Fact] - public void LoadProviderDetails_WhenCalledMultipleTimes_ShouldReturnConsistentResults() - { - // Arrange - EventMessageProvider provider = new(Constants.TestProviderName); - - // Act - var details1 = provider.LoadProviderDetails(); - var details2 = provider.LoadProviderDetails(); - - // Assert - Assert.NotNull(details1); - Assert.NotNull(details2); - Assert.Equal(details1.ProviderName, details2.ProviderName); - } - [Fact] public void LoadProviderDetails_WhenCalled_ShouldHaveNonNullCollections() { @@ -118,6 +102,22 @@ public void LoadProviderDetails_WhenCalled_ShouldReturnProviderDetails() Assert.Equal(Constants.TestProviderName, details.ProviderName); } + [Fact] + public void LoadProviderDetails_WhenCalledMultipleTimes_ShouldReturnConsistentResults() + { + // Arrange + EventMessageProvider provider = new(Constants.TestProviderName); + + // Act + var details1 = provider.LoadProviderDetails(); + var details2 = provider.LoadProviderDetails(); + + // Assert + Assert.NotNull(details1); + Assert.NotNull(details2); + Assert.Equal(details1.ProviderName, details2.ProviderName); + } + [Fact] public void LoadProviderDetails_WhenChannelOwningPublisherUnknown_ShouldReturnEmptyDetailsWithoutFallback() { @@ -203,6 +203,25 @@ public void LoadProviderDetails_WhenProviderNotFound_ShouldReturnDetailsWithProv Assert.Equal(Constants.TestProviderName, details.ProviderName); } + [Fact] + public void LoadProviderDetails_WhenStableProvider_ShouldResolveNamedValues() + { + // End-to-end: the modern path (ToRawContent -> ProviderDetailsFactory) must resolve the raw keyword/opcode/task + // message ids into non-empty display names for a real provider, not merely read the raw rows. + EventMessageProvider provider = new(Constants.SecurityAuditingLogName); + + var details = provider.LoadProviderDetails(); + + Assert.NotNull(details); + Assert.SkipUnless(!details.IsEmpty, "Test requires the Microsoft-Windows-Security-Auditing provider on the host."); + + var resolvedNames = details.Keywords.Values + .Concat(details.Opcodes.Values) + .Concat(details.Tasks.Values); + + Assert.Contains(resolvedNames, name => !string.IsNullOrEmpty(name)); + } + private static bool TryFindChannelWithDistinctOwningPublisher(out string? channelName, out string? owningPublisher) { foreach (var candidate in EventLogSession.GlobalSession.GetLogNames()) diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/ProviderMetadataTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/ProviderMetadataTests.cs index a0e5cdaed..b648ed603 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/ProviderMetadataTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/ProviderMetadataTests.cs @@ -6,53 +6,11 @@ using EventLogExpert.Logging.Abstractions; using EventLogExpert.Logging.Abstractions.Handlers; using NSubstitute; -using System.Collections.ObjectModel; namespace EventLogExpert.Eventing.IntegrationTests.PublisherMetadata; public sealed class ProviderMetadataTests { - [Fact] - public async Task Channels_WhenAccessedConcurrently_ShouldReturnValidData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = new[] - { - Task.Run(() => metadata?.Channels), - Task.Run(() => metadata?.Channels), - Task.Run(() => metadata?.Channels) - }; - - await Task.WhenAll(tasks); - - // Assert - var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, r => - { - Assert.NotNull(r); - Assert.NotEmpty(r); - }); - } - - [Fact] - public void Channels_WhenCalledMultipleTimes_ShouldReturnSameInstance() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var channels1 = metadata?.Channels; - var channels2 = metadata?.Channels; - - // Assert - Assert.NotNull(channels1); - Assert.NotNull(channels2); - Assert.Same(channels1, channels2); - } - [Fact] public void Channels_WhenProviderHasChannels_ShouldHaveValidKeys() { @@ -60,7 +18,7 @@ public void Channels_WhenProviderHasChannels_ShouldHaveValidKeys() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var channels = metadata?.Channels; + var channels = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Channels; // Assert Assert.NotNull(channels); @@ -73,21 +31,6 @@ public void Channels_WhenProviderHasChannels_ShouldHaveValidKeys() }); } - [Fact] - public void Channels_WhenProviderHasNoDuplicateChannelIds_ShouldNotLoseData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var channels = metadata?.Channels; - - // Assert - Assert.NotNull(channels); - var uniqueIds = channels.Keys.Distinct().Count(); - Assert.Equal(channels.Count, uniqueIds); - } - [Fact] public void Channels_WhenValidProvider_ShouldContainData() { @@ -95,27 +38,13 @@ public void Channels_WhenValidProvider_ShouldContainData() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var channels = metadata?.Channels; + var channels = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Channels; // Assert Assert.NotNull(channels); Assert.NotEmpty(channels); } - [Fact] - public void Channels_WhenValidProvider_ShouldReturnReadOnlyDictionary() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var channels = metadata?.Channels; - - // Assert - Assert.NotNull(channels); - Assert.IsAssignableFrom>(channels); - } - [Theory] [InlineData(Constants.SecurityAuditingLogName)] [InlineData(Constants.KernelGeneralLogName)] @@ -258,16 +187,15 @@ public void Events_WhenProviderHasEvents_ShouldHaveValidEventMetadata() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var events = metadata?.Events?.ToList(); + var events = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Events; // Assert Assert.NotNull(events); if (events.Count == 0) { return; } - var firstEvent = events.First(); - Assert.True(firstEvent.Id >= 0); - Assert.True(firstEvent.Version >= 0); + var firstEvent = events[0]; + Assert.True(firstEvent.Id > 0); } [Fact] @@ -277,7 +205,7 @@ public void Events_WhenValidProvider_ShouldContainEventMetadata() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var events = metadata?.Events?.ToList(); + var events = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Events; // Assert Assert.NotNull(events); @@ -285,10 +213,10 @@ public void Events_WhenValidProvider_ShouldContainEventMetadata() if (events.Count == 0) { return; } Assert.All(events, - e => + providerEvent => { - Assert.NotNull(e); - Assert.True(e.Id > 0); + Assert.NotNull(providerEvent); + Assert.True(providerEvent.Id > 0); }); } @@ -299,53 +227,13 @@ public void Events_WhenValidProvider_ShouldContainEvents() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var events = metadata?.Events?.ToList(); + var events = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Events; // Assert Assert.NotNull(events); Assert.NotEmpty(events); } - [Fact] - public async Task Keywords_WhenAccessedConcurrently_ShouldReturnValidData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.PowerShellLogName); - - // Act - var tasks = new[] - { - Task.Run(() => metadata?.Keywords), - Task.Run(() => metadata?.Keywords), - Task.Run(() => metadata?.Keywords) - }; - - await Task.WhenAll(tasks); - - // Assert - var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, r => Assert.NotNull(r)); - // Verify all results have the same count (cached properly) - var firstCount = results[0]?.Count ?? 0; - Assert.All(results, r => Assert.Equal(firstCount, r?.Count ?? 0)); - } - - [Fact] - public void Keywords_WhenCalledMultipleTimes_ShouldReturnSameInstance() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var keywords1 = metadata?.Keywords; - var keywords2 = metadata?.Keywords; - - // Assert - Assert.NotNull(keywords1); - Assert.NotNull(keywords2); - Assert.Same(keywords1, keywords2); - } - [Fact] public void Keywords_WhenProviderHasKeywords_ShouldContainData() { @@ -353,7 +241,7 @@ public void Keywords_WhenProviderHasKeywords_ShouldContainData() var metadata = ProviderMetadata.Create(Constants.PowerShellLogName); // Act - var keywords = metadata?.Keywords; + var keywords = metadata?.ToRawContent(Constants.PowerShellLogName, null).Keywords; // Assert Assert.NotNull(keywords); @@ -372,47 +260,19 @@ public void Keywords_WhenProviderHasKeywords_ShouldHaveValidValues() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var keywords = metadata?.Keywords; + var keywords = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Keywords; // Assert Assert.NotNull(keywords); + // Each raw entry carries a name source: an inline name, or a message id to resolve. Assert.All(keywords, keyword => { - Assert.False(string.IsNullOrEmpty(keyword.Value)); + Assert.True(keyword.MessageId != uint.MaxValue || !string.IsNullOrEmpty(keyword.InlineName)); }); } - [Fact] - public void Keywords_WhenProviderHasNoDuplicateKeywordValues_ShouldNotLoseData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var keywords = metadata?.Keywords; - - // Assert - Assert.NotNull(keywords); - var uniqueValues = keywords.Keys.Distinct().Count(); - Assert.Equal(keywords.Count, uniqueValues); - } - - [Fact] - public void Keywords_WhenValidProvider_ShouldReturnReadOnlyDictionary() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var keywords = metadata?.Keywords; - - // Assert - Assert.NotNull(keywords); - Assert.IsAssignableFrom>(keywords); - } - [Fact] public void MessageFilePath_WhenCalledMultipleTimes_ShouldReturnConsistentPath() { @@ -488,62 +348,6 @@ public void MessageFilePath_WhenValidProvider_ShouldReturnPath() Assert.NotNull(messageFilePath); } - [Fact] - public async Task Opcodes_WhenAccessedConcurrently_ShouldReturnValidData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = new[] - { - Task.Run(() => metadata?.Opcodes), - Task.Run(() => metadata?.Opcodes), - Task.Run(() => metadata?.Opcodes) - }; - - await Task.WhenAll(tasks); - - // Assert - var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, r => - { - Assert.NotNull(r); - Assert.NotEmpty(r); - }); - } - - [Fact] - public void Opcodes_WhenCalledMultipleTimes_ShouldReturnSameInstance() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var opcodes1 = metadata?.Opcodes; - var opcodes2 = metadata?.Opcodes; - - // Assert - Assert.NotNull(opcodes1); - Assert.NotNull(opcodes2); - Assert.Same(opcodes1, opcodes2); - } - - [Fact] - public void Opcodes_WhenProviderHasNoDuplicateOpcodeValues_ShouldNotLoseData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var opcodes = metadata?.Opcodes; - - // Assert - Assert.NotNull(opcodes); - var uniqueValues = opcodes.Keys.Distinct().Count(); - Assert.Equal(opcodes.Count, uniqueValues); - } - [Fact] public void Opcodes_WhenProviderHasOpcodes_ShouldHaveValidValues() { @@ -551,15 +355,16 @@ public void Opcodes_WhenProviderHasOpcodes_ShouldHaveValidValues() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var opcodes = metadata?.Opcodes; + var opcodes = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Opcodes; // Assert Assert.NotNull(opcodes); + // Each raw entry carries a name source: an inline name, or a message id to resolve. Assert.All(opcodes, opcode => { - Assert.False(string.IsNullOrEmpty(opcode.Value)); + Assert.True(opcode.MessageId != uint.MaxValue || !string.IsNullOrEmpty(opcode.InlineName)); }); } @@ -570,27 +375,13 @@ public void Opcodes_WhenValidProvider_ShouldContainData() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var opcodes = metadata?.Opcodes; + var opcodes = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Opcodes; // Assert Assert.NotNull(opcodes); Assert.NotEmpty(opcodes); } - [Fact] - public void Opcodes_WhenValidProvider_ShouldReturnReadOnlyDictionary() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var opcodes = metadata?.Opcodes; - - // Assert - Assert.NotNull(opcodes); - Assert.IsAssignableFrom>(opcodes); - } - [Fact] public void ParameterFilePath_WhenCalledMultipleTimes_ShouldReturnConsistentPath() { @@ -638,62 +429,6 @@ public void ParameterFilePath_WhenValidProvider_ShouldReturnPath() Assert.NotNull(parameterFilePath); } - [Fact] - public async Task Tasks_WhenAccessedConcurrently_ShouldReturnValidData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = new[] - { - Task.Run(() => metadata?.Tasks), - Task.Run(() => metadata?.Tasks), - Task.Run(() => metadata?.Tasks) - }; - - await Task.WhenAll(tasks); - - // Assert - var results = tasks.Select(t => t.Result).ToList(); - Assert.All(results, r => - { - Assert.NotNull(r); - Assert.NotEmpty(r); - }); - } - - [Fact] - public void Tasks_WhenCalledMultipleTimes_ShouldReturnSameInstance() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks1 = metadata?.Tasks; - var tasks2 = metadata?.Tasks; - - // Assert - Assert.NotNull(tasks1); - Assert.NotNull(tasks2); - Assert.Same(tasks1, tasks2); - } - - [Fact] - public void Tasks_WhenProviderHasNoDuplicateTaskValues_ShouldNotLoseData() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = metadata?.Tasks; - - // Assert - Assert.NotNull(tasks); - var uniqueValues = tasks.Keys.Distinct().Count(); - Assert.Equal(tasks.Count, uniqueValues); - } - [Fact] public void Tasks_WhenProviderHasTasks_ShouldHaveValidValues() { @@ -701,15 +436,16 @@ public void Tasks_WhenProviderHasTasks_ShouldHaveValidValues() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var tasks = metadata?.Tasks; + var tasks = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Tasks; // Assert Assert.NotNull(tasks); + // Each raw entry carries a name source: an inline name, or a message id to resolve. Assert.All(tasks, task => { - Assert.False(string.IsNullOrEmpty(task.Value)); + Assert.True(task.MessageId != uint.MaxValue || !string.IsNullOrEmpty(task.InlineName)); }); } @@ -720,24 +456,10 @@ public void Tasks_WhenValidProvider_ShouldContainData() var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); // Act - var tasks = metadata?.Tasks; + var tasks = metadata?.ToRawContent(Constants.SecurityAuditingLogName, null).Tasks; // Assert Assert.NotNull(tasks); Assert.NotEmpty(tasks); } - - [Fact] - public void Tasks_WhenValidProvider_ShouldReturnReadOnlyDictionary() - { - // Arrange - var metadata = ProviderMetadata.Create(Constants.SecurityAuditingLogName); - - // Act - var tasks = metadata?.Tasks; - - // Assert - Assert.NotNull(tasks); - Assert.IsAssignableFrom>(tasks); - } } diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/HostOsProvenanceTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/SourceOsProvenanceTests.cs similarity index 91% rename from tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/HostOsProvenanceTests.cs rename to tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/SourceOsProvenanceTests.cs index fad68c818..9d592ec60 100644 --- a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/HostOsProvenanceTests.cs +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/SourceOsProvenanceTests.cs @@ -6,12 +6,12 @@ namespace EventLogExpert.Eventing.IntegrationTests.PublisherMetadata; -public sealed class HostOsProvenanceTests +public sealed class SourceOsProvenanceTests { [Fact] public void Empty_HasAllNullFields() { - var empty = HostOsProvenance.Empty; + var empty = SourceOsProvenance.Empty; Assert.Null(empty.Build); Assert.Null(empty.Revision); @@ -33,7 +33,7 @@ public void Read_OnWindowsHost_PopulatesEditionRevisionAndDisplayVersion() // UBR is present on every supported build; the read must surface it as the recency secondary. var expectedRevision = currentVersion.GetValue("UBR") is int ubr ? ubr : (int?)null; - var provenance = HostOsProvenance.Read(); + var provenance = SourceOsProvenance.Read(); Assert.Equal(expectedEdition, provenance.Edition); Assert.Equal(expectedRevision, provenance.Revision); @@ -54,7 +54,7 @@ public void Read_OnWindowsHost_ReturnsBuildMatchingRegistry() ? build : (int?)null; - var provenance = HostOsProvenance.Read(); + var provenance = SourceOsProvenance.Read(); Assert.Equal(expectedBuild, provenance.Build); Assert.NotNull(provenance.Build); diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/Wevt/OfflineWevtProviderParityTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/Wevt/OfflineWevtProviderParityTests.cs new file mode 100644 index 000000000..91e42d179 --- /dev/null +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/PublisherMetadata/Wevt/OfflineWevtProviderParityTests.cs @@ -0,0 +1,603 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata; +using EventLogExpert.Eventing.PublisherMetadata.Wevt; +using EventLogExpert.Eventing.TestUtils.Constants; +using EventLogExpert.Provider.Resolution; +using EventLogExpert.ProviderDatabase.Hashing; +using System.Buffers; +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace EventLogExpert.Eventing.IntegrationTests.PublisherMetadata.Wevt; + +public sealed class OfflineWevtProviderParityTests( + OfflineWevtProviderParityTests.SecurityAuditingParityFixture securityAuditing, + OfflineWevtProviderParityTests.PowerShellParityFixture powerShell, + OfflineWevtProviderParityTests.KernelPowerParityFixture kernelPower, + OfflineWevtProviderParityTests.PerfOsParityFixture perfOs) + : IClassFixture, + IClassFixture, + IClassFixture, + IClassFixture +{ + [Fact] + public void Descriptions_SharedByIdAndVersion_AreByteIdenticalToNative() + { + Assert.SkipUnless(securityAuditing.Available, SkipReasonFor(securityAuditing)); + + Dictionary<(long Id, byte Version), EventModel> nativeByKey = BuildEventLookup(securityAuditing.Native!); + int comparedCount = 0; + + foreach (EventModel offlineEvent in securityAuditing.Offline!.Events) + { + if (!nativeByKey.TryGetValue((offlineEvent.Id, offlineEvent.Version), out EventModel? nativeEvent) || + string.IsNullOrEmpty(nativeEvent.Description)) + { + continue; + } + + Assert.Equal(nativeEvent.Description, offlineEvent.Description); + comparedCount++; + } + + Assert.True(comparedCount > 0, "Expected at least one shared event with a non-empty native description to compare."); + } + + [Fact] + public void Events_KernelPowerTemplates_HaveLengthOutTypeAndArrayCountParity() + { + Assert.SkipUnless(kernelPower.Available, SkipReasonFor(kernelPower)); + + Dictionary<(long Id, byte Version), EventModel> nativeByKey = BuildEventLookup(kernelPower.Native!); + int comparedCount = 0; + int lengthBearingNodes = 0; + int fileTimeNodes = 0; + int arrayBearingNodes = 0; + + foreach (EventModel offlineEvent in kernelPower.Offline!.Events) + { + if (string.IsNullOrEmpty(offlineEvent.Template)) { continue; } + + if (!nativeByKey.TryGetValue((offlineEvent.Id, offlineEvent.Version), out EventModel? nativeEvent) || + string.IsNullOrEmpty(nativeEvent.Template)) + { + continue; + } + + List? nativeNodes = ParseDataNodes(nativeEvent.Template); + List? offlineNodes = ParseDataNodes(offlineEvent.Template); + + // Skip struct-bearing native templates here: ParseDataNodes compares only top-level nodes, so structs + // are covered by the dedicated Events_StructTemplates_CanonicalizeToNativeStructure test instead. + if (nativeNodes is null || offlineNodes is null || TemplateHasStruct(nativeEvent.Template)) { continue; } + + // The structural compare includes the length and count attributes, so a length field-reference miss, an array + // count miss, or a missing / wrong outType (the writer always emits one) all fail here. + Assert.Equal(nativeNodes, offlineNodes); + comparedCount++; + lengthBearingNodes += offlineNodes.Count(static node => !string.IsNullOrEmpty(node.Length)); + fileTimeNodes += offlineNodes.Count(static node => node.InType == "win:FILETIME"); + arrayBearingNodes += offlineNodes.Count(static node => !string.IsNullOrEmpty(node.Count)); + } + + Assert.True(comparedCount > 0, "Expected at least one Kernel-Power non-struct template to compare."); + + // Kernel-Power is the corpus that proves length field-references (FIX 1) and always-emitted outType for FILETIME + // (FIX 2): both must appear among the matched templates, otherwise the parity passed without exercising them. It + // also carries array fields, so count="N" / count="" reproduction is proven non-vacuously here too. + Assert.True(lengthBearingNodes > 0, "Expected at least one matched length-bearing field (length field-reference parity)."); + Assert.True(fileTimeNodes > 0, "Expected at least one matched FILETIME field (always-emitted outType parity)."); + Assert.True(arrayBearingNodes > 0, "Expected at least one matched array field (count attribute parity)."); + } + + [Fact] + public void Events_NonStructTemplates_HaveStructuralParity() + { + Assert.SkipUnless(securityAuditing.Available, SkipReasonFor(securityAuditing)); + + Dictionary<(long Id, byte Version), EventModel> nativeByKey = BuildEventLookup(securityAuditing.Native!); + int comparedCount = 0; + + foreach (EventModel offlineEvent in securityAuditing.Offline!.Events) + { + if (string.IsNullOrEmpty(offlineEvent.Template)) { continue; } + + if (!nativeByKey.TryGetValue((offlineEvent.Id, offlineEvent.Version), out EventModel? nativeEvent) || + string.IsNullOrEmpty(nativeEvent.Template)) + { + continue; + } + + List? nativeNodes = ParseDataNodes(nativeEvent.Template); + List? offlineNodes = ParseDataNodes(offlineEvent.Template); + + // Skip struct-bearing native templates here: ParseDataNodes compares only top-level nodes, so structs + // are covered by the dedicated Events_StructTemplates_CanonicalizeToNativeStructure test instead. + if (nativeNodes is null || offlineNodes is null || TemplateHasStruct(nativeEvent.Template)) { continue; } + + Assert.Equal(nativeNodes, offlineNodes); + comparedCount++; + } + + Assert.True(comparedCount > 0, "Expected at least one non-struct template to compare for structural parity."); + } + + [Fact] + public void Events_SharedByIdAndVersion_HaveFieldParity() + { + Assert.SkipUnless(securityAuditing.Available, SkipReasonFor(securityAuditing)); + + Dictionary<(long Id, byte Version), EventModel> nativeByKey = BuildEventLookup(securityAuditing.Native!); + int comparedCount = 0; + + foreach (EventModel offlineEvent in securityAuditing.Offline!.Events) + { + if (!nativeByKey.TryGetValue((offlineEvent.Id, offlineEvent.Version), out EventModel? nativeEvent)) + { + continue; + } + + Assert.Equal(nativeEvent.Level, offlineEvent.Level); + Assert.Equal(nativeEvent.Opcode, offlineEvent.Opcode); + Assert.Equal(nativeEvent.Task, offlineEvent.Task); + Assert.Equal(nativeEvent.LogName, offlineEvent.LogName); + Assert.Equal(nativeEvent.Keywords, offlineEvent.Keywords); + comparedCount++; + } + + Assert.True(comparedCount > 0, "Expected at least one event shared by id and version to compare for field parity."); + } + + [Fact] + public void Events_SharedNonStructTemplates_OfflineWritesWheneverNativeDoes() + { + // Closes a weak-gate blindspot: the structural parity tests skip events whose offline Template is empty, so a + // partial fail-closed regression (some templates that should be written start returning "") would still pass as + // long as one survivor matched. Here, over the shared (Id, Version) events whose native template is non-empty and + // not a struct, the count of offline events that also wrote a non-empty template must equal the native + // count - whenever native renders a non-struct template, offline must too. Native-only events are ignored so the + // native superset is tolerated, matching the other parity tests. + (ProviderParityFixture Fixture, string Label)[] corpus = + [ + (securityAuditing, nameof(securityAuditing)), + (kernelPower, nameof(kernelPower)), + (powerShell, nameof(powerShell)) + ]; + + Assert.SkipUnless( + corpus.Any(static entry => entry.Fixture.Available), + "Test requires at least one parity provider and its WEVT_TEMPLATE resource on the host."); + + int comparedProviders = 0; + + foreach ((ProviderParityFixture fixture, string label) in corpus) + { + if (!fixture.Available) { continue; } + + Dictionary<(long Id, byte Version), EventModel> nativeByKey = BuildEventLookup(fixture.Native!); + Dictionary<(long Id, byte Version), EventModel> offlineByKey = BuildEventLookup(fixture.Offline!); + int nativeWritable = 0; + int offlineWritten = 0; + + foreach (((long Id, byte Version) key, EventModel nativeEvent) in nativeByKey) + { + if (string.IsNullOrEmpty(nativeEvent.Template) || TemplateHasStruct(nativeEvent.Template!)) { continue; } + + if (!offlineByKey.TryGetValue(key, out EventModel? offlineEvent)) { continue; } + + nativeWritable++; + + if (!string.IsNullOrEmpty(offlineEvent.Template)) { offlineWritten++; } + } + + Assert.True(nativeWritable > 0, $"{label}: expected at least one shared non-struct native template to guard."); + Assert.Equal(nativeWritable, offlineWritten); + comparedProviders++; + } + + Assert.True(comparedProviders > 0, "Expected at least one parity provider to compare."); + } + + [Fact] + public void Events_StructTemplates_CanonicalizeToNativeStructure() + { + string[] structProviders = + [ + Constants.BitsClientLogName, + Constants.Direct3D11LogName, + Constants.DotNetRuntimeLogName, + Constants.DwmCoreLogName + ]; + + int comparedProviders = 0; + int comparedStructEvents = 0; + + foreach (string providerName in structProviders) + { + if (!TryLoadNativeAndOffline(providerName, out ProviderDetails? native, out ProviderDetails? offline)) + { + continue; + } + + Dictionary<(long Id, byte Version), EventModel> offlineByKey = BuildEventLookup(offline!); + bool comparedThisProvider = false; + + foreach (EventModel nativeEvent in native!.Events) + { + if (string.IsNullOrEmpty(nativeEvent.Template) || !TemplateHasStruct(nativeEvent.Template)) + { + continue; + } + + if (!offlineByKey.TryGetValue((nativeEvent.Id, nativeEvent.Version), out EventModel? offlineEvent)) + { + continue; + } + + string? nativeShape = CanonicalizeTemplate(nativeEvent.Template); + + // Native always renders a parseable struct template here; if it somehow does not, there is nothing to gate. + if (nativeShape is null) { continue; } + + // These four providers carry only non-nested structs, so offline must synthesize every struct event native + // renders - a null/empty offline shape is a fail-closed regression, not an expected skip. + string? offlineShape = CanonicalizeTemplate(offlineEvent.Template); + Assert.True( + offlineShape is not null, + $"{providerName}: offline emitted an empty or unparseable template for struct event Id={nativeEvent.Id} V{nativeEvent.Version} that native renders."); + + // The canonical form preserves struct name / count / nesting and each data node's name/inType/outType/ + // length/count, so a struct-name, count-mode, member-type, or ordering regression fails here. + Assert.Equal(nativeShape, offlineShape); + comparedStructEvents++; + comparedThisProvider = true; + } + + if (comparedThisProvider) { comparedProviders++; } + } + + Assert.SkipUnless( + comparedProviders > 0, + "Test requires at least one struct-bearing provider and its WEVT_TEMPLATE resource on the host."); + + Assert.True(comparedStructEvents > 0, "Expected at least one struct-bearing event to compare."); + } + + [Fact] + public void Keywords_OfflineEntries_MatchNativeByKeyAndValue() + { + Assert.SkipUnless(powerShell.Available, SkipReasonFor(powerShell)); + + // PowerShell defines keywords, so this exercises keyword decode non-vacuously. + Assert.NotEmpty(powerShell.Offline!.Keywords); + + // A partial decode regression is caught here: every parsed keyword (post-dedup by mask) must survive into the + // resolved table, not just a non-empty subset of it. + Assert.Equal(powerShell.RawKeywordKeyCount, powerShell.Offline!.Keywords.Count); + + AssertOfflineMatchesNative(powerShell.Native!.Keywords, powerShell.Offline!.Keywords); + } + + [Fact] + public void Maps_OfflineEntries_MatchNativeByKeyAndValue() + { + Assert.SkipUnless(powerShell.Available, SkipReasonFor(powerShell)); + + // PowerShell defines value maps, so this exercises map decode non-vacuously. + Assert.NotEmpty(powerShell.Offline!.Maps); + + Assert.Equal(powerShell.RawMapCount, powerShell.Offline!.Maps.Count); + + foreach ((string mapName, ValueMapDefinition offlineMap) in powerShell.Offline!.Maps) + { + Assert.True(powerShell.Native!.Maps.TryGetValue(mapName, out ValueMapDefinition? nativeMap), + $"Native maps are missing the offline map '{mapName}'."); + Assert.Equal(nativeMap!.IsBitMap, offlineMap.IsBitMap); + Assert.Equal(nativeMap.Entries, offlineMap.Entries); + } + } + + [Fact] + public void Messages_OfflineEntries_MatchNative() + { + Assert.SkipUnless(securityAuditing.Available, SkipReasonFor(securityAuditing)); + + Assert.NotEmpty(securityAuditing.Native!.Messages); + + AssertMessagesEqual(securityAuditing.Native!.Messages, securityAuditing.Offline!.Messages); + } + + [Fact] + public void Opcodes_OfflineEntries_MatchNativeByKeyAndValue() + { + Assert.SkipUnless(powerShell.Available, SkipReasonFor(powerShell)); + + // PowerShell defines many opcodes, exercising opcode decode: the raw OPCO id is already opcode << 16 and is passed + // through unchanged so the factory's (int)((uint)Value >> 16) projection recovers the native opcode key. + Assert.NotEmpty(powerShell.Offline!.Opcodes); + + Assert.Equal(powerShell.RawOpcodeKeyCount, powerShell.Offline!.Opcodes.Count); + + AssertOfflineMatchesNative(powerShell.Native!.Opcodes, powerShell.Offline!.Opcodes); + } + + [Fact] + public void Tasks_OfflineEntries_MatchNativeByKeyAndValue() + { + Assert.SkipUnless(powerShell.Available, SkipReasonFor(powerShell)); + + Assert.NotEmpty(powerShell.Offline!.Tasks); + + Assert.Equal(powerShell.RawTaskKeyCount, powerShell.Offline!.Tasks.Count); + + AssertOfflineMatchesNative(powerShell.Native!.Tasks, powerShell.Offline!.Tasks); + } + + [Fact] + public void VersionKey_OfflineProvider_EqualsNative() + { + Assert.SkipUnless(securityAuditing.Available, SkipReasonFor(securityAuditing)); + + Assert.Null(securityAuditing.Native!.ResolvedFromOwningPublisher); + + Assert.Equal( + VersionKeyCalculator.Compute(securityAuditing.Native!), + VersionKeyCalculator.Compute(securityAuditing.Offline!)); + } + + [Fact] + public void VersionKey_PerfOsClassicParameterReferenceProvider_EqualsNative() + { + Assert.SkipUnless(perfOs.Available, SkipReasonFor(perfOs)); + + Assert.Null(perfOs.Native!.ResolvedFromOwningPublisher); + + Assert.NotEmpty(perfOs.Offline!.Events); + + Assert.Equal( + VersionKeyCalculator.Compute(perfOs.Native!), + VersionKeyCalculator.Compute(perfOs.Offline!)); + } + + private static void AppendCanonicalElements(StringBuilder builder, IEnumerable elements) + { + foreach (XElement element in elements) + { + switch (element.Name.LocalName) + { + case "struct": + builder.Append("'); + AppendCanonicalElements(builder, element.Elements()); + builder.Append(""); + break; + case "data": + builder.Append(""); + break; + } + } + } + + private static void AssertMessagesEqual(IReadOnlyList native, IReadOnlyList offline) + { + static MessageKey ToKey(MessageModel message) => + new(message.ShortId, message.RawId, message.LogLink, message.Tag, message.Template, message.Text); + + HashSet nativeSet = native.Select(ToKey).ToHashSet(); + HashSet offlineSet = offline.Select(ToKey).ToHashSet(); + + Assert.True( + nativeSet.SetEquals(offlineSet), + $"Offline messages diverge from native: native {nativeSet.Count} distinct, offline {offlineSet.Count} distinct."); + } + + private static void AssertOfflineMatchesNative(IDictionary native, IDictionary offline) + where TKey : notnull + { + // Parity is by key and value, tolerating native supersets (the native enumeration may carry standard entries the + // provider binary omits). Every offline entry must reproduce its native counterpart exactly; callers assert + // non-emptiness for providers known to define entries so a decode-to-empty regression fails rather than passing. + foreach ((TKey key, string offlineValue) in offline) + { + Assert.True(native.TryGetValue(key, out string? nativeValue), $"Native result is missing offline key '{key}'."); + Assert.Equal(nativeValue, offlineValue); + } + } + + private static Dictionary<(long Id, byte Version), EventModel> BuildEventLookup(ProviderDetails details) + { + Dictionary<(long Id, byte Version), EventModel> lookup = []; + + foreach (EventModel model in details.Events) + { + lookup.TryAdd((model.Id, model.Version), model); + } + + return lookup; + } + + private static string? CanonicalizeTemplate(string? template) + { + if (string.IsNullOrEmpty(template)) { return null; } + + XDocument document; + + try + { + document = XDocument.Parse(template); + } + catch (XmlException) + { + return null; + } + + if (document.Root is null) { return null; } + + StringBuilder builder = new(); + AppendCanonicalElements(builder, document.Root.Elements()); + + return builder.ToString(); + } + + private static List? ParseDataNodes(string template) + { + XDocument document; + + try + { + document = XDocument.Parse(template); + } + catch (XmlException) + { + return null; + } + + if (document.Root is null) { return null; } + + return + [ + .. document.Root.Elements() + .Where(static element => element.Name.LocalName == "data") + .Select(static element => new TemplateDataNode( + element.Attribute("name")?.Value ?? string.Empty, + element.Attribute("inType")?.Value ?? string.Empty, + element.Attribute("outType")?.Value ?? string.Empty, + element.Attribute("length")?.Value ?? string.Empty, + element.Attribute("count")?.Value ?? string.Empty)) + ]; + } + + private static string SkipReasonFor(ProviderParityFixture fixture) => + $"Test requires the {fixture.ProviderName} provider and its WEVT_TEMPLATE resource on the host."; + + private static bool TemplateHasStruct(string template) + { + XDocument document; + + try + { + document = XDocument.Parse(template); + } + catch (XmlException) + { + return false; + } + + return document.Descendants().Any(static element => element.Name.LocalName == "struct"); + } + + private static bool TryLoadNativeAndOffline(string providerName, out ProviderDetails? native, out ProviderDetails? offline) + { + native = null; + offline = null; + + ProviderMetadata? metadata = ProviderMetadata.Create(providerName); + + if (metadata is null || string.IsNullOrEmpty(metadata.ResourceFilePath)) + { + return false; + } + + native = new EventMessageProvider(providerName).LoadProviderDetails(); + offline = OfflineWevtProviderReader.TryBuildProviderDetails( + metadata.ResourceFilePath, + [metadata.MessageFilePath], + metadata.ParameterFilePath, + metadata.PublisherGuid, + providerName, + new RegistryProvider(logger: null), + logger: null); + + return offline is not null; + } + + public sealed class KernelPowerParityFixture() : ProviderParityFixture(Constants.KernelPowerLogName); + + public sealed class PerfOsParityFixture() : ProviderParityFixture(Constants.PerfOsLogName); + + public sealed class PowerShellParityFixture() : ProviderParityFixture(Constants.PowerShellLogName); + + public abstract class ProviderParityFixture + { + protected ProviderParityFixture(string providerName) + { + ProviderName = providerName; + + ProviderMetadata? metadata = ProviderMetadata.Create(providerName); + + if (metadata is null) { return; } + + Native = new EventMessageProvider(providerName, logger: null).LoadProviderDetails(); + + string resourceFilePath = metadata.ResourceFilePath; + + if (string.IsNullOrEmpty(resourceFilePath)) { return; } + + Offline = OfflineWevtProviderReader.TryBuildProviderDetails( + resourceFilePath, + [metadata.MessageFilePath], + metadata.ParameterFilePath, + metadata.PublisherGuid, + providerName, + new RegistryProvider(logger: null), + logger: null); + + CaptureRawTableCounts(resourceFilePath, metadata.PublisherGuid); + } + + public bool Available => Native is not null && Offline is not null; + + public ProviderDetails? Native { get; } + + public ProviderDetails? Offline { get; } + + public string ProviderName { get; } + + public int RawKeywordKeyCount { get; private set; } + + public int RawMapCount { get; private set; } + + public int RawOpcodeKeyCount { get; private set; } + + public int RawTaskKeyCount { get; private set; } + + private void CaptureRawTableCounts(string resourceFilePath, Guid publisherGuid) + { + // Re-parse the resource to record the post-dedup table sizes the resolved provider details must reproduce, so + // a partial decode regression (some entries dropped between parse and assembly) is detectable by count. + byte[]? rented = WevtTemplateReader.TryRentWevtResource(resourceFilePath, logger: null, out int size); + + if (rented is null) { return; } + + try + { + WevtProviderData? raw = WevtTemplateReader.TryParseProvider( + rented.AsSpan(0, size), publisherGuid, logger: null); + + if (raw is null) { return; } + + RawKeywordKeyCount = raw.Keywords.Select(static keyword => keyword.Mask).Distinct().Count(); + RawMapCount = raw.Templates.Maps.Count; + RawOpcodeKeyCount = raw.Opcodes.Select(static opcode => opcode.Id).Distinct().Count(); + RawTaskKeyCount = raw.Tasks.Select(static task => task.Id).Distinct().Count(); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + } + + public sealed class SecurityAuditingParityFixture() : ProviderParityFixture(Constants.SecurityAuditingLogName); + + private readonly record struct MessageKey(short ShortId, long RawId, string? LogLink, string? Tag, string? Template, string Text); + + private readonly record struct TemplateDataNode(string Name, string InType, string OutType, string Length, string Count); +} diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Provider.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Provider.cs index 1cd11ec33..e9a29524d 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Provider.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/Constants/Constants.Provider.cs @@ -7,11 +7,21 @@ public sealed partial class Constants { public const string ApplicationLogName = "Application"; + public const string BitsClientLogName = "Microsoft-Windows-Bits-Client"; + + public const string Direct3D11LogName = "Microsoft-Windows-Direct3D11"; + + public const string DotNetRuntimeLogName = "Microsoft-Windows-DotNETRuntime"; + + public const string DwmCoreLogName = "Microsoft-Windows-Dwm-Core"; + public const string ExchangeFormattedDescription = "Database redundancy health check passed.\r\nDatabase copy: SERVER1\r\nRedundancy count: 4\r\nIsSuppressed: False\r\n\r\nErrors:\r\nLots of copy status text"; public const string KernelGeneralLogName = "Microsoft-Windows-Kernel-General"; + public const string KernelPowerLogName = "Microsoft-Windows-Kernel-Power"; + public const string LocalComputer = "LocalComputer"; public const string Localhost = "localhost"; @@ -20,6 +30,7 @@ public sealed partial class Constants public const string NonExistentDll = "NonExistent.dll"; public const string NonExistentDllFullPath = @"C:\Windows\System32\NonExistent.dll"; public const string NonExistentProviderName = "NonExistentProvider"; + public const string PerfOsLogName = "Microsoft-Windows-PerfOS"; public const string PowerShellLogName = "Microsoft-Windows-PowerShell"; public const string RemoteComputer = "RemoteComputer"; public const string SecurityAuditingLogName = "Microsoft-Windows-Security-Auditing"; diff --git a/tests/Unit/EventLogExpert.DatabaseTools.Tests/CreateDatabase/CreateDatabaseSelectModeTests.cs b/tests/Unit/EventLogExpert.DatabaseTools.Tests/CreateDatabase/CreateDatabaseSelectModeTests.cs new file mode 100644 index 000000000..5ee90d425 --- /dev/null +++ b/tests/Unit/EventLogExpert.DatabaseTools.Tests/CreateDatabase/CreateDatabaseSelectModeTests.cs @@ -0,0 +1,60 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.DatabaseTools.CreateDatabase; + +namespace EventLogExpert.DatabaseTools.Tests.CreateDatabase; + +public sealed class CreateDatabaseSelectModeTests +{ + [Fact] + public void SelectMode_WhenBothOfflineImageAndSource_OfflineImageWins() + { + // Offline detection wins so the operation's validation can then reject the source+offline combination with a + // clear message, rather than silently routing to the file source and ignoring the offline image. + var request = new CreateDatabaseRequest( + @"C:\out.db", SourcePath: @"C:\src.db", FilterRegex: null, SkipProvidersInFile: null, OfflineImagePath: @"X:\"); + + Assert.Equal(CreateDatabaseOperation.CreateDatabaseMode.OfflineImage, CreateDatabaseOperation.SelectMode(request)); + } + + [Fact] + public void SelectMode_WhenNeitherSourceNorOfflineImage_IsLocal() + { + var request = new CreateDatabaseRequest(@"C:\out.db", SourcePath: null, FilterRegex: null, SkipProvidersInFile: null); + + Assert.Equal(CreateDatabaseOperation.CreateDatabaseMode.Local, CreateDatabaseOperation.SelectMode(request)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SelectMode_WhenOfflineImagePathIsBlank_IsNotOfflineImage(string blank) + { + // A blank offline path is treated as unset (matching the operation's validation), so the request falls through + // to local rather than becoming a phantom offline build that then fails a directory check. + var request = new CreateDatabaseRequest( + @"C:\out.db", SourcePath: null, FilterRegex: null, SkipProvidersInFile: null, OfflineImagePath: blank); + + Assert.Equal(CreateDatabaseOperation.CreateDatabaseMode.Local, CreateDatabaseOperation.SelectMode(request)); + } + + [Fact] + public void SelectMode_WhenOfflineImagePathSet_IsOfflineImage() + { + var request = new CreateDatabaseRequest( + @"C:\out.db", SourcePath: null, FilterRegex: null, SkipProvidersInFile: null, OfflineImagePath: @"X:\"); + + // OfflineImage mode is what suppresses the host-provenance read in the operation; locking it here keeps that + // carry-forward regression-proof without needing a real mounted image. + Assert.Equal(CreateDatabaseOperation.CreateDatabaseMode.OfflineImage, CreateDatabaseOperation.SelectMode(request)); + } + + [Fact] + public void SelectMode_WhenSourceOnly_IsFileSource() + { + var request = new CreateDatabaseRequest(@"C:\out.db", SourcePath: @"C:\src.db", FilterRegex: null, SkipProvidersInFile: null); + + Assert.Equal(CreateDatabaseOperation.CreateDatabaseMode.FileSource, CreateDatabaseOperation.SelectMode(request)); + } +} diff --git a/tests/Unit/EventLogExpert.DatabaseTools.Tests/CreateDatabase/CreateDatabaseWimValidationTests.cs b/tests/Unit/EventLogExpert.DatabaseTools.Tests/CreateDatabase/CreateDatabaseWimValidationTests.cs new file mode 100644 index 000000000..d3df43819 --- /dev/null +++ b/tests/Unit/EventLogExpert.DatabaseTools.Tests/CreateDatabase/CreateDatabaseWimValidationTests.cs @@ -0,0 +1,266 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.DatabaseTools.CreateDatabase; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Logging.Abstractions.Handlers; +using Microsoft.Extensions.Logging; + +namespace EventLogExpert.DatabaseTools.Tests.CreateDatabase; + +/// +/// Locks the kind-aware request validation for offline images. These combinations are pure (no wimgapi): +/// they guard against a WIM option being silently ignored (which would build from the wrong source) and against +/// opening a directory as a WIM or vice versa. The actual WIM extraction outcomes are covered by the seam-driven +/// OfflineWimImageTests in the Eventing test project and the manual real-WIM E2E. +/// +public sealed class CreateDatabaseWimValidationTests +{ + [Fact] + public void Validate_AutoDetectsDirectory_WhenNoKindGiven_Accepts() + { + using var workspace = new TempFiles(); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: workspace.Directory, kind: null); + + Assert.True(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + } + + [Fact] + public void Validate_AutoDetectsWim_FromExtension_WithIndex_Accepts() + { + using var workspace = new TempFiles(); + string wim = workspace.CreateFile("image.wim"); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: wim, kind: null, wimIndex: 1); + + Assert.True(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + } + + [Fact] + public void Validate_AutoDetectsWim_FromExtension_WithoutIndex_Rejects() + { + using var workspace = new TempFiles(); + string wim = workspace.CreateFile("image.esd"); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: wim, kind: null); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains("--wim-index", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_AutoDetectUnknownExtension_Rejects() + { + using var workspace = new TempFiles(); + string unknown = workspace.CreateFile("image.dat"); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: unknown, kind: null); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains("determine", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_WhenDirectoryExists_Accepts() + { + using var workspace = new TempFiles(); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: workspace.Directory, kind: OfflineImageKind.Directory); + + Assert.True(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + } + + [Fact] + public void Validate_WhenDirectoryKindGivenAFile_RejectsWithActionableMessage() + { + using var workspace = new TempFiles(); + string wim = workspace.CreateFile("image.wim"); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: wim, kind: OfflineImageKind.Directory); + + // A .wim passed without --image-kind wim must not say "directory not found" (the file exists); the error should + // identify it as a file and point at --image-kind wim. + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => + error.Contains("file", StringComparison.OrdinalIgnoreCase) && error.Contains("--image-kind wim", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_WhenDirectoryMissing_Rejects() + { + var logger = new CapturingTraceLogger(); + var request = Request( + offlineImagePath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), kind: OfflineImageKind.Directory); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + } + + [Fact] + public void Validate_WhenDirectoryWithWimIndex_Rejects() + { + using var workspace = new TempFiles(); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: workspace.Directory, kind: OfflineImageKind.Directory, wimIndex: 2); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains("--wim-index", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_WhenImageAndSource_Rejects() + { + using var workspace = new TempFiles(); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: workspace.Directory, source: @"C:\src.db"); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + } + + [Fact] + public void Validate_WhenIsoKind_Rejects() + { + using var workspace = new TempFiles(); + string iso = workspace.CreateFile("image.iso"); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: iso, kind: OfflineImageKind.Iso); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains("not yet supported", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_WhenNoImageAndNoWimOptions_Accepts() + { + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: null, kind: null); + + Assert.True(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + } + + [Fact] + public void Validate_WhenNoImageButImageKindWim_Rejects() + { + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: null, kind: OfflineImageKind.Wim); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains("--image-kind", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_WhenNoImageButWimIndexSet_Rejects() + { + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: null, kind: null, wimIndex: 1); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains("--wim-index", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_WhenWimFileMissing_Rejects() + { + var logger = new CapturingTraceLogger(); + var request = Request( + offlineImagePath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wim"), + kind: OfflineImageKind.Wim, + wimIndex: 1); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains("not found", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_WhenWimHasWrongExtension_Rejects() + { + using var workspace = new TempFiles(); + string notWim = workspace.CreateFile("image.dat"); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: notWim, kind: OfflineImageKind.Wim, wimIndex: 1); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains(".wim", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_WhenWimWithIndexAndExtension_Accepts() + { + using var workspace = new TempFiles(); + string wim = workspace.CreateFile("image.esd"); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: wim, kind: OfflineImageKind.Wim, wimIndex: 1); + + // Validation passes on the request shape; the index range, elevation, and extraction are checked by the apply. + Assert.True(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + } + + [Fact] + public void Validate_WhenWimWithoutIndex_Rejects() + { + using var workspace = new TempFiles(); + string wim = workspace.CreateFile("image.wim"); + var logger = new CapturingTraceLogger(); + var request = Request(offlineImagePath: wim, kind: OfflineImageKind.Wim); + + Assert.False(CreateDatabaseOperation.ValidateOfflineImageRequest(request, logger)); + Assert.Contains(logger.Errors, error => error.Contains("--wim-index", StringComparison.Ordinal)); + } + + private static CreateDatabaseRequest Request( + string? offlineImagePath, + string? source = null, + OfflineImageKind? kind = null, + int? wimIndex = null) => + new(@"C:\out.db", source, FilterRegex: null, SkipProvidersInFile: null, offlineImagePath, kind, wimIndex); + + private sealed class CapturingTraceLogger : ITraceLogger + { + public List Errors { get; } = []; + + public LogLevel MinimumLevel => LogLevel.Trace; + + public void Critical(CriticalLogHandler handler) => handler.ToStringAndClear(); + + public void Debug(DebugLogHandler handler) => handler.ToStringAndClear(); + + public void Error(ErrorLogHandler handler) => Errors.Add(handler.ToStringAndClear()); + + public void Information(InformationLogHandler handler) => handler.ToStringAndClear(); + + public void Trace(TraceLogHandler handler) => handler.ToStringAndClear(); + + public void Warning(WarningLogHandler handler) => handler.ToStringAndClear(); + } + + private sealed class TempFiles : IDisposable + { + public TempFiles() + { + Directory = Path.Combine(Path.GetTempPath(), "elx_wimvalidate_" + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(Directory); + } + + public string Directory { get; } + + public string CreateFile(string name) + { + string path = Path.Combine(Directory, name); + File.WriteAllText(path, "placeholder"); + + return path; + } + + public void Dispose() + { + try + { + if (System.IO.Directory.Exists(Directory)) { System.IO.Directory.Delete(Directory, recursive: true); } + } + catch (IOException) + { + // Best-effort cleanup. + } + } + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs deleted file mode 100644 index 05e651d22..000000000 --- a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/EventMessageProviderTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.PublisherMetadata; - -namespace EventLogExpert.Eventing.Tests.PublisherMetadata; - -public sealed class EventMessageProviderTests -{ - [Fact] - public void InjectMapAttribute_DataSourcePrefix_InjectsIntoTheRealDataElement() - { - string template = ""; - - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); - - Assert.Equal( - "", - result); - } - - [Fact] - public void InjectMapAttribute_DataSourceWithSameName_IsNotMatched() - { - string template = ""; - - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); - - Assert.Equal(template, result); - Assert.DoesNotContain("map=", result); - } - - [Fact] - public void InjectMapAttribute_FieldNotPresent_ReturnsTemplateUnchanged() - { - string template = ""; - - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); - - Assert.Equal(template, result); - } - - [Fact] - public void InjectMapAttribute_InsertsMapAfterMatchingDataField() - { - string template = ""; - - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); - - Assert.Equal( - "", - result); - } - - [Fact] - public void InjectMapAttribute_PrefixFieldName_DoesNotMisfire() - { - string template = ""; - - string result = EventMessageProvider.InjectMapAttribute(template, "Bus", "BusMap"); - - Assert.Equal(template, result); - } - - [Fact] - public void InjectMapAttribute_SecondDataField_InjectsIntoTheNamedElement() - { - string template = ""; - - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); - - Assert.Equal( - "", - result); - } - - [Fact] - public void InjectMapAttribute_StructWithSameName_IsNotMatched() - { - string template = ""; - - string result = EventMessageProvider.InjectMapAttribute(template, "BusType", "BusTypeMap"); - - Assert.Equal(template, result); - } -} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/BackupRestorePrivilegeScopeTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/BackupRestorePrivilegeScopeTests.cs new file mode 100644 index 000000000..4923292df --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/BackupRestorePrivilegeScopeTests.cs @@ -0,0 +1,36 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; + +/// +/// Guards the TOKEN_PRIVILEGES P/Invoke marshalling that the recovery path depends on. A wrong struct pack +/// and a privilege the token does not hold BOTH make AdjustTokenPrivileges report ERROR_NOT_ALL_ASSIGNED +/// , so only asserting SUCCESS on a privilege EVERY token holds proves the marshalling is correct - no admin required. +/// +public sealed class BackupRestorePrivilegeScopeTests +{ + [Fact] + public void CanEnablePrivilege_ForAnUnknownPrivilegeName_ReturnsFalse() + { + Assert.False(BackupRestorePrivilegeScope.CanEnablePrivilegeForTest("SeThisIsNotARealPrivilege")); + } + + [Fact] + public void CanEnablePrivilege_ForAPrivilegeEveryTokenHolds_Succeeds() + { + // SeChangeNotifyPrivilege (bypass traverse checking) is present and enabled in every process token. A SUCCESS + // here proves the LUID landed at the right struct offset; a Pack regression would report NOT_ALL_ASSIGNED. + Assert.True(BackupRestorePrivilegeScope.CanEnablePrivilegeForTest("SeChangeNotifyPrivilege")); + } + + [Fact] + public void CanEnablePrivilege_ForAPrivilegeTheTokenDoesNotHold_ReturnsFalse() + { + // SeCreateTokenPrivilege is held by virtually no token (not even elevated admins), so enabling it must fail the + // success predicate - confirming the predicate rejects NOT_ALL_ASSIGNED rather than trusting the BOOL return. + Assert.False(BackupRestorePrivilegeScope.CanEnablePrivilegeForTest("SeCreateTokenPrivilege")); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImagePathMapperTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImagePathMapperTests.cs new file mode 100644 index 000000000..04a0104d1 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImagePathMapperTests.cs @@ -0,0 +1,189 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline.Containment; + +public sealed class OfflineImagePathMapperTests +{ + [Theory] + [InlineData(@"C:\Windows\System32\foo.dll")] + [InlineData(@"%SystemRoot%\System32\foo.dll")] + [InlineData(@"%SystemDrive%\App\bar.dll")] + [InlineData("baz.dll")] + [InlineData(@"\Windows\notepad.exe")] + public void ReRoot_AnyAcceptedValue_NeverYieldsABareLeaf(string registryPath) + { + // The load-bearing invariant behind deferring the MessageTableReader offline guard: a mapped path always + // carries directory information, so the loader's host env re-expansion and host-System32 leaf fallback are + // unreachable. (No mapper output may equal its own file name.) + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map(registryPath); + + Assert.NotNull(result); + Assert.NotEqual(Path.GetFileName(result), result); + Assert.StartsWith(image.RootDirectory, result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ReRoot_BareLeaf_RedirectsUnderImageSystem32() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map("APHostRes.dll"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "APHostRes.dll"), result, ignoreCase: true); + } + + [Fact] + public void ReRoot_DotDotTraversalThatEscapesImageRoot_IsDropped() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + // Enough parent climbs to leave the image root regardless of how deep the scaffold is; without the escape guard + // this would normalize onto the host volume and the guard would throw, aborting the whole read. + string escaping = @"C:\Windows\" + string.Concat(Enumerable.Repeat(@"..\", 40)) + "escape.dll"; + + Assert.Null(mapper.Map(escaping)); + } + + [Fact] + public void ReRoot_DotDotTraversalThatStaysWithinImageRoot_IsKept() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map(@"C:\Windows\System32\..\drivers\foo.dll"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "drivers", "foo.dll"), result, ignoreCase: true); + } + + [Fact] + public void ReRoot_DriveAbsolutePath_RootsUnderImageNotHost() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map(@"C:\Windows\System32\foo.dll"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "foo.dll"), result, ignoreCase: true); + } + + [Fact] + public void ReRoot_DriveAbsoluteWithNonCDriveAndUpperCase_StillRootsUnderImage() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + // The image's own system drive may not be C: and the casing varies; the drive is replaced regardless. + string? result = mapper.Map(@"D:\WINDOWS\System32\bar.dll"); + + Assert.Equal(Path.Combine(image.RootDirectory, "WINDOWS", "System32", "bar.dll"), result, ignoreCase: true); + } + + [Fact] + public void ReRoot_PathWithEmbeddedNullCharacter_IsDroppedWithoutThrowing() + { + // A hostile or corrupt hive can yield a registry value with an embedded NUL; Path.Combine tolerates it but + // Path.GetFullPath throws ArgumentException. The mapper must drop it fail-closed, never throw out of the public + // offline enumeration. The Assert.Null both asserts the contract and proves no exception escaped. + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + Assert.Null(mapper.Map("C:\\Windows\\System32\\foo\0bar.dll")); + } + + [Theory] + [InlineData(@"%ProgramFiles%\Windows Defender\MpEvMsg.dll", @"Program Files\Windows Defender\MpEvMsg.dll")] + [InlineData(@"%programfiles%\Windows Defender\MpClient.dll", @"Program Files\Windows Defender\MpClient.dll")] + [InlineData(@"%ProgramFiles(x86)%\Vendor\app.dll", @"Program Files (x86)\Vendor\app.dll")] + [InlineData(@"%CommonProgramFiles%\Microsoft Shared\Ink\mip.exe", @"Program Files\Common Files\Microsoft Shared\Ink\mip.exe")] + [InlineData(@"%CommonProgramFiles(x86)%\Microsoft Shared\Ink\mraut.dll", @"Program Files (x86)\Common Files\Microsoft Shared\Ink\mraut.dll")] + [InlineData(@"%ProgramData%\Microsoft\Windows Defender\Default\MpEngine.dll", @"ProgramData\Microsoft\Windows Defender\Default\MpEngine.dll")] + public void ReRoot_ProgramDirectoryTokens_MapToImageRelativeLocation(string registryPath, string expectedRelative) + { + // Machine-scoped program-directory tokens are re-rooted onto the image (defaulting to the standard folder + // names) so providers whose message files live under Program Files / ProgramData (e.g. Windows Defender) + // resolve offline, matching the live path that expands them against the host. + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map(registryPath); + + Assert.Equal(Path.Combine(image.RootDirectory, expectedRelative), result, ignoreCase: true); + } + + [Fact] + public void ReRoot_QuotedAndPaddedValue_IsTrimmedThenReRooted() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map(" \"C:\\Windows\\System32\\foo.dll\" "); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "foo.dll"), result, ignoreCase: true); + } + + [Fact] + public void ReRoot_RootedNoDrivePath_RootsUnderImage() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map(@"\Windows\System32\foo.dll"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "foo.dll"), result, ignoreCase: true); + } + + [Fact] + public void ReRoot_SystemDriveToken_MapsToImageRoot() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map(@"%SystemDrive%\Program Files\App\bar.dll"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Program Files", "App", "bar.dll"), result, ignoreCase: true); + } + + [Theory] + [InlineData(@"%SystemRoot%\System32\foo.dll")] + [InlineData(@"%systemroot%\System32\foo.dll")] + [InlineData(@"%windir%\System32\foo.dll")] + public void ReRoot_SystemRootTokens_MapToImageWindows(string registryPath) + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + string? result = mapper.Map(registryPath); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "foo.dll"), result, ignoreCase: true); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(@"C:foo.dll")] // drive-relative (resolves against host current dir on C:) + [InlineData(@"\\server\share\foo.dll")] // UNC + [InlineData(@"\\?\C:\Windows\System32\foo.dll")] // extended-length + [InlineData(@"\\.\C:\foo.dll")] // DOS device + [InlineData(@"\??\C:\Windows\foo.dll")] // NT object path + [InlineData(@"%APPDATA%\Vendor\foo.dll")] // unsupported per-user environment token + [InlineData(@"%ProgramFiles%evil\foo.dll")] // token not on a path boundary (live -> "Program Filesevil", not "Program Files\evil") + [InlineData(@"%SystemRoot%System32\foo.dll")] // token not on a path boundary + [InlineData(@"%SystemRoot%\%Nested%\foo.dll")] // residual unsupported token after a supported one + [InlineData("foo.dll:stream")] // alternate data stream + public void ReRoot_UnsafeOrUnsupportedForms_AreDroppedFailClosed(string? registryPath) + { + using OfflineTestImage image = OfflineTestImage.Create(); + var mapper = new OfflineImagePathMapper(image.ImageRoot, logger: null); + + Assert.Null(mapper.Map(registryPath)); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImagePathResolverTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImagePathResolverTests.cs new file mode 100644 index 000000000..3526be601 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImagePathResolverTests.cs @@ -0,0 +1,35 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline.Containment; + +public sealed class OfflineImagePathResolverTests +{ + [Fact] + public void Resolve_ReparsePointEscapingTheImage_DropsInsteadOfThrowing() + { + using OfflineTestImage image = OfflineTestImage.Create(); + string outsideTarget = Path.Combine(Path.GetTempPath(), "elx_outside_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideTarget); + string junctionPath = Path.Combine(image.ImageRoot.System32Directory, "linkdir"); + + Assert.SkipUnless(OfflineTestImage.TryCreateJunction(junctionPath, outsideTarget), "Could not create an NTFS junction for the reparse-point test."); + + try + { + var resolver = new OfflineImagePathResolver( + new OfflineImagePathMapper(image.ImageRoot, logger: null), + new OfflineRootGuard(image.ImageRoot, logger: null)); + + // Lexically under the image, but the junction redirects outside it: the guard throws, yet the resolver must + // drop the value (return null) so a hostile image cannot abort the whole offline enumeration. + Assert.Null(resolver.Resolve(@"C:\Windows\System32\linkdir\evil.dll", "resource")); + } + finally + { + if (Directory.Exists(outsideTarget)) { Directory.Delete(outsideTarget, recursive: true); } + } + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImageRootTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImageRootTests.cs new file mode 100644 index 000000000..b9d5f4d7a --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineImageRootTests.cs @@ -0,0 +1,169 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline.Containment; + +public sealed class OfflineImageRootTests +{ + [Fact] + public void ContainsPath_DifferentCasing_IsTrue() + { + using OfflineTestImage image = OfflineTestImage.Create(); + + string upperCased = Path.Combine(image.RootDirectory.ToUpperInvariant(), "Windows", "System32", "foo.dll"); + + Assert.True(image.ImageRoot.ContainsPath(upperCased)); + } + + [Fact] + public void ContainsPath_HostPathOutsideImageOnSameDrive_IsFalse() + { + // The scaffold lives on the host drive, so the host's own System32 is NOT under the image root - a + // Path.GetPathRoot-based check (treating C:\ as the root) would wrongly accept it. + using OfflineTestImage image = OfflineTestImage.Create(); + + string hostPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "ntdll.dll"); + + Assert.False(image.ImageRoot.ContainsPath(hostPath)); + } + + [Fact] + public void ContainsPath_PathUnderImageRoot_IsTrue() + { + using OfflineTestImage image = OfflineTestImage.Create(); + + string inside = Path.Combine(image.ImageRoot.System32Directory, "foo.dll"); + + Assert.True(image.ImageRoot.ContainsPath(inside)); + } + + [Fact] + public void ContainsPath_SiblingDirectoryWithSharedPrefix_IsFalse() + { + using OfflineTestImage image = OfflineTestImage.Create(); + + // Same string prefix as the image root but a different directory (…elx_img_ABC vs …elx_img_ABCevil). + string sibling = image.RootDirectory + "evil" + Path.DirectorySeparatorChar + "foo.dll"; + + Assert.False(image.ImageRoot.ContainsPath(sibling)); + } + + [Fact] + public void TryCreate_GivenImageRootDirectory_ResolvesLayout() + { + using OfflineTestImage image = OfflineTestImage.Create(); + + OfflineImageRoot? root = OfflineImageRoot.TryCreate(image.RootDirectory, logger: null); + + Assert.NotNull(root); + Assert.Equal(image.RootDirectory, root!.ImageRoot); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows"), root.WindowsDirectory); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32"), root.System32Directory); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "config", "SOFTWARE"), root.SoftwareHivePath); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "config", "SYSTEM"), root.SystemHivePath); + } + + [Fact] + public void TryCreate_GivenWindowsDirectoryItself_ResolvesImageRootAsItsParent() + { + using OfflineTestImage image = OfflineTestImage.Create(); + string windowsDirectory = Path.Combine(image.RootDirectory, "Windows"); + + OfflineImageRoot? root = OfflineImageRoot.TryCreate(windowsDirectory, logger: null); + + Assert.NotNull(root); + Assert.Equal(image.RootDirectory, root!.ImageRoot); + Assert.Equal(windowsDirectory, root.WindowsDirectory); + } + + [Fact] + public void TryCreate_ImageRootReachedViaJunction_FilesUnderItPassTheGuard() + { + using OfflineTestImage image = OfflineTestImage.Create(); + string rootLink = Path.Combine(Path.GetTempPath(), "elx_rootlink_" + Guid.NewGuid().ToString("N")); + + Assert.SkipUnless(OfflineTestImage.TryCreateJunction(rootLink, image.RootDirectory), "Could not create an NTFS junction for the reparse-point test."); + + try + { + // The image root is reached through a junction; TryCreate canonicalizes the boundary so a file under it is not + // falsely rejected by the (reparse-resolving) guard - without that, every resolved file path would mismatch. + OfflineImageRoot? viaJunction = OfflineImageRoot.TryCreate(rootLink, logger: null); + + Assert.NotNull(viaJunction); + + var guard = new OfflineRootGuard(viaJunction!, logger: null); + + guard.Assert(Path.Combine(viaJunction!.System32Directory, "foo.dll"), "resource"); + } + finally + { + if (Directory.Exists(rootLink)) { Directory.Delete(rootLink); } + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryCreate_NullOrEmpty_ReturnsNull(string? input) + { + Assert.Null(OfflineImageRoot.TryCreate(input!, logger: null)); + } + + [Fact] + public void TryCreate_WhenPathHasInvalidCharacters_ReturnsNullWithoutThrowing() + { + // A NUL character makes Path.GetFullPath throw ArgumentException; the offline source promises a fail-closed + // skip (logged null) for a hostile or malformed image path, never an exception bubbling out of the public + // enumeration. The Assert.Null both asserts the contract and proves no exception was thrown. + Assert.Null(OfflineImageRoot.TryCreate("foo\0bar", logger: null)); + } + + [Fact] + public void TryCreate_WhenSoftwareHiveMissing_ReturnsNull() + { + string root = CreateConfigScaffold(out string configDirectory); + + try + { + File.WriteAllBytes(Path.Combine(configDirectory, "SYSTEM"), []); + // SOFTWARE intentionally absent. + + Assert.Null(OfflineImageRoot.TryCreate(root, logger: null)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void TryCreate_WhenSystemHiveMissing_ReturnsNull() + { + string root = CreateConfigScaffold(out string configDirectory); + + try + { + File.WriteAllBytes(Path.Combine(configDirectory, "SOFTWARE"), []); + // SYSTEM intentionally absent. + + Assert.Null(OfflineImageRoot.TryCreate(root, logger: null)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static string CreateConfigScaffold(out string configDirectory) + { + string root = Path.Combine(Path.GetTempPath(), "elx_imgroot_" + Guid.NewGuid().ToString("N")); + configDirectory = Path.Combine(root, "Windows", "System32", "config"); + Directory.CreateDirectory(configDirectory); + + return root; + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineRootGuardTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineRootGuardTests.cs new file mode 100644 index 000000000..a4ef1ab54 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/Containment/OfflineRootGuardTests.cs @@ -0,0 +1,59 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline.Containment; + +public sealed class OfflineRootGuardTests +{ + [Fact] + public void Assert_HostPathOutsideImage_ThrowsEvenOnSameDrive() + { + // The scaffold lives on the host drive, so the host's own C:\Windows is NOT under the image root - the guard must + // reject it, which a Path.GetPathRoot-based guard (treating C:\ as the root) would wrongly allow. + using OfflineTestImage image = OfflineTestImage.Create(); + var guard = new OfflineRootGuard(image.ImageRoot, logger: null); + + string hostPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "ntdll.dll"); + + Assert.Throws(() => guard.Assert(hostPath, "resource")); + } + + [Fact] + public void Assert_PathTraversingAJunctionThatEscapesTheImage_Throws() + { + using OfflineTestImage image = OfflineTestImage.Create(); + string outsideTarget = Path.Combine(Path.GetTempPath(), "elx_outside_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideTarget); + + try + { + string junctionPath = Path.Combine(image.ImageRoot.System32Directory, "linkdir"); + + Assert.SkipUnless(OfflineTestImage.TryCreateJunction(junctionPath, outsideTarget), "Could not create an NTFS junction for the reparse-point test."); + + var guard = new OfflineRootGuard(image.ImageRoot, logger: null); + string throughJunction = Path.Combine(junctionPath, "evil.dll"); + + // Lexically the path is under the image root, but the junction redirects outside it - the guard must resolve + // the reparse point and fail closed. + Assert.Throws(() => guard.Assert(throughJunction, "resource")); + } + finally + { + if (Directory.Exists(outsideTarget)) { Directory.Delete(outsideTarget, recursive: true); } + } + } + + [Fact] + public void Assert_PathUnderImageRoot_DoesNotThrow() + { + using OfflineTestImage image = OfflineTestImage.Create(); + var guard = new OfflineRootGuard(image.ImageRoot, logger: null); + + string inside = Path.Combine(image.ImageRoot.System32Directory, "foo.dll"); + + guard.Assert(inside, "resource"); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineImageProviderExtractorTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineImageProviderExtractorTests.cs new file mode 100644 index 000000000..7a8659fa4 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineImageProviderExtractorTests.cs @@ -0,0 +1,116 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata; +using EventLogExpert.Eventing.PublisherMetadata.Offline; +using Microsoft.Win32; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; + +public sealed class OfflineImageProviderExtractorTests +{ + private const string PublishersKeyPath = @"Microsoft\Windows\CurrentVersion\WINEVT\Publishers"; + private const string TestGuid = "{33333333-3333-3333-3333-333333333333}"; + + [Fact] + public void ReadImageProvenance_DoesNotExpandRegExpandSzValuesAgainstTheHost() + { + using OfflineTestImage image = OfflineTestImage.Create( + software => + { + using RegistryKey currentVersion = software.CreateSubKey(@"Microsoft\Windows NT\CurrentVersion"); + currentVersion.SetValue("CurrentBuildNumber", "20348", RegistryValueKind.String); + + // A foreign or malformed hive could store these as REG_EXPAND_SZ; provenance must record the literal + // stored value, never expand %SystemRoot% against the HOST environment. + currentVersion.SetValue("EditionID", @"%SystemRoot%Edition", RegistryValueKind.ExpandString); + currentVersion.SetValue("DisplayVersion", @"%SystemRoot%", RegistryValueKind.ExpandString); + }, + SeedSystem); + using OfflineImageProviderExtractor extractor = OfflineImageProviderExtractor.TryCreate(image.ImageRoot, logger: null)!; + + SourceOsProvenance provenance = extractor.ReadImageProvenance(); + + Assert.Equal(20348, provenance.Build); + Assert.Equal(@"%SystemRoot%Edition", provenance.Edition); + Assert.Equal(@"%SystemRoot%", provenance.DisplayVersion); + } + + [Fact] + public void ReadImageProvenance_ReadsCurrentVersionFromTheImageSoftwareHive() + { + using OfflineTestImage image = OfflineTestImage.Create(SeedSoftware, SeedSystem); + using OfflineImageProviderExtractor extractor = OfflineImageProviderExtractor.TryCreate(image.ImageRoot, logger: null)!; + + SourceOsProvenance provenance = extractor.ReadImageProvenance(); + + // Provenance comes from the IMAGE's SOFTWARE hive, never the host registry. + Assert.Equal(20348, provenance.Build); + Assert.Equal(2700, provenance.Revision); + Assert.Equal("ServerDatacenter", provenance.Edition); + Assert.Equal("21H2", provenance.DisplayVersion); + } + + [Fact] + public void ReadImageProvenance_WhenCurrentVersionIsAbsent_ReturnsEmpty() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSoftware: _ => { }, SeedSystem); + using OfflineImageProviderExtractor extractor = OfflineImageProviderExtractor.TryCreate(image.ImageRoot, logger: null)!; + + Assert.Equal(SourceOsProvenance.Empty, extractor.ReadImageProvenance()); + } + + [Fact] + public void TryBuildModernProvider_WhenResourceIsNotAValidWevtModule_ReturnsNullGracefully() + { + using OfflineTestImage image = OfflineTestImage.Create(SeedSoftware, SeedSystem); + using OfflineImageProviderExtractor extractor = OfflineImageProviderExtractor.TryCreate(image.ImageRoot, logger: null)!; + + OfflinePublisherRegistration registration = Assert.Single(extractor.ReadModernRegistrations()); + + // The re-rooted resource path does not exist in the synthetic image, so the WEVT parse fails closed (no crash). + Assert.Null(extractor.TryBuildModernProvider(registration)); + } + + [Fact] + public void TryCreate_LoadsHivesAndExposesCatalogAndLegacyEnumeration() + { + using OfflineTestImage image = OfflineTestImage.Create(SeedSoftware, SeedSystem); + + using OfflineImageProviderExtractor? extractor = OfflineImageProviderExtractor.TryCreate(image.ImageRoot, logger: null); + + Assert.NotNull(extractor); + + OfflinePublisherRegistration registration = Assert.Single(extractor!.ReadModernRegistrations()); + Assert.Equal("Modern-Test-Provider", registration.ProviderName); + Assert.Equal( + Path.Combine(image.RootDirectory, "Windows", "System32", "modern.dll"), + registration.ResourceFilePath, + ignoreCase: true); + + Assert.Contains("LegacyTestProvider", extractor.EnumerateLegacyProviderNames()); + } + + private static void SeedSoftware(RegistryKey software) + { + using (RegistryKey currentVersion = software.CreateSubKey(@"Microsoft\Windows NT\CurrentVersion")) + { + currentVersion.SetValue("CurrentBuildNumber", "20348", RegistryValueKind.String); + currentVersion.SetValue("UBR", 2700, RegistryValueKind.DWord); + currentVersion.SetValue("EditionID", "ServerDatacenter", RegistryValueKind.String); + currentVersion.SetValue("DisplayVersion", "21H2", RegistryValueKind.String); + } + + using RegistryKey publisher = software.CreateSubKey($@"{PublishersKeyPath}\{TestGuid}"); + publisher.SetValue(null, "Modern-Test-Provider"); + publisher.SetValue("ResourceFileName", @"%SystemRoot%\System32\modern.dll", RegistryValueKind.ExpandString); + } + + private static void SeedSystem(RegistryKey system) + { + using (RegistryKey select = system.CreateSubKey("Select")) { select.SetValue("Current", 1, RegistryValueKind.DWord); } + + using RegistryKey provider = system.CreateSubKey(@"ControlSet001\Services\EventLog\Application\LegacyTestProvider"); + provider.SetValue("EventMessageFile", @"C:\Windows\System32\legacy.dll", RegistryValueKind.ExpandString); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineLegacyMessageFileResolverTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineLegacyMessageFileResolverTests.cs new file mode 100644 index 000000000..c3fbf6f73 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineLegacyMessageFileResolverTests.cs @@ -0,0 +1,164 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline; +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; +using Microsoft.Win32; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; + +public sealed class OfflineLegacyMessageFileResolverTests +{ + [Fact] + public void EnumerateProviderNames_ReturnsProvidersAcrossChannels() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSystem: system => + { + SetCurrentControlSet(system, 1); + using (RegistryKey app = system.CreateSubKey(@"ControlSet001\Services\EventLog\Application\AppProvider")) + { + app.SetValue("EventMessageFile", @"C:\Windows\System32\app.dll", RegistryValueKind.ExpandString); + } + + using RegistryKey security = system.CreateSubKey(@"ControlSet001\Services\EventLog\Security\SecProvider"); + security.SetValue("EventMessageFile", @"C:\Windows\System32\sec.dll", RegistryValueKind.ExpandString); + }); + using OfflineRegistryHive hive = LoadSystemHive(image); + + IReadOnlyList names = ResolverFor(image, hive).EnumerateProviderNames(); + + Assert.Contains("AppProvider", names); + Assert.Contains("SecProvider", names); + } + + [Fact] + public void GetMessageFiles_FiltersNonDllExeExtensions() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSystem: system => + { + SetCurrentControlSet(system, 1); + using RegistryKey provider = system.CreateSubKey(@"ControlSet001\Services\EventLog\Application\DriverProvider"); + provider.SetValue("EventMessageFile", @"C:\Windows\System32\drivers\flt.sys;C:\Windows\System32\evt.dll", RegistryValueKind.ExpandString); + }); + using OfflineRegistryHive hive = LoadSystemHive(image); + + IReadOnlyList files = ResolverFor(image, hive).GetMessageFilesForLegacyProvider("DriverProvider"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "evt.dll"), Assert.Single(files), ignoreCase: true); + } + + [Fact] + public void GetMessageFiles_HonorsSelectCurrentControlSet() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSystem: system => + { + SetCurrentControlSet(system, 2); + using RegistryKey provider = system.CreateSubKey(@"ControlSet002\Services\EventLog\Application\OnSetTwo"); + provider.SetValue("EventMessageFile", @"C:\Windows\System32\two.dll", RegistryValueKind.ExpandString); + }); + using OfflineRegistryHive hive = LoadSystemHive(image); + + IReadOnlyList files = ResolverFor(image, hive).GetMessageFilesForLegacyProvider("OnSetTwo"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "two.dll"), Assert.Single(files), ignoreCase: true); + } + + [Fact] + public void GetMessageFiles_OrdersCategoryFirstAndDiscardsParameterFile() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSystem: SeedApplicationProvider); + using OfflineRegistryHive hive = LoadSystemHive(image); + + IReadOnlyList files = ResolverFor(image, hive).GetMessageFilesForLegacyProvider("TestLegacyProvider"); + + Assert.Equal(2, files.Count); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "cat.dll"), files[0], ignoreCase: true); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "evt.dll"), files[1], ignoreCase: true); + + // The parameter message file is excluded from the message list; it is surfaced separately by + // GetParameterFilesForLegacyProvider so offline databases stay comparable with native-built ones. + Assert.DoesNotContain(files, file => file.EndsWith("param.dll", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void GetMessageFiles_ReadsAdminChannelsThatTheLiveReaderSkips() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSystem: system => + { + SetCurrentControlSet(system, 1); + using RegistryKey provider = system.CreateSubKey(@"ControlSet001\Services\EventLog\Security\SecAuditProvider"); + provider.SetValue("EventMessageFile", @"C:\Windows\System32\sec.dll", RegistryValueKind.ExpandString); + }); + using OfflineRegistryHive hive = LoadSystemHive(image); + + IReadOnlyList files = ResolverFor(image, hive).GetMessageFilesForLegacyProvider("SecAuditProvider"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "sec.dll"), Assert.Single(files), ignoreCase: true); + } + + [Fact] + public void GetMessageFiles_UnknownProvider_ReturnsEmpty() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSystem: SeedApplicationProvider); + using OfflineRegistryHive hive = LoadSystemHive(image); + + Assert.Empty(ResolverFor(image, hive).GetMessageFilesForLegacyProvider("NoSuchProvider")); + } + + [Fact] + public void GetParameterFiles_ProviderWithoutParameterFile_ReturnsEmpty() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSystem: system => + { + SetCurrentControlSet(system, 1); + using RegistryKey provider = system.CreateSubKey(@"ControlSet001\Services\EventLog\Application\NoParamProvider"); + provider.SetValue("EventMessageFile", @"C:\Windows\System32\evt.dll", RegistryValueKind.ExpandString); + }); + using OfflineRegistryHive hive = LoadSystemHive(image); + + Assert.Empty(ResolverFor(image, hive).GetParameterFilesForLegacyProvider("NoParamProvider")); + } + + [Fact] + public void GetParameterFiles_ReturnsReRootedParameterFile() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSystem: SeedApplicationProvider); + using OfflineRegistryHive hive = LoadSystemHive(image); + + IReadOnlyList files = ResolverFor(image, hive).GetParameterFilesForLegacyProvider("TestLegacyProvider"); + + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "param.dll"), Assert.Single(files), ignoreCase: true); + } + + private static OfflineRegistryHive LoadSystemHive(OfflineTestImage image) + { + OfflineRegistryHive? hive = OfflineRegistryHive.TryLoad(image.ImageRoot.SystemHivePath, logger: null).Hive; + Assert.NotNull(hive); + + return hive!; + } + + private static OfflineLegacyMessageFileResolver ResolverFor(OfflineTestImage image, OfflineRegistryHive hive) + { + var pathResolver = new OfflineImagePathResolver( + new OfflineImagePathMapper(image.ImageRoot, logger: null), + new OfflineRootGuard(image.ImageRoot, logger: null)); + + return new OfflineLegacyMessageFileResolver(hive.Root, pathResolver, logger: null); + } + + private static void SeedApplicationProvider(RegistryKey system) + { + SetCurrentControlSet(system, 1); + using RegistryKey provider = system.CreateSubKey(@"ControlSet001\Services\EventLog\Application\TestLegacyProvider"); + provider.SetValue("EventMessageFile", @"%SystemRoot%\System32\evt.dll", RegistryValueKind.ExpandString); + provider.SetValue("CategoryMessageFile", @"C:\Windows\System32\cat.dll", RegistryValueKind.ExpandString); + provider.SetValue("ParameterMessageFile", @"C:\Windows\System32\param.dll", RegistryValueKind.ExpandString); + } + + private static void SetCurrentControlSet(RegistryKey system, int current) + { + using RegistryKey select = system.CreateSubKey("Select"); + select.SetValue("Current", current, RegistryValueKind.DWord); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflinePublisherCatalogTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflinePublisherCatalogTests.cs new file mode 100644 index 000000000..5d0b6e55f --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflinePublisherCatalogTests.cs @@ -0,0 +1,118 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata.Offline; +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; +using Microsoft.Win32; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; + +public sealed class OfflinePublisherCatalogTests +{ + private const string PublishersKeyPath = @"Microsoft\Windows\CurrentVersion\WINEVT\Publishers"; + private const string TestGuid = "{11111111-1111-1111-1111-111111111111}"; + + [Fact] + public void ReadRegistrations_MultiValueMessageFile_IsSplitAndReRooted() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSoftware: software => + { + using RegistryKey publisher = software.CreateSubKey($@"{PublishersKeyPath}\{TestGuid}"); + publisher.SetValue(null, "Test-Provider"); + publisher.SetValue("MessageFileName", @"C:\Windows\System32\a.dll;C:\Windows\System32\b.dll", RegistryValueKind.ExpandString); + }); + + OfflinePublisherRegistration registration = Assert.Single(ReadRegistrations(image)); + + Assert.Equal(2, registration.MessageFilePaths.Count); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "a.dll"), registration.MessageFilePaths[0], ignoreCase: true); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "b.dll"), registration.MessageFilePaths[1], ignoreCase: true); + } + + [Fact] + public void ReadRegistrations_ReadsNameAndReRootsPaths() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSoftware: software => + { + using RegistryKey publisher = software.CreateSubKey($@"{PublishersKeyPath}\{TestGuid}"); + publisher.SetValue(null, "Test-Provider"); + publisher.SetValue("ResourceFileName", @"%SystemRoot%\System32\test.dll", RegistryValueKind.ExpandString); + publisher.SetValue("MessageFileName", @"C:\Windows\System32\msg.dll", RegistryValueKind.ExpandString); + publisher.SetValue("ParameterFileName", @"C:\Windows\System32\param.dll", RegistryValueKind.ExpandString); + }); + + IReadOnlyList registrations = ReadRegistrations(image); + + OfflinePublisherRegistration registration = Assert.Single(registrations); + Assert.Equal(Guid.Parse(TestGuid), registration.PublisherGuid); + Assert.Equal("Test-Provider", registration.ProviderName); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "test.dll"), registration.ResourceFilePath, ignoreCase: true); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "msg.dll"), Assert.Single(registration.MessageFilePaths), ignoreCase: true); + Assert.Equal(Path.Combine(image.RootDirectory, "Windows", "System32", "param.dll"), registration.ParameterFilePath, ignoreCase: true); + } + + [Fact] + public void ReadRegistrations_RegExpandSzValue_IsReadLiterallyNotHostExpanded() + { + // If the catalog let .NET host-expand the REG_EXPAND_SZ value, %APPDATA% would become a real host path (e.g. + // C:\Users\\AppData\Roaming\…) that the mapper re-roots to a non-null image path. Reading it literally + // (DoNotExpandEnvironmentNames) leaves the per-user token intact, and the mapper does not map per-user tokens, + // so it drops the value - which is what proves the host environment is never consulted. + using OfflineTestImage image = OfflineTestImage.Create(seedSoftware: software => + { + using RegistryKey publisher = software.CreateSubKey($@"{PublishersKeyPath}\{TestGuid}"); + publisher.SetValue(null, "Test-Provider"); + publisher.SetValue("ResourceFileName", @"%APPDATA%\Vendor\foo.dll", RegistryValueKind.ExpandString); + }); + + OfflinePublisherRegistration registration = Assert.Single(ReadRegistrations(image)); + + Assert.Null(registration.ResourceFilePath); + } + + [Fact] + public void ReadRegistrations_SkipsMalformedGuidAndNamelessPublishers() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSoftware: software => + { + using (RegistryKey notAGuid = software.CreateSubKey($@"{PublishersKeyPath}\not-a-guid")) + { + notAGuid.SetValue(null, "Ignored"); + } + + using (RegistryKey nameless = software.CreateSubKey($@"{PublishersKeyPath}\{{22222222-2222-2222-2222-222222222222}}")) + { + nameless.SetValue("ResourceFileName", @"C:\Windows\System32\x.dll", RegistryValueKind.ExpandString); + } + + using RegistryKey valid = software.CreateSubKey($@"{PublishersKeyPath}\{TestGuid}"); + valid.SetValue(null, "Test-Provider"); + }); + + OfflinePublisherRegistration registration = Assert.Single(ReadRegistrations(image)); + + Assert.Equal("Test-Provider", registration.ProviderName); + } + + [Fact] + public void ReadRegistrations_WhenPublishersKeyAbsent_ReturnsEmpty() + { + using OfflineTestImage image = OfflineTestImage.Create(seedSoftware: software => + software.CreateSubKey(@"Microsoft\Windows\CurrentVersion").Dispose()); + + Assert.Empty(ReadRegistrations(image)); + } + + private static IReadOnlyList ReadRegistrations(OfflineTestImage image) + { + var pathResolver = new OfflineImagePathResolver( + new OfflineImagePathMapper(image.ImageRoot, logger: null), + new OfflineRootGuard(image.ImageRoot, logger: null)); + var catalog = new OfflinePublisherCatalog(pathResolver, logger: null); + + using OfflineRegistryHive? hive = OfflineRegistryHive.TryLoad(image.ImageRoot.SoftwareHivePath, logger: null).Hive; + Assert.NotNull(hive); + + return catalog.ReadRegistrations(hive!.Root); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineRegistryHiveFallbackTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineRegistryHiveFallbackTests.cs new file mode 100644 index 000000000..795f703e9 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineRegistryHiveFallbackTests.cs @@ -0,0 +1,197 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.PublisherMetadata.Offline; +using EventLogExpert.Logging.Abstractions; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; + +/// +/// Drives the dirty-hive recovery state machine deterministically through a fake +/// . The synthetic clean hives the other tests use are born clean, so +/// RegLoadAppKey always succeeds on them and the recovery fallback is never exercised - the exact blind spot +/// that let the real-image bug ship. These tests seed a real regf hive (so staging + signature checks run for +/// real) but make the fake app-key load FAIL, then assert each recovery branch. +/// +public sealed class OfflineRegistryHiveFallbackTests +{ + [Fact] + public void TryLoad_WhenAppHiveLoadFailsAndPrivilegeUnavailable_ReturnsNeedsElevation() + { + using var hive = TempHive.Seeded(); + var nativeApi = new FakeHiveNativeApi + { + AppHiveLoadResult = Win32ErrorCodes.ERROR_BADDB, + RecoveryPrivilegeAvailable = false + }; + + OfflineHiveLoadResult result = OfflineRegistryHive.TryLoad(hive.Path, logger: null, nativeApi); + + Assert.Equal(OfflineHiveLoadStatus.NeedsElevation, result.Status); + Assert.Equal(0, nativeApi.LoadHiveForRecoveryCalls); + Assert.Equal(0, nativeApi.UnloadHiveCalls); + } + + [Fact] + public void TryLoad_WhenAppHiveLoadFailsAndRecoveryLoadFails_ReturnsRecoveryFailedWithoutLeakingAMount() + { + using var hive = TempHive.Seeded(); + var nativeApi = new FakeHiveNativeApi + { + AppHiveLoadResult = Win32ErrorCodes.ERROR_BADDB, + RecoveryPrivilegeAvailable = true, + RecoveryLoadResult = Win32ErrorCodes.ERROR_REGISTRY_CORRUPT + }; + + OfflineHiveLoadResult result = OfflineRegistryHive.TryLoad(hive.Path, logger: null, nativeApi); + + Assert.Equal(OfflineHiveLoadStatus.RecoveryFailed, result.Status); + Assert.Equal(1, nativeApi.LoadHiveForRecoveryCalls); + // RegLoadKey failed, so there is nothing mounted to unload. + Assert.Equal(0, nativeApi.UnloadHiveCalls); + } + + [Fact] + public void TryLoad_WhenAppHiveLoadSucceeds_DoesNotEnterRecovery() + { + using var hive = TempHive.Seeded(); + var nativeApi = new FakeHiveNativeApi + { + // A real handle so RegistryKey.FromHandle works; the fake just reports success and never recovers. + AppHiveLoadResult = Win32ErrorCodes.ERROR_SUCCESS, + AppHiveHandleFactory = () => OpenRealAppHive(hive.Path) + }; + + OfflineHiveLoadResult result = OfflineRegistryHive.TryLoad(hive.Path, logger: null, nativeApi); + + using OfflineRegistryHive? loaded = result.Hive; + + Assert.Equal(OfflineHiveLoadStatus.Loaded, result.Status); + Assert.Equal(0, nativeApi.TryEnterRecoveryPrivilegeCalls); + Assert.Equal(0, nativeApi.LoadHiveForRecoveryCalls); + } + + [Fact] + public void TryLoad_WhenRecoveryLoadSucceedsButRootCannotOpen_ReturnsRecoveryFailedAndUnloadsTheMount() + { + using var hive = TempHive.Seeded(); + var nativeApi = new FakeHiveNativeApi + { + AppHiveLoadResult = Win32ErrorCodes.ERROR_BADDB, + RecoveryPrivilegeAvailable = true, + RecoveryLoadResult = Win32ErrorCodes.ERROR_SUCCESS, + MountedRoot = null + }; + + OfflineHiveLoadResult result = OfflineRegistryHive.TryLoad(hive.Path, logger: null, nativeApi); + + Assert.Equal(OfflineHiveLoadStatus.RecoveryFailed, result.Status); + // The exception-safe partial-failure path must unmount the hive it just mounted, so no HKLM mount leaks. + Assert.Equal(1, nativeApi.LoadHiveForRecoveryCalls); + Assert.Equal(1, nativeApi.UnloadHiveCalls); + } + + private static SafeRegistryHandle OpenRealAppHive(string hivePath) + { + int result = NativeMethods.RegLoadAppKey(hivePath, out nint handle, NativeMethods.KEY_READ, 0, 0); + Assert.Equal(Win32ErrorCodes.ERROR_SUCCESS, result); + + return new SafeRegistryHandle(handle, ownsHandle: true); + } + + private sealed class FakeHiveNativeApi : IOfflineHiveNativeApi + { + public Func? AppHiveHandleFactory { get; init; } + + public int AppHiveLoadResult { get; init; } + + public int LoadHiveForRecoveryCalls { get; private set; } + + public RegistryKey? MountedRoot { get; init; } + + public int RecoveryLoadResult { get; init; } + + public bool RecoveryPrivilegeAvailable { get; init; } + + public int TryEnterRecoveryPrivilegeCalls { get; private set; } + + public int UnloadHiveCalls { get; private set; } + + // The sweep enumerates HKLM through the seam; an empty set keeps these fake-driven tests hermetic from any real + // ELX_ recovery mount a force-killed elevated run may have left on the host. + public IReadOnlyList EnumerateHklmSubKeyNames() => Array.Empty(); + + public int LoadApplicationHive(string hiveFilePath, out SafeRegistryHandle? root) + { + root = AppHiveLoadResult == Win32ErrorCodes.ERROR_SUCCESS ? AppHiveHandleFactory?.Invoke() : null; + + return AppHiveLoadResult; + } + + public int LoadHiveForRecovery(string mountSubKey, string hiveFilePath) + { + LoadHiveForRecoveryCalls++; + + return RecoveryLoadResult; + } + + public RegistryKey? OpenMountedRoot(string mountSubKey) => MountedRoot; + + public IDisposable? TryEnterRecoveryPrivilege(ITraceLogger? logger) + { + TryEnterRecoveryPrivilegeCalls++; + + return RecoveryPrivilegeAvailable ? new NoOpScope() : null; + } + + public int UnloadHive(string mountSubKey) + { + UnloadHiveCalls++; + + return Win32ErrorCodes.ERROR_SUCCESS; + } + + private sealed class NoOpScope : IDisposable + { + public void Dispose() { } + } + } + + private sealed class TempHive : IDisposable + { + private const int KeyAllAccess = 0xF003F; + + private TempHive(string path) => Path = path; + + public string Path { get; } + + public static TempHive Seeded() + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "elx_fallback_" + Guid.NewGuid().ToString("N") + ".dat"); + + int result = NativeMethods.RegLoadAppKey(path, out nint handle, KeyAllAccess, 0, 0); + Assert.Equal(0, result); + + using (RegistryKey root = RegistryKey.FromHandle(new SafeRegistryHandle(handle, ownsHandle: true))) + { + using RegistryKey key = root.CreateSubKey(@"Microsoft\Windows\CurrentVersion\WINEVT\Publishers"); + key.SetValue("seeded", 1); + root.Flush(); + } + + return new TempHive(path); + } + + public void Dispose() + { + foreach (string suffix in new[] { string.Empty, ".LOG", ".LOG1", ".LOG2" }) + { + if (File.Exists(Path + suffix)) { File.Delete(Path + suffix); } + } + } + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineRegistryHiveTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineRegistryHiveTests.cs new file mode 100644 index 000000000..569ad0a9e --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineRegistryHiveTests.cs @@ -0,0 +1,98 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.PublisherMetadata.Offline; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; + +public sealed class OfflineRegistryHiveTests +{ + private const int KEY_ALL_ACCESS = 0xF003F; + + [Fact] + public void TryLoad_FileWithoutHiveSignature_ReturnsNotAHiveWithoutAttemptingRecovery() + { + string notAHive = Path.Combine(Path.GetTempPath(), "elx_notahive_" + Guid.NewGuid().ToString("N") + ".dat"); + File.WriteAllBytes(notAHive, "this is not a registry hive"u8.ToArray()); + + try + { + // No "regf" signature -> rejected before any load/recovery path runs. + Assert.Equal(OfflineHiveLoadStatus.NotAHive, OfflineRegistryHive.TryLoad(notAHive, logger: null).Status); + } + finally + { + File.Delete(notAHive); + } + } + + [Fact] + public void TryLoad_NonexistentFile_ReturnsNotAHive() + { + string missing = Path.Combine(Path.GetTempPath(), "elx_missing_" + Guid.NewGuid().ToString("N") + ".dat"); + + Assert.Equal(OfflineHiveLoadStatus.NotAHive, OfflineRegistryHive.TryLoad(missing, logger: null).Status); + } + + [Fact] + public void TryLoad_ReadsValuesAndSubkeysWrittenToAStandaloneHive() + { + string hivePath = Path.Combine(Path.GetTempPath(), "elx_test_hive_" + Guid.NewGuid().ToString("N") + ".dat"); + + try + { + SeedHive(hivePath); + + OfflineHiveLoadResult result = OfflineRegistryHive.TryLoad(hivePath, logger: null); + + Assert.Equal(OfflineHiveLoadStatus.Loaded, result.Status); + + using OfflineRegistryHive? hive = result.Hive; + Assert.NotNull(hive); + + using RegistryKey? publisher = hive!.Root.OpenSubKey( + @"Microsoft\Windows\CurrentVersion\WINEVT\Publishers\{11111111-1111-1111-1111-111111111111}"); + + Assert.NotNull(publisher); + Assert.Equal("Test-Provider", publisher!.GetValue(null)); + Assert.Equal(@"%SystemRoot%\System32\test.dll", publisher.GetValue("ResourceFileName")); + } + finally + { + DeleteHive(hivePath); + } + } + + private static void DeleteHive(string hivePath) + { + foreach (string suffix in new[] { string.Empty, ".LOG", ".LOG1", ".LOG2" }) + { + string path = hivePath + suffix; + + if (File.Exists(path)) { File.Delete(path); } + } + } + + // Creates a fresh standalone hive file via RegLoadAppKey (which creates the backing file when absent), writes a + // representative WINEVT publisher subkey, then flushes + unloads so the file is a valid hive the production reader can + // stage and load. No admin or reg.exe needed - this is the CI-friendly offline-hive fixture. + private static void SeedHive(string hivePath) + { + int result = NativeMethods.RegLoadAppKey(hivePath, out nint handle, KEY_ALL_ACCESS, 0, 0); + + Assert.Equal(0, result); + + using (RegistryKey root = RegistryKey.FromHandle(new SafeRegistryHandle(handle, ownsHandle: true))) + { + using RegistryKey publisher = root.CreateSubKey( + @"Microsoft\Windows\CurrentVersion\WINEVT\Publishers\{11111111-1111-1111-1111-111111111111}"); + + publisher.SetValue(null, "Test-Provider"); + publisher.SetValue("ResourceFileName", @"%SystemRoot%\System32\test.dll"); + root.Flush(); + } + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineTestImage.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineTestImage.cs new file mode 100644 index 000000000..eaacba007 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineTestImage.cs @@ -0,0 +1,115 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.PublisherMetadata.Offline.Containment; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Diagnostics; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; + +/// +/// Builds a throwaway on-disk Windows-image scaffold ( +/// <temp>\Windows\System32\config\{SOFTWARE,SYSTEM}) for offline-extraction unit tests, with no admin and +/// no real Windows image. Each hive is either an empty placeholder (enough for path-only components such as the mapper +/// and root-guard) or a real standalone hive seeded via (for the catalog +/// and legacy-resolver readers). The scaffold's root directory is the image root, so a mapped host path such as +/// C:\Windows\System32\foo.dll correctly lands under the scaffold - never the host - even though the scaffold +/// itself lives on the host drive. +/// +internal sealed class OfflineTestImage : IDisposable +{ + private const int KeyAllAccess = 0xF003F; + + private OfflineTestImage(string rootDirectory, OfflineImageRoot imageRoot) + { + RootDirectory = rootDirectory; + ImageRoot = imageRoot; + } + + public OfflineImageRoot ImageRoot { get; } + + /// The image root directory (the directory that contains Windows). + public string RootDirectory { get; } + + public static OfflineTestImage Create( + Action? seedSoftware = null, + Action? seedSystem = null) + { + string rootDirectory = Path.Combine(Path.GetTempPath(), "elx_img_" + Guid.NewGuid().ToString("N")); + string configDirectory = Path.Combine(rootDirectory, "Windows", "System32", "config"); + Directory.CreateDirectory(configDirectory); + + SeedOrTouchHive(Path.Combine(configDirectory, "SOFTWARE"), seedSoftware); + SeedOrTouchHive(Path.Combine(configDirectory, "SYSTEM"), seedSystem); + + OfflineImageRoot imageRoot = OfflineImageRoot.TryCreate(rootDirectory, logger: null) + ?? throw new InvalidOperationException($"Failed to create OfflineImageRoot for scaffold {rootDirectory}."); + + return new OfflineTestImage(rootDirectory, imageRoot); + } + + /// + /// Creates an NTFS directory junction (which, unlike symbolic links, does not require elevation) for + /// reparse-point tests; returns false when the platform refuses so callers can Assert.SkipUnless. + /// + public static bool TryCreateJunction(string junctionPath, string targetPath) + { + try + { + using var process = Process.Start(new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{junctionPath}\" \"{targetPath}\"") + { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + + if (process is null) { return false; } + + process.WaitForExit(10_000); + + return process.HasExited && process.ExitCode == 0 && Directory.Exists(junctionPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Win32Exception) + { + return false; + } + } + + public void Dispose() + { + try + { + if (Directory.Exists(RootDirectory)) { Directory.Delete(RootDirectory, recursive: true); } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup of the throwaway scaffold. + } + } + + private static void SeedOrTouchHive(string hivePath, Action? seed) + { + if (seed is null) + { + // An empty placeholder is sufficient for components that only inspect paths and never load the hive. + File.WriteAllBytes(hivePath, []); + + return; + } + + int result = NativeMethods.RegLoadAppKey(hivePath, out nint handle, KeyAllAccess, 0, 0); + + if (result != 0) + { + throw new InvalidOperationException($"RegLoadAppKey failed to create the test hive {hivePath} (error {result})."); + } + + using RegistryKey root = RegistryKey.FromHandle(new SafeRegistryHandle(handle, ownsHandle: true)); + seed(root); + root.Flush(); + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineWimImageTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineWimImageTests.cs new file mode 100644 index 000000000..c7102ac5e --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/Offline/OfflineWimImageTests.cs @@ -0,0 +1,283 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.PublisherMetadata.Offline; +using EventLogExpert.Logging.Abstractions; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; + +/// +/// Drives the WIM read/validate/extract/cancel state machine deterministically through a fake +/// . The real WIMApplyImage path needs administrator privileges and a multi-GB +/// image, so these tests assert the orchestration (index validation, elevation gate, disk precheck, failure-safe temp +/// cleanup, status mapping) while a fake stands in for the native calls and creates/deletes a REAL temp directory so +/// the cleanup assertions are real, not mocked. The actual native apply is covered by the manual real-WIM E2E. +/// +public sealed class OfflineWimImageTests +{ + [Fact] + public void ReadIndexList_WhenFileExists_ForwardsToNativeApi() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi + { + Images = [new WimImageEntry(1, "Image", "Edition", 123)] + }; + + WimImageList result = OfflineWimImage.ReadIndexList(workspace.WimPath, nativeApi, logger: null); + + Assert.Equal(WimImageListStatus.Ok, result.Status); + Assert.Equal(1, nativeApi.ReadImageListCallCount); + Assert.Single(result.Images); + } + + [Fact] + public void ReadIndexList_WhenFileMissing_ReturnsNotAWimWithoutCallingNative() + { + var nativeApi = new FakeWimNativeApi(); + + WimImageList result = OfflineWimImage.ReadIndexList( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wim"), nativeApi, logger: null); + + Assert.Equal(WimImageListStatus.NotAWim, result.Status); + Assert.Equal(0, nativeApi.ReadImageListCallCount); + } + + [Fact] + public async Task TryExtractAsync_DisposeIsIdempotent() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi { OnApply = WriteAPartialFile }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + result.Image!.Dispose(); + result.Image.Dispose(); + + Assert.False(Directory.Exists(result.Image.ExtractedRoot)); + } + + [Fact] + public async Task TryExtractAsync_WhenAlreadyCancelled_ReturnsCancelledWithoutApplying() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, cts.Token); + + Assert.Equal(OfflineWimExtractStatus.Cancelled, result.Status); + Assert.Equal(0, nativeApi.ApplyCallCount); + } + + [Fact] + public async Task TryExtractAsync_WhenApplyAborts_ReturnsCancelledAndDeletesTheTemp() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi + { + ApplyResult = Win32ErrorCodes.ERROR_REQUEST_ABORTED, + OnApply = WriteAPartialFile + }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + Assert.Equal(OfflineWimExtractStatus.Cancelled, result.Status); + Assert.Empty(Directory.GetDirectories(workspace.TempParent)); + } + + [Fact] + public async Task TryExtractAsync_WhenApplyFails_ReturnsApplyFailedAndDeletesTheTemp() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi + { + ApplyResult = Win32ErrorCodes.ERROR_INVALID_DATA, + OnApply = WriteAPartialFile + }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + Assert.Equal(OfflineWimExtractStatus.ApplyFailed, result.Status); + Assert.Equal(1, nativeApi.ApplyCallCount); + // The partial extraction must not leak: no ELX_WIM_* directory remains under the temp parent. + Assert.Empty(Directory.GetDirectories(workspace.TempParent)); + } + + [Fact] + public async Task TryExtractAsync_WhenApplySucceeds_ReturnsExtractedRootThatDisposeDeletes() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi { OnApply = WriteAPartialFile }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + Assert.Equal(OfflineWimExtractStatus.Extracted, result.Status); + Assert.NotNull(result.Image); + Assert.True(Directory.Exists(result.Image!.ExtractedRoot)); + Assert.True(File.Exists(Path.Combine(result.Image.ExtractedRoot, "applied.txt"))); + + result.Image.Dispose(); + + Assert.False(Directory.Exists(result.Image.ExtractedRoot)); + } + + [Fact] + public async Task TryExtractAsync_WhenExtractionContainsReadOnlyFiles_StillDeletesTheTemp() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi + { + ApplyResult = Win32ErrorCodes.ERROR_INVALID_DATA, + OnApply = WriteAReadOnlyFile + }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + Assert.Equal(OfflineWimExtractStatus.ApplyFailed, result.Status); + // WIMApplyImage restores read-only attributes; cleanup must clear them or Directory.Delete would throw. + Assert.Empty(Directory.GetDirectories(workspace.TempParent)); + } + + [Fact] + public async Task TryExtractAsync_WhenImageLargerThanFreeSpace_ReturnsInsufficientSpaceWithoutApplying() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi + { + Images = [new WimImageEntry(1, "Huge", "Edition", long.MaxValue)] + }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + Assert.Equal(OfflineWimExtractStatus.InsufficientSpace, result.Status); + Assert.Equal(0, nativeApi.ApplyCallCount); + } + + [Fact] + public async Task TryExtractAsync_WhenIndexNotInImage_ReturnsIndexOutOfRangeWithoutApplying() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi + { + Images = [new WimImageEntry(1, "Only image", "Edition", null)] + }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 5, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + Assert.Equal(OfflineWimExtractStatus.IndexOutOfRange, result.Status); + Assert.Equal(0, nativeApi.ApplyCallCount); + } + + [Fact] + public async Task TryExtractAsync_WhenNotAWim_ReturnsNotAWimWithoutApplying() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi { ImageListStatus = WimImageListStatus.NotAWim }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + Assert.Equal(OfflineWimExtractStatus.NotAWim, result.Status); + Assert.Equal(0, nativeApi.ApplyCallCount); + } + + [Fact] + public async Task TryExtractAsync_WhenNotElevated_ReturnsNeedsElevationWithoutApplying() + { + using var workspace = new TempWorkspace(); + var nativeApi = new FakeWimNativeApi { Elevated = false }; + + OfflineWimExtractResult result = await OfflineWimImage.TryExtractAsync( + workspace.WimPath, 1, workspace.TempParent, nativeApi, logger: null, CancellationToken.None); + + Assert.Equal(OfflineWimExtractStatus.NeedsElevation, result.Status); + Assert.Null(result.Image); + Assert.Equal(0, nativeApi.ApplyCallCount); + Assert.Empty(Directory.GetDirectories(workspace.TempParent)); + } + + private static void WriteAPartialFile(string destinationDirectory) => + File.WriteAllText(Path.Combine(destinationDirectory, "applied.txt"), "extracted"); + + private static void WriteAReadOnlyFile(string destinationDirectory) + { + string path = Path.Combine(destinationDirectory, "readonly.bin"); + File.WriteAllText(path, "locked"); + File.SetAttributes(path, FileAttributes.ReadOnly); + } + + private sealed class FakeWimNativeApi : IWimNativeApi + { + public int ApplyCallCount { get; private set; } + + public int ApplyResult { get; init; } = Win32ErrorCodes.ERROR_SUCCESS; + + public bool Elevated { get; init; } = true; + + public WimImageListStatus ImageListStatus { get; init; } = WimImageListStatus.Ok; + + public IReadOnlyList Images { get; init; } = [new WimImageEntry(1, "Image", "Edition", null)]; + + public Action? OnApply { get; init; } + + public int ReadImageListCallCount { get; private set; } + + public int ApplyImage( + string wimPath, int imageIndex, string destinationDirectory, string scratchDirectory, CancellationToken cancellationToken, ITraceLogger? logger) + { + ApplyCallCount++; + OnApply?.Invoke(destinationDirectory); + + return ApplyResult; + } + + public bool IsProcessElevated() => Elevated; + + public WimImageList ReadImageList(string wimPath, ITraceLogger? logger) + { + ReadImageListCallCount++; + + return ImageListStatus == WimImageListStatus.Ok + ? new WimImageList(WimImageListStatus.Ok, Images) + : WimImageList.NotAWim; + } + } + + private sealed class TempWorkspace : IDisposable + { + public TempWorkspace() + { + TempParent = Path.Combine(Path.GetTempPath(), "elx_wimtest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(TempParent); + WimPath = Path.Combine(TempParent, "image.wim"); + File.WriteAllText(WimPath, "not a real wim - the fake ignores the content"); + } + + public string TempParent { get; } + + public string WimPath { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(TempParent)) { Directory.Delete(TempParent, recursive: true); } + } + catch (IOException) + { + // Best-effort cleanup of the test workspace. + } + } + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/OfflineImageProviderSourceTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/OfflineImageProviderSourceTests.cs new file mode 100644 index 000000000..0efed65e2 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/OfflineImageProviderSourceTests.cs @@ -0,0 +1,277 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata; +using EventLogExpert.Eventing.PublisherMetadata.Offline; +using EventLogExpert.Eventing.Tests.PublisherMetadata.Offline; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Logging.Abstractions.Handlers; +using EventLogExpert.Provider.Resolution; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; +using System.Text.RegularExpressions; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata; + +public sealed class OfflineImageProviderSourceTests +{ + private static readonly SourceOsProvenance s_testProvenance = new(22621, 3, "ServerStandard", "23H2"); + + [Fact] + public void Enumerate_DuplicateModernRegistrationsForOneName_YieldOnce() + { + var extractor = new FakeExtractor + { + ModernRegistrations = { Registration("Dup"), Registration("Dup") }, + BuildModern = registration => NonEmpty(registration.ProviderName) + }; + + List result = Enumerate(extractor); + + Assert.Equal(["Dup"], result.Select(details => details.ProviderName)); + } + + [Fact] + public void Enumerate_ExcludeSet_AppliesToBothModernAndLegacy() + { + var extractor = new FakeExtractor + { + ModernRegistrations = { Registration("Keep"), Registration("Excluded") }, + LegacyProviderNames = { "Excluded", "Other" }, + BuildModern = registration => NonEmpty(registration.ProviderName), + BuildLegacy = NonEmpty + }; + + var exclude = new HashSet(StringComparer.OrdinalIgnoreCase) { "Excluded" }; + + List result = Enumerate(extractor, excludeProviderNames: exclude); + + Assert.Equal(["Keep", "Other"], result.Select(details => details.ProviderName)); + } + + [Fact] + public void Enumerate_FailedModernBuild_DoesNotSuppressSameNameLegacyProvider() + { + var extractor = new FakeExtractor + { + ModernRegistrations = { Registration("X") }, + LegacyProviderNames = { "X" }, + BuildModern = _ => null, + BuildLegacy = NonEmpty + }; + + List result = Enumerate(extractor); + + // A name is marked seen only after a non-empty yield, so a modern build that fails closed leaves the legacy + // provider of the same name free to be built and yielded. + Assert.Equal(["X"], result.Select(details => details.ProviderName)); + } + + [Fact] + public void Enumerate_LegacyNameMatchingModern_YieldsModernAndNeverBuildsTheLegacyDuplicate() + { + var extractor = new FakeExtractor + { + ModernRegistrations = { Registration("Shared") }, + LegacyProviderNames = { "Shared", "Legacy-Only" }, + BuildModern = registration => NonEmpty(registration.ProviderName), + BuildLegacy = NonEmpty + }; + + List result = Enumerate(extractor); + + // The modern build already populates the legacy tables, so the same-named legacy registration is skipped + // before it is even built. + Assert.Equal(["Shared", "Legacy-Only"], result.Select(details => details.ProviderName)); + Assert.DoesNotContain("Shared", extractor.LegacyBuildRequests); + Assert.Contains("Legacy-Only", extractor.LegacyBuildRequests); + } + + [Fact] + public void Enumerate_NullOrEmptyModernBuilds_AreSkipped() + { + var extractor = new FakeExtractor + { + ModernRegistrations = { Registration("Null-Build"), Registration("Empty-Build") }, + BuildModern = registration => + registration.ProviderName == "Null-Build" ? null : Empty(registration.ProviderName) + }; + + Assert.Empty(Enumerate(extractor)); + } + + [Fact] + public void Enumerate_RegexFilter_AppliesToBothModernAndLegacyBeforeBuilding() + { + var extractor = new FakeExtractor + { + ModernRegistrations = { Registration("Keep-A"), Registration("Drop-B") }, + LegacyProviderNames = { "Keep-C", "Drop-D" }, + BuildModern = registration => NonEmpty(registration.ProviderName), + BuildLegacy = NonEmpty + }; + + List result = Enumerate(extractor, regex: new Regex("^Keep-")); + + Assert.Equal(["Keep-A", "Keep-C"], result.Select(details => details.ProviderName)); + Assert.DoesNotContain(extractor.ModernBuildRequests, registration => registration.ProviderName == "Drop-B"); + Assert.DoesNotContain("Drop-D", extractor.LegacyBuildRequests); + } + + [Fact] + public void Enumerate_YieldsModernThenLegacy_StampedWithImageProvenance() + { + var extractor = new FakeExtractor + { + Provenance = s_testProvenance, + ModernRegistrations = { Registration("Modern-A") }, + LegacyProviderNames = { "Legacy-B" }, + BuildModern = registration => NonEmpty(registration.ProviderName), + BuildLegacy = NonEmpty + }; + + List result = Enumerate(extractor); + + // Modern providers are enumerated before pure-legacy providers. + Assert.Equal(["Modern-A", "Legacy-B"], result.Select(details => details.ProviderName)); + Assert.All(result, details => + { + Assert.Equal(s_testProvenance.Build, details.SourceOsBuild); + Assert.Equal(s_testProvenance.Revision, details.SourceOsRevision); + Assert.Equal(s_testProvenance.Edition, details.SourceOsEdition); + Assert.Equal(s_testProvenance.DisplayVersion, details.SourceOsDisplayVersion); + }); + } + + [Fact] + public void LoadProviders_PathIsNotAWindowsImage_LogsErrorAndYieldsEmpty() + { + var logger = new CapturingTraceLogger(); + string emptyDirectory = Path.Combine(Path.GetTempPath(), "elx_not_image_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(emptyDirectory); + + try + { + List result = + OfflineImageProviderSource.LoadProviders(emptyDirectory, logger).ToList(); + + Assert.Empty(result); + Assert.Contains( + logger.Errors, + message => message.Contains("not a readable Windows image", StringComparison.Ordinal)); + } + finally + { + Directory.Delete(emptyDirectory, recursive: true); + } + } + + [Fact] + public void LoadProviders_SyntheticImage_RunsTheFullPipelineWithoutCrashing() + { + var logger = new CapturingTraceLogger(); + using OfflineTestImage image = OfflineTestImage.Create(SeedSoftware, SeedSystem); + + // A real extractor over a synthetic image: the re-rooted DLLs do not exist, so every modern and legacy build + // fails closed and the enumeration yields nothing - but TryCreate, both loops, the provenance read, and the + // hive unload all run end to end. + List result = + OfflineImageProviderSource.LoadProviders(image.RootDirectory, logger).ToList(); + + Assert.Empty(result); + } + + private static ProviderDetails Empty(string providerName) => new() { ProviderName = providerName }; + + private static List Enumerate( + FakeExtractor extractor, + Regex? regex = null, + IReadOnlySet? excludeProviderNames = null) => + OfflineImageProviderSource.Enumerate(extractor, regex, excludeProviderNames).ToList(); + + private static ProviderDetails NonEmpty(string providerName) => + new() { ProviderName = providerName, Keywords = new Dictionary { [1] = "keyword" } }; + + private static OfflinePublisherRegistration Registration(string providerName) => + new(Guid.NewGuid(), providerName, ResourceFilePath: null, MessageFilePaths: [], ParameterFilePath: null); + + private static void SeedSoftware(RegistryKey software) + { + using RegistryKey publisher = software.CreateSubKey( + @"Microsoft\Windows\CurrentVersion\WINEVT\Publishers\{44444444-4444-4444-4444-444444444444}"); + publisher.SetValue(null, "Modern-Image-Provider"); + publisher.SetValue("ResourceFileName", @"%SystemRoot%\System32\absent.dll", RegistryValueKind.ExpandString); + } + + private static void SeedSystem(RegistryKey system) + { + using (RegistryKey select = system.CreateSubKey("Select")) + { + select.SetValue("Current", 1, RegistryValueKind.DWord); + } + + using RegistryKey provider = + system.CreateSubKey(@"ControlSet001\Services\EventLog\Application\LegacyImageProvider"); + provider.SetValue("EventMessageFile", @"C:\Windows\System32\absent.dll", RegistryValueKind.ExpandString); + } + + private sealed class CapturingTraceLogger : ITraceLogger + { + public List Errors { get; } = []; + + public LogLevel MinimumLevel => LogLevel.Trace; + + public void Critical(CriticalLogHandler handler) => handler.ToStringAndClear(); + + public void Debug(DebugLogHandler handler) => handler.ToStringAndClear(); + + public void Error(ErrorLogHandler handler) => Errors.Add(handler.ToStringAndClear()); + + public void Information(InformationLogHandler handler) => handler.ToStringAndClear(); + + public void Trace(TraceLogHandler handler) => handler.ToStringAndClear(); + + public void Warning(WarningLogHandler handler) => handler.ToStringAndClear(); + } + + private sealed class FakeExtractor : IOfflineImageProviderExtractor + { + public Func BuildLegacy { get; init; } = _ => null; + + public Func BuildModern { get; init; } = _ => null; + + public bool Disposed { get; private set; } + + public List LegacyBuildRequests { get; } = []; + + public List LegacyProviderNames { get; init; } = []; + + public List ModernBuildRequests { get; } = []; + + public List ModernRegistrations { get; init; } = []; + + public SourceOsProvenance Provenance { get; init; } = SourceOsProvenance.Empty; + + public void Dispose() => Disposed = true; + + public IReadOnlyList EnumerateLegacyProviderNames() => LegacyProviderNames; + + public SourceOsProvenance ReadImageProvenance() => Provenance; + + public IReadOnlyList ReadModernRegistrations() => ModernRegistrations; + + public ProviderDetails? TryBuildLegacyProvider(string providerName) + { + LegacyBuildRequests.Add(providerName); + + return BuildLegacy(providerName); + } + + public ProviderDetails? TryBuildModernProvider(OfflinePublisherRegistration registration) + { + ModernBuildRequests.Add(registration); + + return BuildModern(registration); + } + } +} diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsFactoryTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsFactoryTests.cs new file mode 100644 index 000000000..092ea121a --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/ProviderDetailsFactoryTests.cs @@ -0,0 +1,313 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata; + +public sealed class ProviderDetailsFactoryTests +{ + [Fact] + public void Create_DuplicateProjectedOpcodeKey_KeepsFirstAndPreservesDistinctKeys() + { + // Opcode keys are the value shifted right 16 bits: 0x00010000 and 0x0001FFFF both project to 1; 0x00020000 to 2. + // First write wins for the colliding key, and the distinct key survives (the dedup the native getters used to do). + var content = CreateContent( + resolveMessage: _ => null, + opcodes: + [ + new RawNamedValue(0x00010000, uint.MaxValue, "First"), + new RawNamedValue(0x0001FFFF, uint.MaxValue, "Second"), + new RawNamedValue(0x00020000, uint.MaxValue, "Third") + ]); + + var details = ProviderDetailsFactory.Create(content, null); + + Assert.Equal(2, details.Opcodes.Count); + Assert.Equal("First", details.Opcodes[1]); + Assert.Equal("Third", details.Opcodes[2]); + } + + [Fact] + public void Create_Event_ExpandsKeywordMaskAndResolvesDescriptionAndLogName() + { + var content = CreateContent( + resolveMessage: id => id == 50 ? "Event description" : null, + channels: new Dictionary { [16] = "Operational" }, + events: + [ + new RawProviderEvent( + Id: 4624, + Version: 1, + ChannelId: 16, + Level: 0, + Opcode: 0, + Task: 0, + KeywordsMask: 0x8000000000000001, + Template: "