Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ protected static async IAsyncEnumerable<ProviderDetails> LoadLocalProvidersAsync
}
}

/// <summary>
/// Streams providers extracted from a mounted or extracted foreign Windows image, fully offline. Mirrors
/// <see cref="LoadLocalProvidersAsync" />: offline extraction is synchronous (registry-hive + DLL reads), so this
/// wrapper exposes it as an <see cref="IAsyncEnumerable{T}" /> for the shared <c>await foreach</c> 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.
/// </summary>
protected static async IAsyncEnumerable<ProviderDetails> LoadOfflineImageProvidersAsync(
string offlineImagePath,
ITraceLogger logger,
Regex? regex,
IReadOnlySet<string>? excludeProviderNames = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;

foreach (var details in OfflineImageProviderSource.LoadProviders(offlineImagePath, logger, regex, excludeProviderNames))
{
cancellationToken.ThrowIfCancellationRequested();

yield return details;
}
}

/// <summary>
/// Emits the provider-details column header sized to the longest provider name. Updates the instance format
/// string so subsequent <see cref="LogProviderDetails" /> calls align to the same width.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<DatabaseToolsOutcome> ExecuteAsync(
ITraceLogger logger,
IProgress<DatabaseToolsProgress>? progress,
Expand All @@ -40,6 +43,11 @@ public async Task<DatabaseToolsOutcome> 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;
Expand Down Expand Up @@ -84,13 +92,69 @@ public async Task<DatabaseToolsOutcome> ExecuteAsync(
// 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<ProviderDetails> 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;
}

var hostOsProvenance = request.SourcePath is null ? HostOsProvenance.Read(logger) : null;
// 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<ProviderDetails> providersToAdd;
SourceOsProvenance? sourceOsProvenance;

switch (mode)
{
case CreateDatabaseMode.OfflineImage:
providersToAdd = LoadOfflineImageProvidersAsync(effectiveOfflineImagePath!,
logger,
filterRegex,
excludeProviderNames,
cancellationToken);

sourceOsProvenance = 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))
{
Expand All @@ -116,12 +180,12 @@ public async Task<DatabaseToolsOutcome> ExecuteAsync(
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)
Expand Down Expand Up @@ -203,6 +267,192 @@ public async Task<DatabaseToolsOutcome> 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;
}

/// <summary>
/// Picks the provider source for the request. An offline image (a non-whitespace <c>OfflineImagePath</c>) wins;
/// otherwise a null <c>SourcePath</c> 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.
/// </summary>
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;
}
}

/// <summary>
/// Maps a non-success <see cref="OfflineWimExtractStatus" /> to an actionable error and the operation outcome.
/// The extraction already cleaned up any partial temp, so there is nothing to dispose here.
/// </summary>
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})");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,37 @@
namespace EventLogExpert.DatabaseTools.CreateDatabase;

/// <summary>
/// Creates a new provider database from local providers or from a source. When <see cref="SourcePath" /> 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
/// <see cref="SourcePath" /> and <see cref="OfflineImagePath" /> are null, local providers on this machine are used
/// (no fallback). When <see cref="SourcePath" /> is supplied, ONLY that source is used. When
/// <see cref="OfflineImagePath" /> is supplied, ONLY that image is used, fully offline; it is mutually exclusive with
/// <see cref="SourcePath" />.
/// </summary>
/// <param name="TargetPath">Target .db file path. Must not already exist; must have .db extension.</param>
/// <param name="SourcePath">Optional source: .db, .evtx, or folder. Null = local providers.</param>
/// <param name="SourcePath">
/// Optional source: .db, .evtx, or folder. Null = local providers (unless an offline image is
/// given).
/// </param>
/// <param name="FilterRegex">Optional regex applied to provider names; null = no filter.</param>
/// <param name="SkipProvidersInFile">Optional source whose provider names are excluded from the new database.</param>
public sealed record CreateDatabaseRequest(string TargetPath, string? SourcePath, Regex? FilterRegex, string? SkipProvidersInFile);
/// <param name="OfflineImagePath">
/// Optional offline Windows image to extract providers from, fully offline (no host
/// registry or host files). Null = not an offline build. Mutually exclusive with <paramref name="SourcePath" />.
/// </param>
/// <param name="ImageKind">
/// How <paramref name="OfflineImagePath" /> is accessed: a mounted volume/extracted folder (
/// <see cref="OfflineImageKind.Directory" />) or a <c>.wim</c>/<c>.esd</c> file (<see cref="OfflineImageKind.Wim" />,
/// which extracts <paramref name="WimIndex" /> first). Null = auto-detect from the path (directory vs .wim/.esd/.iso).
/// </param>
/// <param name="WimIndex">
/// The 1-based image index to extract from a <c>.wim</c>/<c>.esd</c>, for
/// <see cref="OfflineImageKind.Wim" />. Null otherwise.
/// </param>
public sealed record CreateDatabaseRequest(
string TargetPath,
string? SourcePath,
Regex? FilterRegex,
string? SkipProvidersInFile,
string? OfflineImagePath = null,
OfflineImageKind? ImageKind = null,
int? WimIndex = null);
Loading