diff --git a/GenHub/GenHub.Core/Constants/CatalogConstants.cs b/GenHub/GenHub.Core/Constants/CatalogConstants.cs index 0e0ff3f5d..2e23578b4 100644 --- a/GenHub/GenHub.Core/Constants/CatalogConstants.cs +++ b/GenHub/GenHub.Core/Constants/CatalogConstants.cs @@ -1,8 +1,26 @@ namespace GenHub.Core.Constants; /// -/// Constants for publisher catalog system. +/// Constants for the modular publisher-catalog system. /// +/// +/// Layering (see Publisher Studio architecture): +/// +/// +/// Provider Definition — static publisher metadata + catalog endpoint(s) +/// (bundled *.provider.json today; user-hosted definitions via Publisher Studio later). +/// +/// +/// Catalog — dynamic content listing (catalog.json / remote endpoint), updated on each release. +/// +/// +/// Artifacts — downloadable files referenced by catalog releases. +/// +/// +/// Anyone can author a GenHub-schema catalog, host it, and share +/// genhub://subscribe?url=.... Discovery uses +/// for catalog-direct subscriptions without per-publisher code. +/// public static class CatalogConstants { /// @@ -11,12 +29,17 @@ public static class CatalogConstants public const int CatalogSchemaVersion = 1; /// - /// Filename for subscriptions storage. + /// Filename for user subscription storage under application data. /// public const string SubscriptionFileName = "subscriptions.json"; /// - /// Resolver ID for generic catalog resolver. + /// Sidebar / discoverer category for user-subscribed catalogs (vs built-in static/dynamic). + /// + public const string SubscribedPublisherCategory = "subscribed"; + + /// + /// Resolver / pipeline ID for the generic catalog pipeline (any GenHub-schema catalog). /// public const string GenericCatalogResolverId = "generic-catalog"; @@ -29,4 +52,44 @@ public static class CatalogConstants /// Maximum catalog size in bytes (10 MB). /// public const long MaxCatalogSizeBytes = 10 * 1024 * 1024; + + /// + /// Maximum number of entries allowed when extracting publisher catalog archives. + /// + public const int MaxZipEntryCount = 50_000; + + /// + /// Maximum cumulative uncompressed size allowed when extracting publisher catalog archives (5 GB). + /// + public const long MaxZipUncompressedSizeBytes = 5L * 1024 * 1024 * 1024; + + /// + /// Resolver metadata key for serialized publisher profile JSON. + /// + public const string PublisherProfileJsonMetadataKey = "publisherProfileJson"; + + /// + /// Resolver metadata key for serialized catalog item JSON. + /// + public const string CatalogItemJsonMetadataKey = "catalogItemJson"; + + /// + /// Resolver metadata key for serialized release JSON. + /// + public const string ReleaseJsonMetadataKey = "releaseJson"; + + /// + /// Resolver metadata key for the stable catalog content id (not the display name). + /// + public const string CatalogContentIdMetadataKey = "catalogContentId"; + + /// + /// Resolver metadata key for serialized bundle component descriptors. + /// + public const string BundleComponentsJsonMetadataKey = "bundleComponentsJson"; + + /// + /// Resolver metadata key for serialized publisher referrals JSON. + /// + public const string CatalogReferralsJsonMetadataKey = "catalogReferralsJson"; } diff --git a/GenHub/GenHub.Core/Constants/GameContentConstants.cs b/GenHub/GenHub.Core/Constants/GameContentConstants.cs new file mode 100644 index 000000000..146ef84c8 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/GameContentConstants.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace GenHub.Core.Constants; + +/// +/// Constants for game content structure, archive payload normalization, and recognized game assets. +/// +public static class GameContentConstants +{ + /// + /// Maximum recursive wrapper directory stripping depth. + /// + public const int MaxWrapperNormalizationDepth = 10; + + /// + /// Supported archive file extensions. + /// + public static readonly IReadOnlyList ArchiveExtensions = + [ + ".zip", + ".7z", + ".rar", + ".dat", + ]; + + /// + /// Canonical directory names used at the game workspace root. + /// + public static readonly IReadOnlyList RecognizedGameDirectories = + [ + "Data", + "Art", + "Window", + "Audio", + "Maps", + "INI", + "Scripts", + "Textures", + "W3D", + "English", + "German", + "French", + "Italian", + "Spanish", + "Korean", + "Polish", + "Chinese", + ]; + + /// + /// Canonical file extensions for game assets, binaries, and configurations. + /// + public static readonly IReadOnlyList RecognizedGameFileExtensions = + [ + ".big", + ".exe", + ".dll", + ".str", + ".csf", + ".ini", + ".map", + ".bik", + ".asi", + ]; + + /// + /// Extensions for loose non-game documentation and metadata files. + /// + public static readonly IReadOnlyList DocumentationExtensions = + [ + ".txt", + ".url", + ".md", + ".htm", + ".html", + ".pdf", + ".lnk", + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ]; + + /// + /// System junk file or directory names to purge during payload normalization. + /// + public static readonly IReadOnlyList SystemJunkNames = + [ + ".ds_store", + "thumbs.db", + "desktop.ini", + "__macosx", + ]; + + /// + /// Subfolder aliases that denote Zero Hour specific game content. + /// + public static readonly IReadOnlyList ZeroHourSubfolderAliases = + [ + "Zero Hour", + "ZH", + "Command and Conquer Generals Zero Hour", + "Command & Conquer Generals - Zero Hour", + "Command & Conquer: Generals - Zero Hour", + "C&C Generals Zero Hour", + "ZeroHour", + ]; + + /// + /// Subfolder aliases that denote Generals specific game content. + /// + public static readonly IReadOnlyList GeneralsSubfolderAliases = + [ + "Generals", + "CCG", + "Command and Conquer Generals", + "Command & Conquer Generals", + "C&C Generals", + ]; + + /// + /// Default variant resolution for control bar packages. + /// + public const string DefaultControlBarVariant = "1080p"; + + /// + /// Base filename for standard Control Bar Pro BIG archive. + /// + public const string ControlBarProBaseFileName = "340_ControlBarProZH.big"; + + /// + /// Base filename for Lemon Edition Control Bar Pro BIG archive. + /// + public const string ControlBarProLemonBaseFileName = "340_ControlBarProLemonEditionZH.big"; + + /// + /// Standard subfolder name for English BIG files. + /// + public const string BigEnDirectoryName = "BIG EN"; + + /// + /// Standard subfolder name for BIG files. + /// + public const string BigDirectoryName = "BIG"; + + /// + /// GenTool directory name. + /// + public const string GenToolDirectoryName = "GenTool"; + + /// + /// Window directory name. + /// + public const string WindowDirectoryName = "Window"; + + /// + /// Determines whether the specified directory name is a recognized canonical game directory. + /// + /// The directory name to check. + /// true if recognized; otherwise, false. + public static bool IsRecognizedGameDirectory(string? directoryName) + { + return !string.IsNullOrEmpty(directoryName) && + RecognizedGameDirectories.Contains(directoryName, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Determines whether the specified file extension or file name represents a recognized game asset. + /// + /// The file name or extension to check. + /// true if recognized; otherwise, false. + public static bool IsRecognizedGameFile(string? fileNameOrExtension) + { + if (string.IsNullOrEmpty(fileNameOrExtension)) + { + return false; + } + + var ext = Path.GetExtension(fileNameOrExtension); + if (string.IsNullOrEmpty(ext)) + { + ext = fileNameOrExtension; + } + + return RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/GenHub/GenHub.Core/Constants/IoConstants.cs b/GenHub/GenHub.Core/Constants/IoConstants.cs index 5b99c5710..9fc9be4f8 100644 --- a/GenHub/GenHub.Core/Constants/IoConstants.cs +++ b/GenHub/GenHub.Core/Constants/IoConstants.cs @@ -10,6 +10,11 @@ public static class IoConstants /// public const int DefaultFileBufferSize = 4096; + /// + /// Buffer size used when scanning binary streams for embedded signatures (8KB). + /// + public const int SignatureScanBufferSize = 8192; + /// /// How many times a path may be re-resolved while following symbolic links whose targets are /// themselves reached through links. Bounds the walk on a filesystem that contains a cycle. diff --git a/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs new file mode 100644 index 000000000..a165d5b67 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Helpers; + +/// +/// Policy and validation helper for ensuring file system paths remain safely contained +/// within a target root directory, preventing directory traversal and zip slip attacks across OS platforms. +/// +public static class ContentPathPolicy +{ + /// + /// Resolves a candidate relative path within a designated root directory, ensuring that the + /// resolved canonical path is strictly contained within that root directory. + /// + /// The trusted root directory. + /// The relative path to validate and resolve. + /// + /// An containing the normalized absolute destination path if safe, + /// or a failure result if the path escapes the root directory or contains illegal rooted/traversal components. + /// + public static OperationResult ResolveContainedFile(string? rootDirectory, string? relativePath) + { + if (string.IsNullOrWhiteSpace(rootDirectory)) + { + return OperationResult.CreateFailure("Root directory cannot be null or empty."); + } + + if (string.IsNullOrWhiteSpace(relativePath)) + { + return OperationResult.CreateFailure("Relative path cannot be null or empty."); + } + + if (Path.IsPathRooted(relativePath) || + (relativePath.Length >= 2 && relativePath[1] == ':' && char.IsLetter(relativePath[0])) || + relativePath.StartsWith("\\\\", StringComparison.Ordinal) || + relativePath.StartsWith("//", StringComparison.Ordinal)) + { + return OperationResult.CreateFailure($"Relative path cannot be rooted or absolute: {relativePath}"); + } + + // Normalize directory separators + var normalizedRelative = relativePath.Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar) + .TrimStart(Path.DirectorySeparatorChar); + + if (string.IsNullOrWhiteSpace(normalizedRelative)) + { + return OperationResult.CreateFailure("Normalized relative path cannot be empty."); + } + + var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + var fullRoot = Path.GetFullPath(normalizedRoot); + var fullCandidate = Path.GetFullPath(Path.Combine(fullRoot, normalizedRelative)); + + if (!IsContainedInternal(fullRoot, fullCandidate)) + { + return OperationResult.CreateFailure( + $"Path '{relativePath}' escapes target root directory '{rootDirectory}'."); + } + + return OperationResult.CreateSuccess(fullCandidate); + } + + /// + /// Validates whether a candidate path is strictly contained within a designated root directory. + /// + /// The root directory. + /// The candidate path to check. + /// if the candidate path is contained within the root; otherwise . + public static bool IsContained(string? rootDirectory, string? candidatePath) + { + if (string.IsNullOrWhiteSpace(rootDirectory) || string.IsNullOrWhiteSpace(candidatePath)) + { + return false; + } + + try + { + var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + var normalizedCandidate = candidatePath.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + var fullRoot = Path.GetFullPath(normalizedRoot); + var fullCandidate = Path.GetFullPath(normalizedCandidate); + + return IsContainedInternal(fullRoot, fullCandidate); + } + catch + { + return false; + } + } + + private static bool IsContainedInternal(string fullRoot, string fullCandidate) + { + var rootPrefix = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + if (!fullCandidate.StartsWith(rootPrefix, PathHelper.PathComparison) && + !fullCandidate.Equals(fullRoot, PathHelper.PathComparison)) + { + return false; + } + + var realRoot = ResolveRealPath(fullRoot); + var realCandidate = ResolveRealPath(fullCandidate); + + var realRootPrefix = realRoot.EndsWith(Path.DirectorySeparatorChar) + ? realRoot + : realRoot + Path.DirectorySeparatorChar; + + return realCandidate.StartsWith(realRootPrefix, PathHelper.PathComparison) || + realCandidate.Equals(realRoot, PathHelper.PathComparison); + } + + private static string ResolveRealPath(string path) + { + try + { + var current = path; + while (!string.IsNullOrEmpty(current)) + { + if (TryResolveLink(current, path, out var resolvedPath)) + { + return resolvedPath; + } + + if (File.Exists(current)) + { + break; + } + + current = Path.GetDirectoryName(current); + } + } + catch + { + // Fallback to path if resolution fails + } + + return path; + } + + private static bool TryResolveLink(string current, string originalPath, out string resolvedPath) + { + resolvedPath = string.Empty; + var targetFullName = TryGetLinkTargetFullName(current); + if (string.IsNullOrEmpty(targetFullName)) + { + return false; + } + + var relativeSuffix = Path.GetRelativePath(current, originalPath); + resolvedPath = relativeSuffix == "." + ? targetFullName + : Path.GetFullPath(Path.Combine(targetFullName, relativeSuffix)); + return true; + } + + private static string? TryGetLinkTargetFullName(string path) + { + if (File.Exists(path)) + { + var fileInfo = new FileInfo(path); + return fileInfo.LinkTarget != null + ? fileInfo.ResolveLinkTarget(returnFinalTarget: true)?.FullName + : null; + } + + if (Directory.Exists(path)) + { + var dirInfo = new DirectoryInfo(path); + return dirInfo.LinkTarget != null + ? dirInfo.ResolveLinkTarget(returnFinalTarget: true)?.FullName + : null; + } + + return null; + } +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs new file mode 100644 index 000000000..de2a70b5f --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs @@ -0,0 +1,52 @@ +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Service for safely extracting archives and normalizing payload directory structures for game workspaces. +/// +public interface IArchivePayloadProcessor +{ + /// + /// Extracts all archives located within the directory safely, recursively removing archive files after extraction. + /// + /// The directory containing extracted or downloaded content. + /// Optional content type to constrain executable archive extraction. + /// Cancellation token. + /// A task representing the asynchronous extraction operation. + Task ExtractArchivesSafelyAsync( + string extractedDirectory, + ContentType? contentType = null, + CancellationToken cancellationToken = default); + + /// + /// Normalizes the directory structure of an extracted payload, removing extraneous wrapper directories + /// and reconciling the content root with the workspace/target directory. + /// + /// The directory containing extracted files. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Cancellation token. + /// A task representing the asynchronous normalization operation. + Task NormalizeDirectoryStructureAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default); + + /// + /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation. + /// + /// The directory containing extracted or downloaded content. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Cancellation token. + /// A task representing the asynchronous processing operation. + Task ProcessPayloadAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs new file mode 100644 index 000000000..1b416b594 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Manifest; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Service for detecting, isolating, converting, and packaging Control Bar content into SAGE-compatible .big archives. +/// +public interface IControlBarPackageProcessor +{ + /// + /// Checks whether the extracted directory or manifest represents a Control Bar mod or UI addon that needs repacking. + /// + /// The directory containing extracted files. + /// The content manifest. + /// True if the content is a Control Bar that requires processing. + bool IsControlBarContent(string extractedDirectory, ContentManifest manifest); + + /// + /// Processes extracted Control Bar content: isolates the requested resolution variant, converts AVIF/WebP textures to TGA, + /// repacks Art/Data folders into .big archives, ensures metadata BIG is present, and cleans up raw sources. + /// + /// The directory containing extracted files. + /// The content manifest. + /// Optional explicit variant identifier (e.g. "1080p"). + /// Cancellation token. + /// A list of generated or included .big file names. + Task> ProcessAndRepackControlBarAsync( + string extractedDirectory, + ContentManifest manifest, + string? requestedVariant = null, + CancellationToken cancellationToken = default); + + /// + /// Finds the variant BIG root directory within extracted content. + /// + /// The extracted root directory. + /// The variant identifier (e.g. "1080p"). + /// The path to the variant root directory, or null if not found. + string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId); + + /// + /// Gets the normalized suffix for a variant identifier (e.g. "1080p" -> "1080"). + /// + /// The variant identifier. + /// The normalized variant suffix. + string GetControlBarVariantSuffix(string variantId); + + /// + /// Checks if a file is an allowed Control Bar .big archive for the given variant suffix. + /// + /// The file name. + /// The variant suffix. + /// True if the file is allowed. + bool IsAllowedControlBarBig(string fileName, string variantSuffix); +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs new file mode 100644 index 000000000..398f01d5c --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs @@ -0,0 +1,546 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; +using GenHub.Features.Content.Services.Common; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Common; + +/// +/// Unit tests for archive payload processing and directory structure normalization. +/// +public sealed class ArchivePayloadProcessorTests : IDisposable +{ + private readonly string _stagingDirectory = Path.Combine(Path.GetTempPath(), "GenHubPayloadTests", Guid.NewGuid().ToString("N")); + + /// + /// Verifies that extracting a valid ZIP archive unpacks all entries and removes the archive file. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ValidZip_ExtractsAllEntriesAndDeletesZipAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var zipPath = Path.Combine(_stagingDirectory, "test.zip"); + { + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + { + using var writer1 = new StreamWriter(archive.CreateEntry("Data/INI/GameData.ini").Open()); + await writer1.WriteAsync("GameData=1"); + } + + { + using var writer2 = new StreamWriter(archive.CreateEntry("Art/Textures/test.tga").Open()); + await writer2.WriteAsync("Texture"); + } + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory); + + // Assert + Assert.False(File.Exists(zipPath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "test.tga"))); + } + + /// + /// Verifies that multi-level nested wrapper directories (e.g. ModDB mods like C&C Generals Undone) + /// are recursively flattened so game assets end up directly at the workspace root. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MultiLevelSingleWrapper_FlattensToRootAsync() + { + // Arrange + var nestedDir = Path.Combine(_stagingDirectory, "C&C Generals Undone v1.0", "C&C Generals Undone v1.0"); + Directory.CreateDirectory(Path.Combine(nestedDir, "Art", "Textures")); + Directory.CreateDirectory(Path.Combine(nestedDir, "Data", "INI")); + Directory.CreateDirectory(Path.Combine(nestedDir, "Window")); + + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Readme.txt"), "Generals Undone Readme"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Art", "Textures", "test.tga"), "texture data"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Data", "INI", "GameData.ini"), "data"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Window", "MainMenu.wnd"), "window"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "test.tga"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Window", "MainMenu.wnd"))); + + // Old wrapper paths should no longer exist + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "C&C Generals Undone v1.0"))); + } + + /// + /// Verifies that loose documentation files at root alongside a single mod wrapper directory + /// are reconciled by promoting the mod contents to the root and keeping the documentation files. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_LooseReadmeWithModWrapper_FlattensModWrapperAlongsideReadmeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"), "Important instructions"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "ModDB_Link.url"), "https://www.moddb.com"); + + var modDir = Path.Combine(_stagingDirectory, "GeneralsUndone"); + Directory.CreateDirectory(Path.Combine(modDir, "Data", "INI")); + Directory.CreateDirectory(Path.Combine(modDir, "Art", "Textures")); + await File.WriteAllTextAsync(Path.Combine(modDir, "Data", "INI", "GameData.ini"), "inidata"); + await File.WriteAllTextAsync(Path.Combine(modDir, "Art", "Textures", "unit.tga"), "tgadata"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "ModDB_Link.url"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "unit.tga"))); + Assert.False(Directory.Exists(modDir)); + } + + /// + /// Verifies that game-specific subdirectories matching the target game (e.g. "Zero Hour") + /// are promoted to the payload root. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_GameSpecificSubdirectory_PromotesMatchingTargetGameFolderAsync() + { + // Arrange + var zhDir = Path.Combine(_stagingDirectory, "Zero Hour", "Data", "INI"); + Directory.CreateDirectory(zhDir); + await File.WriteAllTextAsync(Path.Combine(zhDir, "ZHData.ini"), "zh config"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "ZHData.ini"))); + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "Zero Hour"))); + } + + /// + /// Verifies that single map directories for ContentType.Map are preserved with their map folder. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MapContent_PreservesSingleMapDirectoryAsync() + { + // Arrange + var mapDir = Path.Combine(_stagingDirectory, "Lemuria"); + Directory.CreateDirectory(mapDir); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.map"), "map payload"); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.tga"), "preview payload"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Map, GameType.ZeroHour); + + // Assert + Assert.True(Directory.Exists(mapDir)); + Assert.True(File.Exists(Path.Combine(mapDir, "Lemuria.map"))); + Assert.True(File.Exists(Path.Combine(mapDir, "Lemuria.tga"))); + } + + /// + /// Verifies that double-wrapped map archives (e.g. MapDownload/MapName/MapName.map) + /// strip only the outer wrapper while preserving the inner map folder. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MapContentWithDoubleWrapper_FlattensOuterWrapperOnlyAsync() + { + // Arrange + var outerWrapper = Path.Combine(_stagingDirectory, "MapDownloadWrapper"); + var mapDir = Path.Combine(outerWrapper, "Lemuria"); + Directory.CreateDirectory(mapDir); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.map"), "map payload"); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.tga"), "preview payload"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Map, GameType.ZeroHour); + + // Assert + Assert.False(Directory.Exists(outerWrapper)); + Assert.True(Directory.Exists(Path.Combine(_stagingDirectory, "Lemuria"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Lemuria", "Lemuria.map"))); + } + + /// + /// Verifies that system junk files (.DS_Store, Thumbs.db, desktop.ini, __MACOSX) + /// are purged during normalization. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_PurgesSystemJunkAsync() + { + // Arrange + Directory.CreateDirectory(Path.Combine(_stagingDirectory, "__MACOSX")); + Directory.CreateDirectory(Path.Combine(_stagingDirectory, "Data")); + + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, ".DS_Store"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Thumbs.db"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "desktop.ini"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "__MACOSX", "._something"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Data", "GameData.ini"), "real data"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.False(File.Exists(Path.Combine(_stagingDirectory, ".DS_Store"))); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "Thumbs.db"))); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "desktop.ini"))); + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "__MACOSX"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "GameData.ini"))); + } + + /// + /// Verifies that an HTML error page pretending to be an archive is rejected. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_HtmlErrorPayload_ThrowsInvalidDataExceptionAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var fakeZip = Path.Combine(_stagingDirectory, "broken.zip"); + await File.WriteAllTextAsync(fakeZip, "Error 404 Not Found"); + + var processor = CreateProcessor(); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => processor.ExtractArchivesSafelyAsync(_stagingDirectory)); + Assert.Contains("HTML", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that a self-extracting .exe archive for a Mod is extracted safely and the source .exe is removed. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_SelfExtractingExeMod_ExtractsAndDeletesExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var sfxExePath = Path.Combine(_stagingDirectory, "ShockWaveV1201.exe"); + using (var archive = ZipFile.Open(sfxExePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("!ShockWave.big"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("BIG data payload"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod); + + // Assert + Assert.False(File.Exists(sfxExePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShockWave.big"))); + } + + /// + /// Verifies that executable files for tools or executables are never extracted or deleted even if they are zip containers. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ExecutableTool_DoesNotExtractOrDeleteExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var toolExePath = Path.Combine(_stagingDirectory, "WorldBuilder.exe"); + using (var archive = ZipFile.Open(toolExePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("internal.dll"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("dll"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.ModdingTool); + + // Assert: Tool executable is preserved intact and NOT extracted + Assert.True(File.Exists(toolExePath)); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "internal.dll"))); + } + + /// + /// Verifies that non-archive game.dat files are skipped and preserved. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_GameDatBinary_PreservedWithoutThrowingAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var gameDatPath = Path.Combine(_stagingDirectory, "game.dat"); + await File.WriteAllTextAsync(gameDatPath, "MZ_Binary_Executable_Payload_Not_Archive"); + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Patch); + + // Assert + Assert.True(File.Exists(gameDatPath)); + } + + /// + /// Verifies that valid .dat archives (e.g. 10zh.dat) are extracted. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ValidDatArchive_ExtractsAndDeletesDatAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var datArchivePath = Path.Combine(_stagingDirectory, "10zh.dat"); + using (var archive = ZipFile.Open(datArchivePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("ZH/game.dat"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("ZH game binary"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Patch); + + // Assert + Assert.False(File.Exists(datArchivePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "ZH", "game.dat"))); + } + + /// + /// Verifies that inactive .gib mod files are renamed to .big during normalization. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_GibFiles_NormalizesToBigAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var gibPath = Path.Combine(_stagingDirectory, "!ShwAudio.gib"); + await File.WriteAllTextAsync(gibPath, "Audio BIG payload"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.False(File.Exists(gibPath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShwAudio.big"))); + } + + /// + /// Verifies that self-extracting executable archives (e.g. ShockWaveV1201.exe with PE header followed by ZIP central directory) + /// are detected and extracted safely for mod content types. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_SelfExtractingExeArchive_ExtractsAndDeletesExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var sfxExePath = Path.Combine(_stagingDirectory, "ShockWaveV1201.exe"); + + using (var memoryStream = new MemoryStream()) + { + var peHeader = new byte[512]; + peHeader[0] = 0x4D; // 'M' + peHeader[1] = 0x5A; // 'Z' + memoryStream.Write(peHeader, 0, peHeader.Length); + + using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + { + var entry1 = zipArchive.CreateEntry("Data/INI/ShockWave.ini"); + using var writer1 = new StreamWriter(entry1.Open()); + await writer1.WriteAsync("ModName=ShockWave"); + } + + { + var entry2 = zipArchive.CreateEntry("!ShwAudio.gib"); + using var writer2 = new StreamWriter(entry2.Open()); + await writer2.WriteAsync("Audio content"); + } + } + + await File.WriteAllBytesAsync(sfxExePath, memoryStream.ToArray()); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod); + + // Assert + Assert.False(File.Exists(sfxExePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "ShockWave.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShwAudio.gib"))); + } + + /// + /// Verifies that Smart Install Maker SFX executables (e.g. ShockWave) are safely extracted and normalized. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_WithSmartInstallMakerExecutable_ExtractsAndNormalizesSuccessfully() + { + var casPath = @"A:\Steam\steamapps\common\.genhub-cas\objects\f4\f45e14d6b4a1e6e6feaa2ad737528b385586ad81ab7535bf9a330972db834c4e"; + if (!File.Exists(casPath)) + { + return; + } + + var testDir = Path.Combine(_stagingDirectory, "sim_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testDir); + + var installerPath = Path.Combine(testDir, "ShockWaveV1201.exe"); + File.Copy(casPath, installerPath, overwrite: true); + + var processor = CreateProcessor(); + + // 1. Extract archive safely + await processor.ExtractArchivesSafelyAsync(testDir, ContentType.Mod); + + // 2. Original installer .exe should have been deleted after extraction + Assert.False(File.Exists(installerPath), "Installer executable should be removed after successful extraction."); + + // 3. Normalize directory structure + await processor.NormalizeDirectoryStructureAsync(testDir, ContentType.Mod, GameType.ZeroHour); + + // 4. Verify extracted and normalized game files exist with full uncompressed size + var textureBigPath = Path.Combine(testDir, "!ShwTextures.big"); + Assert.True(File.Exists(textureBigPath), "Expected !ShwTextures.big to exist after normalization."); + var textureInfo = new FileInfo(textureBigPath); + Assert.True(textureInfo.Length > 60_000_000, $"Expected full textures >60MB, got {textureInfo.Length} bytes."); + + Assert.True( + File.Exists(Path.Combine(testDir, "!!0ShwPtchIcon.big")), + "Expected !!0ShwPtchIcon.big to exist."); + Assert.True( + File.Exists(Path.Combine(testDir, "!ShwAudio.big")), + "Expected !ShwAudio.big to exist."); + Assert.True( + File.Exists(Path.Combine(testDir, "ShockWaveLauncher.exe")), + "Expected ShockWaveLauncher.exe to exist."); + } + + /// + /// Verifies that payloads containing nested archives exceeding maximum extraction depth throw InvalidDataException. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ExceedsMaxNestedDepth_ThrowsInvalidDataExceptionAsync() + { + // Arrange: create 6 layers of nested zips + Directory.CreateDirectory(_stagingDirectory); + var currentZip = Path.Combine(_stagingDirectory, "nested_level_6.zip"); + { + using var archive = ZipFile.Open(currentZip, ZipArchiveMode.Create); + using var writer = new StreamWriter(archive.CreateEntry("Data/test.ini").Open()); + await writer.WriteAsync("data=1"); + } + + for (var i = 5; i >= 1; i--) + { + var nextZip = Path.Combine(_stagingDirectory, $"nested_level_{i}.zip"); + using (var archive = ZipFile.Open(nextZip, ZipArchiveMode.Create)) + { + archive.CreateEntryFromFile(currentZip, Path.GetFileName(currentZip)); + } + + File.Delete(currentZip); + currentZip = nextZip; + } + + var processor = CreateProcessor(); + + // Act & Assert + await Assert.ThrowsAsync(() => + processor.ExtractArchivesSafelyAsync(_stagingDirectory)); + } + + /// + /// Verifies that wrapper promotion with colliding files preserving both files when content differs. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_WrapperCollisionWithDifferentContent_PreservesBothFilesAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var wrapperDir = Path.Combine(_stagingDirectory, "WrapperFolder"); + Directory.CreateDirectory(Path.Combine(wrapperDir, "Data")); + + // File at root + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"), "Root Readme content"); + + // File inside wrapper with same name but different content + await File.WriteAllTextAsync(Path.Combine(wrapperDir, "Readme.txt"), "Wrapper Readme content"); + await File.WriteAllTextAsync(Path.Combine(wrapperDir, "Data", "GameData.ini"), "data=1"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme_1.txt"))); + var rootText = await File.ReadAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt")); + var wrapperText = await File.ReadAllTextAsync(Path.Combine(_stagingDirectory, "Readme_1.txt")); + Assert.Contains("Readme content", rootText); + Assert.Contains("Readme content", wrapperText); + Assert.NotEqual(rootText, wrapperText); + } + + /// + public void Dispose() + { + if (Directory.Exists(_stagingDirectory)) + { + Directory.Delete(_stagingDirectory, recursive: true); + } + } + + private static ArchivePayloadProcessor CreateProcessor() + { + return new ArchivePayloadProcessor(new Mock>().Object); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs index b976ece3e..c3d836dd0 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs @@ -1,4 +1,5 @@ using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.CommunityOutpost; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; @@ -22,6 +23,7 @@ public class CommunityOutpostManifestFactoryTests : IDisposable { private readonly Mock> _loggerMock; private readonly Mock _hashProviderMock; + private readonly Mock _controlBarProcessorMock; private readonly CommunityOutpostManifestFactory _factory; private readonly string _tempDir; @@ -32,11 +34,12 @@ public CommunityOutpostManifestFactoryTests() { _loggerMock = new Mock>(); _hashProviderMock = new Mock(); + _controlBarProcessorMock = new Mock(); _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) .ReturnsAsync("abc123hash"); - _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, null!); + _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, _controlBarProcessorMock.Object); _tempDir = Path.Combine(Path.GetTempPath(), "GenHubTest_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index 8ecfe2931..112d9df4e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -1,6 +1,7 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Providers; using GenHub.Core.Models.Results; @@ -9,6 +10,7 @@ using GenHub.Features.Content.Services.GitHub; using Microsoft.Extensions.Logging; using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content; @@ -22,6 +24,7 @@ public class GitHubContentProviderTests private readonly Mock _delivererMock; private readonly Mock _validatorMock; private readonly Mock> _loggerMock; + private readonly Mock _archiveProcessorMock; private readonly GitHubContentProvider _provider; /// @@ -34,6 +37,7 @@ public GitHubContentProviderTests() _delivererMock = new Mock(); _validatorMock = new Mock(); _loggerMock = new Mock>(); + _archiveProcessorMock = new Mock(); // Setup mocks to be correctly identified by the provider _discovererMock.Setup(d => d.SourceName).Returns("GitHub"); @@ -62,7 +66,8 @@ public GitHubContentProviderTests() [_delivererMock.Object], _loggerMock.Object, _validatorMock.Object, - instructionsMock.Object); + instructionsMock.Object, + _archiveProcessorMock.Object); } /// @@ -139,5 +144,6 @@ public async Task PrepareContentAsync_CallsDelivererAndValidator_SuccessfullyAsy // The base class should orchestrate the calls _delivererMock.Verify(d => d.CanDeliver(It.IsAny()), Times.AtLeastOnce()); _delivererMock.Verify(d => d.DeliverContentAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once()); + _archiveProcessorMock.Verify(a => a.ProcessPayloadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs new file mode 100644 index 000000000..9a21da8a5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.Common; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.Common; + +/// +/// Unit tests for . +/// +public sealed class ControlBarPackageProcessorTests : IDisposable +{ + private readonly string _testDir = Path.Combine(Path.GetTempPath(), "GenHubTests", Guid.NewGuid().ToString("N")); + + /// + public void Dispose() + { + if (Directory.Exists(_testDir)) + { + try + { + Directory.Delete(_testDir, recursive: true); + } + catch + { + // Best effort + } + } + } + + /// + /// Verifies that IsControlBarContent detects Control Bar manifests by identifier and name. + /// + [Fact] + public void IsControlBarContent_WithControlBarManifest_ReturnsTrue() + { + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"), + Name = "Control Bar Pro Lemon Edition ZH (1080p)", + ContentType = ContentType.Addon, + }; + + var result = processor.IsControlBarContent(_testDir, manifest); + + Assert.True(result); + } + + /// + /// Verifies that nested resolution folder structure (ZH/1080p/BIG/...) is repacked into SAGE BIG archives. + /// + /// A completed task. + [Fact] + public async Task ProcessAndRepackControlBarAsync_WithNestedVariantStructure_RepacksToBigArchivesAsync() + { + // Arrange + var variantRoot = Path.Combine(_testDir, "ZH", "1080p", "BIG"); + var windowDir = Path.Combine(variantRoot, "Window"); + var artDir = Path.Combine(variantRoot, "Art", "Textures"); + var genToolDir = Path.Combine(variantRoot, "GenTool"); + + Directory.CreateDirectory(windowDir); + Directory.CreateDirectory(artDir); + Directory.CreateDirectory(genToolDir); + + await File.WriteAllTextAsync(Path.Combine(windowDir, "ControlBarPro.wnd"), "Window data"); + await File.WriteAllTextAsync(Path.Combine(artDir, "test.tga"), "TGA Texture data"); + await File.WriteAllTextAsync(Path.Combine(genToolDir, "fullviewport.dat"), "Viewport data"); + + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"), + Name = "Control Bar Pro Lemon Edition ZH (1080p)", + ContentType = ContentType.Addon, + }; + + // Act + var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest); + + // Assert + Assert.Contains("340_ControlBarProArt1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProData1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProZH.big", outputFiles); + + Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProArt1080ZH.big"))); + Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProData1080ZH.big"))); + Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProZH.big"))); + + // Verify that raw source folder was cleaned up + Assert.False(Directory.Exists(Path.Combine(_testDir, "ZH"))); + } + + /// + /// Verifies that flat prebuilt BIG files are identified and retained along with metadata BIG. + /// + /// A completed task. + [Fact] + public async Task ProcessAndRepackControlBarAsync_WithPrebuiltBigFiles_RetainsMatchingFilesAsync() + { + // Arrange + Directory.CreateDirectory(_testDir); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProArt1080ZH.big"), "BIG content"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProData1080ZH.big"), "BIG content"); + + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"), + Name = "Control Bar Pro Lemon Edition ZH (1080p)", + ContentType = ContentType.Addon, + }; + + // Act + var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest); + + // Assert + Assert.Contains("340_ControlBarProArt1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProData1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProZH.big", outputFiles); + } + + /// + /// Verifies that flat prebuilt Lemon Edition BIG files are identified and retained with existing Lemon Edition metadata. + /// + /// A completed task. + [Fact] + public async Task ProcessAndRepackControlBarAsync_WithLemonEditionPrebuiltBigFiles_RetainsLemonEditionFilesAsync() + { + // Arrange + Directory.CreateDirectory(_testDir); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionArt1080ZH.big"), "BIG art content"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionData1080ZH.big"), "BIG data content"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionZH.big"), "BIG base content"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "339_ControlBarProLemonEditionHideIpZH.big.BAK"), "BAK file"); + await File.WriteAllTextAsync(Path.Combine(_testDir, "ReadMe.txt"), "readme"); + + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"), + Name = "Control Bar Pro Lemon Edition ZH (1080p)", + ContentType = ContentType.Addon, + }; + + // Act + var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest); + + // Assert + Assert.Contains("340_ControlBarProLemonEditionArt1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProLemonEditionData1080ZH.big", outputFiles); + Assert.Contains("340_ControlBarProLemonEditionZH.big", outputFiles); + Assert.DoesNotContain("340_ControlBarProZH.big", outputFiles); + } + + /// + /// Verifies that generic game folders like ZH without Control Bar markers or assets do not trigger Control Bar classification. + /// + [Fact] + public void IsControlBarContent_WithGenericGameDirectoryAndNoMarker_ReturnsFalse() + { + var zhDir = Path.Combine(_testDir, "ZH"); + Directory.CreateDirectory(zhDir); + File.WriteAllText(Path.Combine(zhDir, "mod.big"), "some mod content"); + + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.github.mod.somemod"), + Name = "Regular Mod (ZH)", + ContentType = ContentType.Mod, + }; + + var result = processor.IsControlBarContent(_testDir, manifest); + + Assert.False(result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs index ee1ba5497..897625c84 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs @@ -10,6 +10,7 @@ using GenHub.Core.Models.Content; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.Common; using GenHub.Features.Content.Services.CommunityOutpost; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -326,10 +327,13 @@ private static CommunityOutpostDeliverer CreateDeliverer( IContentManifestPool manifestPool) { var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var controlBarProcessor = new ControlBarPackageProcessor( + converter, + NullLogger.Instance); var manifestFactory = new CommunityOutpostManifestFactory( NullLogger.Instance, new Mock().Object, - converter); + controlBarProcessor); return new CommunityOutpostDeliverer( downloadService, diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs new file mode 100644 index 000000000..01fff88e1 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public sealed class ContentPathPolicyTests : IDisposable +{ + private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "GenHubTests_PathPolicy_" + Guid.NewGuid().ToString("N")); + + /// + /// Initializes a new instance of the class. + /// + public ContentPathPolicyTests() + { + Directory.CreateDirectory(_tempRoot); + } + + /// + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + { + try + { + Directory.Delete(_tempRoot, recursive: true); + } + catch + { + // Best effort cleanup + } + } + } + + /// + /// Verifies that valid contained relative paths resolve successfully. + /// + [Fact] + public void ResolveContainedFile_ValidRelativePath_ResolvesCorrectly() + { + var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, "sub/file.txt"); + var expected = Path.GetFullPath(Path.Combine(_tempRoot, "sub", "file.txt")); + Assert.True(result.Success); + Assert.Equal(expected, result.Data); + } + + /// + /// Verifies that directory traversal sequences return a failure result. + /// + /// The traversal path to test. + [Theory] + [InlineData("../outside.txt")] + [InlineData("sub/../../outside.txt")] + [InlineData("..\\outside.txt")] + [InlineData("/etc/passwd")] + [InlineData("C:\\Windows\\System32\\cmd.exe")] + [InlineData("\\\\server\\share\\file.txt")] + public void ResolveContainedFile_PathEscapesRoot_ReturnsFailure(string maliciousPath) + { + var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, maliciousPath); + Assert.False(result.Success); + } + + /// + /// Verifies that null or whitespace inputs return a failure result. + /// + /// The invalid path to test. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveContainedFile_NullOrEmptyRelativePath_ReturnsFailure(string? invalidPath) + { + var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, invalidPath); + Assert.False(result.Success); + } + + /// + /// Verifies that accurately detects containment. + /// + [Fact] + public void IsContained_ValidAndInvalidPaths_ReturnsExpectedBoolean() + { + var inside = Path.Combine(_tempRoot, "nested", "file.dll"); + var outside = Path.Combine(Path.GetTempPath(), "other_dir", "file.dll"); + + Assert.True(ContentPathPolicy.IsContained(_tempRoot, inside)); + Assert.False(ContentPathPolicy.IsContained(_tempRoot, outside)); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs new file mode 100644 index 000000000..77c7973ed --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -0,0 +1,1549 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Utilities; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; +using SharpCompress.Common; + +namespace GenHub.Features.Content.Services.Common; + +/// +/// Service for safely extracting archives and normalizing payload directory structures for game workspaces. +/// +public class ArchivePayloadProcessor(ILogger logger) : IArchivePayloadProcessor +{ + private const int MaxNestedExtractionDepth = 5; + private static readonly byte[] SevenZipSignature = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]; + private static readonly byte[] RarSignature = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]; + private static readonly byte[] SmartInstallMakerSignature = [0x77, 0x77, 0x67, 0x54, 0x29, 0x48, 0x35, 0x14]; + + /// + public Task ExtractArchivesSafelyAsync( + string extractedDirectory, + ContentType? contentType = null, + CancellationToken cancellationToken = default) + { + if (!Directory.Exists(extractedDirectory)) + { + return Task.CompletedTask; + } + + return Task.Run( + () => + { + var depth = 0; + while (depth < MaxNestedExtractionDepth) + { + cancellationToken.ThrowIfCancellationRequested(); + depth++; + + var archiveFiles = FindArchiveFiles(extractedDirectory, contentType); + if (archiveFiles.Count == 0) + { + break; + } + + logger.LogInformation( + "Found {Count} archive(s) to extract in payload directory: {Directory} (pass {Pass})", + archiveFiles.Count, + extractedDirectory, + depth); + + foreach (var archivePath in archiveFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + + EnsureValidArchivePayload(archivePath); + logger.LogInformation("Extracting archive safely: {ArchivePath}", archivePath); + + ExtractSingleArchive(archivePath, extractedDirectory, cancellationToken); + File.Delete(archivePath); + logger.LogInformation("Extracted archive and removed archive source: {ArchivePath}", archivePath); + } + } + + var remainingArchives = FindArchiveFiles(extractedDirectory, contentType); + if (remainingArchives.Count > 0) + { + throw new InvalidDataException( + $"Payload contains nested archives exceeding maximum extraction depth of {MaxNestedExtractionDepth}: {string.Join(", ", remainingArchives.Select(Path.GetFileName))}"); + } + }, + cancellationToken); + } + + /// + public Task NormalizeDirectoryStructureAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default) + { + if (!Directory.Exists(extractedDirectory)) + { + return Task.CompletedTask; + } + + return Task.Run( + () => + { + cancellationToken.ThrowIfCancellationRequested(); + + // 1. Purge system junk files and folders + PurgeSystemJunk(extractedDirectory); + + // 2. Iteratively strip single wrapper directories + StripSingleWrapperDirectories(extractedDirectory, contentType, cancellationToken); + + // 3. Handle game-specific subdirectories (e.g. ZH, Zero Hour, Generals, CCG) + RouteGameSpecificSubdirectories(extractedDirectory, targetGame, cancellationToken); + + // 4. Heuristic root content detection (single mod directory alongside loose documentation files) + ReconcileContentRootWithDocumentation(extractedDirectory, contentType, cancellationToken); + + // 5. Normalize inactive .gib mod archive files to .big + NormalizeGibExtensions(extractedDirectory, contentType); + + // 6. Cleanup empty directories + CleanupEmptyDirectories(extractedDirectory); + }, + cancellationToken); + } + + /// + public async Task ProcessPayloadAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default) + { + await ExtractArchivesSafelyAsync(extractedDirectory, contentType, cancellationToken); + await NormalizeDirectoryStructureAsync(extractedDirectory, contentType, targetGame, cancellationToken); + } + + private static bool ShouldAttemptExecutableExtraction(ContentType? contentType) + { + if (!contentType.HasValue) + { + return false; + } + + return contentType.Value switch + { + ContentType.ModdingTool => false, + ContentType.Executable => false, + ContentType.GameClient => false, + ContentType.GameInstallation => false, + _ => true, + }; + } + + private static bool IsArchiveFile(string filePath, ContentType? contentType = null) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return false; + } + + try + { + var info = new FileInfo(filePath); + if (info.Length == 0) + { + return false; + } + + var extension = Path.GetExtension(filePath); + + if (extension.Equals(".zip", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".7z", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".rar", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".tar", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".gz", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".tgz", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".bz2", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".xz", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (extension.Equals(".dat", StringComparison.OrdinalIgnoreCase)) + { + return ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath); + } + + if (extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + if (!ShouldAttemptExecutableExtraction(contentType)) + { + return false; + } + + return IsSelfExtractingArchive(filePath); + } + + if (string.IsNullOrEmpty(extension)) + { + return ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath); + } + + return false; + } + catch + { + return false; + } + } + + private static bool IsSelfExtractingArchive(string filePath) + { + try + { + using var zipArchive = ZipFile.OpenRead(filePath); + if (zipArchive.Entries.Count > 0) + { + return true; + } + } + catch + { + // Not a zip sfx + } + + try + { + if (ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath)) + { + return true; + } + } + catch + { + // Ignore + } + + try + { + using var stream = File.OpenRead(filePath); + if (FindSignatureOffset(stream, SevenZipSignature) >= 0) + { + return true; + } + + stream.Position = 0; + if (FindSignatureOffset(stream, RarSignature) >= 0) + { + return true; + } + + stream.Position = 0; + if (FindSignatureOffset(stream, SmartInstallMakerSignature) >= 0) + { + return true; + } + } + catch + { + // Ignore + } + + return false; + } + + private static long FindSignatureOffset(Stream stream, byte[] signature) + { + if (signature.Length == 0) + { + return -1; + } + + // Keep the last partial match across chunk boundaries so a signature + // split between two reads is still detected. + var overlap = signature.Length - 1; + var buffer = new byte[IoConstants.SignatureScanBufferSize]; + long streamOffset = 0; + var buffered = 0; + var read = 0; + + while ((read = stream.Read(buffer.AsSpan(buffered))) > 0) + { + var available = buffered + read; + var index = buffer.AsSpan(0, available).IndexOf(signature); + if (index >= 0) + { + return streamOffset + index; + } + + buffered = Math.Min(available, overlap); + buffer.AsSpan(available - buffered, buffered).CopyTo(buffer); + streamOffset += available - buffered; + } + + return -1; + } + + private static IReadOnlyList FindArchiveFiles(string rootDirectory, ContentType? contentType = null) + { + return Directory.GetFiles(rootDirectory, "*", SearchOption.AllDirectories) + .Where(file => IsArchiveFile(file, contentType)) + .ToList(); + } + + private static void EnsureValidArchivePayload(string archivePath) + { + var info = new FileInfo(archivePath); + if (!info.Exists || info.Length == 0) + { + throw new InvalidDataException($"Archive file is missing or empty: {archivePath}"); + } + + Span header = stackalloc byte[16]; + using (var stream = File.OpenRead(archivePath)) + { + var read = stream.Read(header); + if (read == 0) + { + throw new InvalidDataException($"Archive file is empty: {archivePath}"); + } + + header = header[..read]; + } + + if (LooksLikeHtml(header)) + { + var preview = ReadTextPreview(archivePath, maxChars: 120); + throw new InvalidDataException( + $"Downloaded file is HTML, not an archive (likely a broken download URL or HTTP error page): {archivePath}. Preview: {preview}"); + } + } + + private static bool LooksLikeHtml(ReadOnlySpan header) + { + if (header.Length >= 3 && header[0] == 0xEF && header[1] == 0xBB && header[2] == 0xBF) + { + header = header[3..]; + } + + while (header.Length > 0 && (header[0] == (byte)' ' || header[0] == (byte)'\t' || header[0] == (byte)'\r' || header[0] == (byte)'\n')) + { + header = header[1..]; + } + + if (header.Length < 5) + { + return false; + } + + Span ascii = stackalloc char[Math.Min(header.Length, 9)]; + for (var i = 0; i < ascii.Length; i++) + { + ascii[i] = (char)header[i]; + } + + ReadOnlySpan prefix = ascii; + return prefix.StartsWith(" !e.IsDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(entry.Key)) + { + continue; + } + + entryCount++; + if (entryCount > CatalogConstants.MaxZipEntryCount) + { + throw new InvalidDataException( + $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); + } + + if (Path.IsPathRooted(entry.Key)) + { + throw new InvalidDataException($"Archive entry has an unsafe path: {entry.Key}"); + } + + var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, entry.Key); + if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data)) + { + throw new InvalidDataException($"Archive entry has an unsafe path: {entry.Key}"); + } + + var destinationPath = pathResult.Data; + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + using var entryStream = entry.OpenEntryStream(); + CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); + } + } + + private static void CopyEntryWithCap( + Stream source, + string destinationPath, + ref long totalBytesWritten, + CancellationToken cancellationToken) + { + using var dest = File.Create(destinationPath); + var buffer = new byte[81920]; + var read = 0; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + totalBytesWritten += read; + if (totalBytesWritten > CatalogConstants.MaxZipUncompressedSizeBytes) + { + throw new InvalidDataException( + $"Archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); + } + + dest.Write(buffer, 0, read); + } + } + + private static bool TryExtractZipArchive( + string archivePath, + string extractPath, + CancellationToken cancellationToken) + { + if (!ZipValidation.IsValidZipFile(archivePath)) + { + return false; + } + + try + { + using var zip = ZipFile.OpenRead(archivePath); + if (zip.Entries.Count == 0) + { + return false; + } + + var entryCount = 0; + long totalUncompressedSize = 0; + var extractRoot = Path.GetFullPath(extractPath); + + foreach (var entry in zip.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(entry.FullName) || entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\')) + { + continue; + } + + entryCount++; + if (entryCount > CatalogConstants.MaxZipEntryCount) + { + throw new InvalidDataException( + $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); + } + + ExtractSingleZipEntry(entry, extractRoot, ref totalUncompressedSize, cancellationToken); + } + + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidDataException) + { + throw; + } + catch + { + return false; + } + } + + private static void ExtractSingleZipEntry( + ZipArchiveEntry entry, + string extractRoot, + ref long totalUncompressedSize, + CancellationToken cancellationToken) + { + if (Path.IsPathRooted(entry.FullName)) + { + throw new InvalidDataException($"Archive entry has an unsafe path: {entry.FullName}"); + } + + var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, entry.FullName); + if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data)) + { + throw new InvalidDataException($"Archive entry has an unsafe path: {entry.FullName}"); + } + + var destinationPath = pathResult.Data; + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + using var entryStream = entry.Open(); + CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); + } + + private static bool TryExtractSubStreamArchive( + string archivePath, + string extractPath, + CancellationToken cancellationToken) + { + try + { + using var stream = File.OpenRead(archivePath); + var offset = FindSignatureOffset(stream, SevenZipSignature); + if (offset < 0) + { + stream.Position = 0; + offset = FindSignatureOffset(stream, RarSignature); + } + + if (offset < 0) + { + return false; + } + + stream.Position = offset; + using var subStream = new SubStream(stream, offset, stream.Length - offset); + using var archive = ArchiveFactory.OpenArchive(subStream); + ExtractSharpCompressArchive(archive, extractPath, cancellationToken); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidDataException) + { + throw; + } + catch + { + return false; + } + } + + private static bool TryExtractSmartInstallMakerArchive( + string archivePath, + string extractPath, + CancellationToken cancellationToken) + { + var stagingDir = Path.Combine(extractPath, "_sim_staging_" + Guid.NewGuid().ToString("N")); + try + { + using var stream = File.OpenRead(archivePath); + var sigOffset = FindSignatureOffset(stream, SmartInstallMakerSignature); + if (sigOffset < 0) + { + return false; + } + + stream.Position = sigOffset + SmartInstallMakerSignature.Length; + var (fileTableData, payloadOffset) = ReadSmartInstallMakerMetadata(stream); + if (fileTableData == null || fileTableData.Length == 0 || payloadOffset < 0) + { + return false; + } + + var records = ParseSmartInstallMakerFileTable(fileTableData, stream, payloadOffset); + if (records.Count == 0) + { + return false; + } + + Directory.CreateDirectory(stagingDir); + var stagingRoot = Path.GetFullPath(stagingDir); + var extractedCount = ExtractSmartInstallMakerPayload(stream, payloadOffset, records, stagingRoot, cancellationToken); + if (extractedCount != records.Count) + { + throw new InvalidDataException( + $"Smart Install Maker extraction incomplete: extracted {extractedCount} of {records.Count} entries."); + } + + PromoteDirectoryContents(stagingDir, extractPath); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidDataException) + { + throw; + } + catch + { + return false; + } + finally + { + try + { + if (Directory.Exists(stagingDir)) + { + Directory.Delete(stagingDir, recursive: true); + } + } + catch + { + // Best effort cleanup + } + } + } + + private static (byte[]? TableData, long PayloadOffset) ReadSmartInstallMakerMetadata(Stream stream) + { + var (secondToLastBlock, lastBlock) = WalkSmartInstallMakerBlocks(stream); + if (secondToLastBlock == null || lastBlock == null) + { + return (null, -1); + } + + var payloadOffset = lastBlock.Value.DataStart; + var tableBlock = secondToLastBlock.Value; + if (tableBlock.CompType == 1) + { + var tableData = DecompressSimTableBlock(stream, tableBlock.DataStart); + return (tableData, payloadOffset); + } + + return (null, payloadOffset); + } + + private static ((long Pos, int CompSize, byte CompType, long DataStart)? SecondToLast, (long Pos, int CompSize, byte CompType, long DataStart)? Last) WalkSmartInstallMakerBlocks(Stream stream) + { + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); + (long Pos, int CompSize, byte CompType, long DataStart)? secondToLastBlock = null; + (long Pos, int CompSize, byte CompType, long DataStart)? lastBlock = null; + var blockCount = 0; + const int MaxBlockWalkCount = 100_000; + + while (stream.Position < stream.Length - 13 && blockCount < MaxBlockWalkCount) + { + var pos = stream.Position; + _ = blockCount == 0 ? reader.ReadInt16() : reader.ReadInt32(); + var compSize = reader.ReadInt32(); + _ = reader.ReadInt32(); + var compType = reader.ReadByte(); + var dataLength = compSize - 5; + var dataStart = stream.Position; + + secondToLastBlock = lastBlock; + lastBlock = (pos, compSize, compType, dataStart); + blockCount++; + + if (dataLength > 0 && stream.Position + dataLength <= stream.Length) + { + stream.Position += dataLength; + } + else + { + break; + } + } + + return (secondToLastBlock, lastBlock); + } + + private static byte[] DecompressSimTableBlock(Stream stream, long dataStart) + { + stream.Position = dataStart + 2; // skip zlib 78-DA header + using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); + using var ms = new MemoryStream(); + var buf = new byte[8192]; + var r = 0; + var totalDecompressed = 0L; + while ((r = def.Read(buf, 0, buf.Length)) > 0) + { + totalDecompressed += r; + if (totalDecompressed > CatalogConstants.MaxCatalogSizeBytes) + { + throw new InvalidDataException("Smart Install Maker metadata table exceeds maximum allowed size."); + } + + ms.Write(buf, 0, r); + } + + return ms.ToArray(); + } + + private static int ExtractSmartInstallMakerPayload( + Stream stream, + long payloadOffset, + List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records, + string extractRoot, + CancellationToken cancellationToken) + { + var extractedCount = 0; + var copyBuffer = new byte[65536]; + + foreach (var rec in records) + { + cancellationToken.ThrowIfCancellationRequested(); + ExtractSingleSmartInstallMakerRecord(stream, payloadOffset, rec, extractRoot, copyBuffer); + extractedCount++; + } + + return extractedCount; + } + + private static void ExtractSingleSmartInstallMakerRecord( + Stream stream, + long payloadOffset, + (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) rec, + string extractRoot, + byte[] copyBuffer) + { + var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, rec.Name); + if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data)) + { + throw new InvalidDataException($"Smart Install Maker entry has an unsafe path: {rec.Name}"); + } + + var destinationPath = pathResult.Data; + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + var filePos = payloadOffset + rec.StreamOffset; + if (filePos < 0 || filePos + rec.CompressedSize > stream.Length) + { + throw new InvalidDataException($"Smart Install Maker entry '{rec.Name}' compressed range exceeds stream bounds."); + } + + stream.Position = filePos; + var header = new byte[2]; + var headerRead = stream.Read(header, 0, 2); + stream.Position = filePos; + + var written = TryDecompressSmartInstallMakerRecord(stream, filePos, header, headerRead, destinationPath, rec.UncompressedSize, copyBuffer); + + if (written != rec.UncompressedSize && filePos + rec.UncompressedSize <= stream.Length) + { + // Fallback to raw copy if sniffed decompressor failed but raw payload is available + stream.Position = filePos; + using var outStream = File.Create(destinationPath); + written = 0; + while (written < rec.UncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, rec.UncompressedSize - written); + var readBytes = stream.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + } + + if (written != rec.UncompressedSize) + { + throw new InvalidDataException( + $"Smart Install Maker entry '{rec.Name}' decompressed size mismatch: expected {rec.UncompressedSize} bytes, got {written} bytes."); + } + } + + private static long TryDecompressSmartInstallMakerRecord( + Stream stream, + long filePos, + byte[] header, + int headerRead, + string destinationPath, + uint uncompressedSize, + byte[] copyBuffer) + { + if (headerRead >= 2 && header[0] == 'B' && header[1] == 'Z') + { + return DecompressBz2SmartInstallMakerRecord(stream, destinationPath, uncompressedSize, copyBuffer); + } + + if (headerRead >= 2 && header[0] == 0x78 && (header[1] == 0xDA || header[1] == 0x9C || header[1] == 0x01 || header[1] == 0x5E)) + { + return DecompressDeflateSmartInstallMakerRecord(stream, filePos, destinationPath, uncompressedSize, copyBuffer); + } + + return DecompressRawSmartInstallMakerRecord(stream, destinationPath, uncompressedSize, copyBuffer); + } + + private static long DecompressBz2SmartInstallMakerRecord(Stream stream, string destinationPath, uint uncompressedSize, byte[] copyBuffer) + { + using var bz2 = SharpCompress.Compressors.BZip2.BZip2Stream.Create( + stream, + SharpCompress.Compressors.CompressionMode.Decompress, + decompressConcatenated: false, + leaveOpen: true); + + using var outStream = File.Create(destinationPath); + long written = 0; + while (written < uncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = bz2.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + + return written; + } + + private static long DecompressDeflateSmartInstallMakerRecord(Stream stream, long filePos, string destinationPath, uint uncompressedSize, byte[] copyBuffer) + { + stream.Position = filePos + 2; // skip zlib header + using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); + using var outStream = File.Create(destinationPath); + long written = 0; + while (written < uncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = def.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + + return written; + } + + private static long DecompressRawSmartInstallMakerRecord(Stream stream, string destinationPath, uint uncompressedSize, byte[] copyBuffer) + { + using var outStream = File.Create(destinationPath); + long written = 0; + while (written < uncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = stream.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + + return written; + } + + private static List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> ParseSmartInstallMakerFileTable( + byte[] tableData, + Stream stream, + long payloadOffset) + { + var records = new List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)>(); + var cumulativeUncompressedSize = 0L; + var index = 0; + + while (index < tableData.Length - 4) + { + index = ProcessNextSimCandidate(tableData, index, stream, payloadOffset, records, ref cumulativeUncompressedSize); + } + + return records; + } + + private static int ProcessNextSimCandidate( + byte[] tableData, + int index, + Stream stream, + long payloadOffset, + List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records, + ref long cumulativeUncompressedSize) + { + if (tableData[index] != '.' || index < 40) + { + return index + 1; + } + + if (!TryExtractSimCandidateName(tableData, index, out var name, out var nextIndex, out var startOffset)) + { + return index + 1; + } + + if (IsValidSimEntryName(name) && + TryReadSimRecord(tableData, startOffset, name, stream, payloadOffset, records, out var record)) + { + ValidateAndAddSimRecord(record, records, ref cumulativeUncompressedSize); + } + + return nextIndex; + } + + private static void ValidateAndAddSimRecord( + (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) record, + List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records, + ref long cumulativeUncompressedSize) + { + if (records.Count >= CatalogConstants.MaxZipEntryCount) + { + throw new InvalidDataException( + $"Smart Install Maker archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); + } + + cumulativeUncompressedSize += record.UncompressedSize; + if (cumulativeUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) + { + throw new InvalidDataException( + $"Smart Install Maker archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); + } + + records.Add(record); + } + + private static bool TryExtractSimCandidateName( + byte[] tableData, + int dotIndex, + out string name, + out int nextIndex, + out int startOffset) + { + var start = dotIndex; + while (start > 0 && tableData[start - 1] != 0 && tableData[start - 1] >= 32 && tableData[start - 1] <= 126) + { + start--; + } + + var end = dotIndex; + while (end < tableData.Length && tableData[end] != 0 && tableData[end] >= 32 && tableData[end] <= 126) + { + end++; + } + + startOffset = start; + nextIndex = end; + + if (start < 40 || end - start <= 3) + { + name = string.Empty; + return false; + } + + name = Encoding.Latin1.GetString(tableData, start, end - start); + return true; + } + + private static bool IsValidSimEntryName(string name) + { + if (!name.Contains('.') || name.StartsWith(' ') || name.Length <= 3 || name.Contains("..")) + { + return false; + } + + if (name.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("Intrnl.exe", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var invalidChars = Path.GetInvalidPathChars().Concat([':', '"', '<', '>', '|', '*', '?']).ToArray(); + if (name.IndexOfAny(invalidChars) >= 0) + { + return false; + } + + var ext = Path.GetExtension(name); + return !string.IsNullOrEmpty(ext) && ext.Length <= 5; + } + + private static bool TryReadSimRecord( + byte[] tableData, + int startOffset, + string name, + Stream stream, + long payloadOffset, + List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> existingRecords, + out (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) record) + { + record = default; + var uncompSize = BitConverter.ToUInt32(tableData, startOffset - 40); + var streamOffset = BitConverter.ToUInt32(tableData, startOffset - 36); + var compSize = BitConverter.ToUInt32(tableData, startOffset - 32); + + if (uncompSize == 0 || compSize == 0) + { + return false; + } + + if ((ulong)uncompSize > (ulong)CatalogConstants.MaxZipUncompressedSizeBytes || + payloadOffset + streamOffset + compSize > stream.Length) + { + return false; + } + + if (existingRecords.Exists(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + record = (name, uncompSize, streamOffset, compSize); + return true; + } + + private static void PurgeSystemJunk(string directory) + { + try + { + if (!Directory.Exists(directory)) + { + return; + } + + foreach (var subDir in Directory.GetDirectories(directory, "*", SearchOption.AllDirectories)) + { + if (!Directory.Exists(subDir)) + { + continue; + } + + var dirName = Path.GetFileName(subDir); + if (GameContentConstants.SystemJunkNames.Contains(dirName, StringComparer.OrdinalIgnoreCase)) + { + Directory.Delete(subDir, recursive: true); + } + } + + foreach (var file in Directory.GetFiles(directory, "*", SearchOption.AllDirectories)) + { + if (!File.Exists(file)) + { + continue; + } + + var fileName = Path.GetFileName(file); + if (GameContentConstants.SystemJunkNames.Contains(fileName, StringComparer.OrdinalIgnoreCase)) + { + File.Delete(file); + } + } + } + catch + { + // Ignore system junk removal failures + } + } + + private static bool ContainsRecognizedGameContent(string directory) + { + var subDirs = Directory.GetDirectories(directory, "*", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrEmpty(name)); + + if (subDirs.Any(name => GameContentConstants.RecognizedGameDirectories.Contains(name, StringComparer.OrdinalIgnoreCase))) + { + return true; + } + + var files = Directory.GetFiles(directory, "*", SearchOption.TopDirectoryOnly) + .Select(Path.GetExtension) + .Where(ext => !string.IsNullOrEmpty(ext)); + + return files.Any(ext => GameContentConstants.RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase)); + } + + private static bool DirectoryContainsMapFilesDirectly(string directory) + { + return Directory.GetFiles(directory, "*.map", SearchOption.TopDirectoryOnly).Length > 0; + } + + private static void PromoteDirectoryContents(string sourceDirectory, string targetDirectory) + { + if (!Directory.Exists(sourceDirectory) || + string.Equals(Path.GetFullPath(sourceDirectory), Path.GetFullPath(targetDirectory), StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var tempStaging = Path.Combine(Path.GetDirectoryName(sourceDirectory) ?? targetDirectory, Path.GetFileName(sourceDirectory)) + + "_staging_" + Guid.NewGuid().ToString("N"); + + try + { + Directory.Move(sourceDirectory, tempStaging); + + foreach (var subFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories)) + { + PromoteSingleStagedFile(subFile, tempStaging, targetDirectory); + } + } + catch + { + RollbackStaging(tempStaging, sourceDirectory); + throw; + } + finally + { + CleanupStaging(tempStaging); + } + } + + private static void PromoteSingleStagedFile(string subFile, string tempStaging, string targetDirectory) + { + var relativePath = Path.GetRelativePath(tempStaging, subFile); + var destinationPath = Path.Combine(targetDirectory, relativePath); + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + if (File.Exists(destinationPath)) + { + var destInfo = new FileInfo(destinationPath); + var srcInfo = new FileInfo(subFile); + + if (destInfo.Length == srcInfo.Length && FilesHaveIdenticalContent(subFile, destinationPath)) + { + File.Delete(subFile); + return; + } + + var newDestPath = GetNonCollidingDestinationPath(destinationPath); + File.Move(subFile, newDestPath); + } + else + { + File.Move(subFile, destinationPath); + } + } + + private static void RollbackStaging(string tempStaging, string sourceDirectory) + { + try + { + if (!Directory.Exists(tempStaging)) + { + return; + } + + if (!Directory.Exists(sourceDirectory)) + { + Directory.Move(tempStaging, sourceDirectory); + return; + } + + foreach (var remainingFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(tempStaging, remainingFile); + var backPath = Path.Combine(sourceDirectory, rel); + var dir = Path.GetDirectoryName(backPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.Move(remainingFile, backPath, overwrite: true); + } + } + catch + { + // Best effort rollback + } + } + + private static void CleanupStaging(string tempStaging) + { + if (Directory.Exists(tempStaging)) + { + try + { + Directory.Delete(tempStaging, recursive: true); + } + catch + { + // Best effort cleanup + } + } + } + + private static string GetNonCollidingDestinationPath(string destinationPath) + { + var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty; + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(destinationPath); + var ext = Path.GetExtension(destinationPath); + var counter = 1; + var newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); + while (File.Exists(newDestPath)) + { + counter++; + newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); + } + + return newDestPath; + } + + private static bool FilesHaveIdenticalContent(string file1, string file2) + { + const int bufferSize = 65536; + var buffer1 = new byte[bufferSize]; + var buffer2 = new byte[bufferSize]; + + using var s1 = File.OpenRead(file1); + using var s2 = File.OpenRead(file2); + + if (s1.Length != s2.Length) + { + return false; + } + + while (true) + { + var bytesRead1 = s1.Read(buffer1, 0, bufferSize); + if (bytesRead1 <= 0) + { + break; + } + + var bytesRead2 = s2.Read(buffer2, 0, bufferSize); + if (bytesRead1 != bytesRead2) + { + return false; + } + + if (!buffer1.AsSpan(0, bytesRead1).SequenceEqual(buffer2.AsSpan(0, bytesRead2))) + { + return false; + } + } + + return true; + } + + private static void CleanupEmptyDirectories(string rootDirectory) + { + try + { + foreach (var subDir in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories) + .Where(d => Directory.Exists(d) && !Directory.EnumerateFileSystemEntries(d).Any()) + .OrderByDescending(d => d.Length)) + { + Directory.Delete(subDir); + } + } + catch + { + // Ignore directory cleanup exceptions + } + } + + private void StripSingleWrapperDirectories( + string extractedDirectory, + ContentType contentType, + CancellationToken cancellationToken) + { + var depth = 0; + while (depth < GameContentConstants.MaxWrapperNormalizationDepth) + { + cancellationToken.ThrowIfCancellationRequested(); + depth++; + + var rootFiles = Directory.GetFiles(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + + if (rootFiles.Length != 0 || rootDirs.Length != 1) + { + break; + } + + var singleDir = rootDirs[0]; + var dirName = Path.GetFileName(singleDir); + + // For map content, if the single directory contains .map files directly, preserve this directory + if (contentType is ContentType.Map or ContentType.MapPack && DirectoryContainsMapFilesDirectly(singleDir)) + { + logger.LogInformation("Preserving map folder structure for: {MapDir}", singleDir); + break; + } + + // If the single directory is a canonical game directory (e.g. Data, Art, Window, Maps, Audio), + // it is already at the game root level (e.g. /Data/INI/...) and should NOT be flattened. + if (GameContentConstants.IsRecognizedGameDirectory(dirName)) + { + logger.LogInformation("Preserving canonical game root directory: {SingleDir}", singleDir); + break; + } + + logger.LogInformation("Flattening single wrapper directory: {SingleDir} into {Root}", singleDir, extractedDirectory); + PromoteDirectoryContents(singleDir, extractedDirectory); + } + } + + private void RouteGameSpecificSubdirectories( + string extractedDirectory, + GameType targetGame, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + if (rootDirs.Length == 0) + { + return; + } + + var matchingAliases = targetGame switch + { + GameType.ZeroHour => GameContentConstants.ZeroHourSubfolderAliases, + GameType.Generals => GameContentConstants.GeneralsSubfolderAliases, + _ => null, + }; + + if (matchingAliases == null) + { + return; + } + + foreach (var subDir in rootDirs) + { + var dirName = Path.GetFileName(subDir); + if (matchingAliases.Contains(dirName, StringComparer.OrdinalIgnoreCase)) + { + logger.LogInformation( + "Detected matching game-specific subdirectory '{DirName}' for game {Game}. Promoting contents to root.", + dirName, + targetGame); + + PromoteDirectoryContents(subDir, extractedDirectory); + } + } + } + + private void ReconcileContentRootWithDocumentation( + string extractedDirectory, + ContentType contentType, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (contentType is ContentType.Map or ContentType.MapPack) + { + return; + } + + var rootFiles = Directory.GetFiles(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly); + + if (rootDirs.Length != 1) + { + return; + } + + var singleDir = rootDirs[0]; + var dirName = Path.GetFileName(singleDir); + + // If the single directory is already a canonical game directory (e.g. Data), it should remain as is + if (GameContentConstants.IsRecognizedGameDirectory(dirName)) + { + return; + } + + // Check if all files at the root level are loose documentation/metadata files + var allRootFilesAreDocs = rootFiles.All(file => + { + var ext = Path.GetExtension(file); + return GameContentConstants.DocumentationExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + }); + + if (!allRootFilesAreDocs) + { + return; + } + + // Check if the single directory contains recognizable game root folders or files + if (ContainsRecognizedGameContent(singleDir)) + { + logger.LogInformation( + "Promoting game content root from wrapper '{SingleDir}' to payload root alongside documentation", + singleDir); + + PromoteDirectoryContents(singleDir, extractedDirectory); + } + } + + private void NormalizeGibExtensions(string extractedDirectory, ContentType contentType) + { + if (contentType is ContentType.ModdingTool or ContentType.Executable or ContentType.GameClient or ContentType.GameInstallation) + { + return; + } + + try + { + foreach (var gibFile in Directory.GetFiles(extractedDirectory, "*.gib", SearchOption.AllDirectories)) + { + var bigFile = Path.ChangeExtension(gibFile, ".big"); + if (File.Exists(bigFile)) + { + if (FilesHaveIdenticalContent(gibFile, bigFile)) + { + File.Delete(gibFile); + logger.LogInformation("Removed duplicate identical inactive file '{GibFile}' as '{BigFile}' already exists", gibFile, bigFile); + } + else + { + var nonCollidingBigPath = GetNonCollidingDestinationPath(bigFile); + File.Move(gibFile, nonCollidingBigPath); + logger.LogInformation("Preserved differing inactive file '{GibFile}' by renaming to '{NewBigFile}'", gibFile, nonCollidingBigPath); + } + } + else + { + File.Move(gibFile, bigFile); + logger.LogInformation("Normalized inactive mod archive '{GibFile}' to '{BigFile}'", gibFile, bigFile); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to normalize .gib file extensions in: {Directory}", extractedDirectory); + } + } + + private sealed class SubStream(Stream baseStream, long streamOffset, long length) : Stream + { + private long _position; + + public override bool CanRead => baseStream.CanRead; + + public override bool CanSeek => baseStream.CanSeek; + + public override bool CanWrite => false; + + public override long Length => length; + + public override long Position + { + get => _position; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, length); + _position = value; + } + } + + public override void Flush() => baseStream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= length) + { + return 0; + } + + var toRead = (int)Math.Min(count, length - _position); + baseStream.Position = streamOffset + _position; + var read = baseStream.Read(buffer, offset, toRead); + _position += read; + return read; + } + + public override long Seek(long offset, SeekOrigin origin) + { + var target = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => length + offset, + _ => throw new ArgumentOutOfRangeException(nameof(origin)), + }; + Position = target; + return _position; + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs new file mode 100644 index 000000000..68fc6559a --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs @@ -0,0 +1,595 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services.Common; + +/// +/// Service for detecting, isolating, converting, and packaging Control Bar content into SAGE-compatible .big archives. +/// +public class ControlBarPackageProcessor( + CompressedImageToTgaConverter avifConverter, + ILogger logger) : IControlBarPackageProcessor +{ + private const string ControlBarMetadataBigBase64 = "QklHRngBAAAAAAACAAAAUwAAAFMAAAEkQ29udHJvbEJhclByby50eHQAAAABdwAAAAFHZW5Ub29sXGZ1bGx2aWV3cG9ydC5kYXQAAAAAAAAAAABDb250cm9sIEJhciBQcm8gZm9yIENPTU1BTkQgQU5EIENPTlFVRVIgR0VORVJBTFM6IFpFUk8gSE9VUg0KDQpBVVRIT1I6DQpFQSBHYW1lcywgRkFTLCB4ZXpvbg0KDQpPUklHSU5BTCBET1dOTE9BRCBVUkw6DQpodHRwOi8vZ2VudG9vbC5uZXQvZG93bmxvYWQvY29udHJvbGJhcnBybw0KDQpTT1VSQ0UgQ09ERSAmIEFTU0VUUzoNCmh0dHBzOi8vZ2l0aHViLmNvbS9UaGVTdXBlckhhY2tlcnMvR2VuZXJhbHNDb250cm9sQmFyDQoNCkRPTkFUSU9OIExJTks6DQpodHRwczovL3d3dy5wYXlwYWwubWUvZ2VudG9vbA0KMQ=="; + + private static readonly string[] KnownResolutionVariants = ["720p", "900p", "1080p", "1440p", "4k", "2160p"]; + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1); + private static readonly Regex WordVariantRegex = new(@"\b(720p?|900p?|1080p?|1440p?|2160p?|4k)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexMatchTimeout); + private static readonly Regex InlineVariantRegex = new(@"(720p|900p|1080p|1440p|2160p|4k)", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexMatchTimeout); + + /// + public bool IsControlBarContent(string extractedDirectory, ContentManifest manifest) + { + return HasControlBarManifestMetadata(manifest) || HasControlBarFiles(extractedDirectory); + } + + /// + public async Task> ProcessAndRepackControlBarAsync( + string extractedDirectory, + ContentManifest manifest, + string? requestedVariant = null, + CancellationToken cancellationToken = default) + { + logger.LogInformation( + "Processing Control Bar packaging in {Directory} for manifest {ManifestId}", + extractedDirectory, + manifest.Id); + + var variantId = DetermineVariantId(extractedDirectory, manifest, requestedVariant); + var variantSuffix = GetControlBarVariantSuffix(variantId); + var repackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase); + + var variantBigRoot = FindControlBarVariantBigRoot(extractedDirectory, variantId); + if (!string.IsNullOrEmpty(variantBigRoot)) + { + await ProcessVariantBigRootAsync(variantBigRoot, extractedDirectory, variantId, variantSuffix, repackedOutputs, cancellationToken); + } + else + { + CollectFlatPrebuiltBigs(extractedDirectory, variantSuffix, repackedOutputs); + } + + await EnsureMetadataBigIncludedAsync(extractedDirectory, variantId, repackedOutputs, cancellationToken); + CleanupSourceDirectories(extractedDirectory, repackedOutputs); + + return [.. repackedOutputs]; + } + + /// + public string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId) + { + var rawSuffix = GetControlBarVariantSuffix(variantId); + var candidates = new[] + { + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, "ZH", variantId), + Path.Combine(extractedDirectory, "ZH", rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "ZH", rawSuffix, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, "ZH", rawSuffix), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, "CCG", variantId), + Path.Combine(extractedDirectory, "CCG", rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "CCG", rawSuffix, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, "CCG", rawSuffix), + Path.Combine(extractedDirectory, variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, variantId, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, variantId), + Path.Combine(extractedDirectory, rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, rawSuffix, GameContentConstants.BigDirectoryName), + Path.Combine(extractedDirectory, rawSuffix), + }; + + var existingCandidate = candidates.FirstOrDefault(Directory.Exists); + if (existingCandidate != null) + { + return existingCandidate; + } + + if (Directory.Exists(Path.Combine(extractedDirectory, GameContentConstants.WindowDirectoryName)) || + Directory.Exists(Path.Combine(extractedDirectory, "Art")) || + Directory.Exists(Path.Combine(extractedDirectory, "Data")) || + Directory.Exists(Path.Combine(extractedDirectory, GameContentConstants.GenToolDirectoryName))) + { + return extractedDirectory; + } + + return null; + } + + /// + public string GetControlBarVariantSuffix(string variantId) + { + if (variantId.EndsWith("p", StringComparison.OrdinalIgnoreCase)) + { + return variantId[..^1]; + } + + if (variantId.Equals("4k", StringComparison.OrdinalIgnoreCase)) + { + return "4K"; + } + + return variantId; + } + + /// + public bool IsAllowedControlBarBig(string fileName, string variantSuffix) + { + return fileName.Equals($"340_ControlBarProArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarPro{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarPro-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProLemonEditionArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProLemonEditionData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProLemonEdition{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals($"340_ControlBarProLemonEdition-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase) + || fileName.Equals("400_ControlBarHDEnglishZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("400_ControlBarProCoreZH.big", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("400_ControlBarHDBaseZH.big", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasControlBarManifestMetadata(ContentManifest manifest) + { + if (manifest.ContentType is not (ContentType.Addon or ContentType.Mod)) + { + return false; + } + + var id = manifest.Id.Value; + if (id.Contains("controlbar", StringComparison.OrdinalIgnoreCase) || + id.Contains("cbpr", StringComparison.OrdinalIgnoreCase) || + id.Contains("cbpx", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var name = manifest.Name; + if (name.Contains("controlbar", StringComparison.OrdinalIgnoreCase) || + name.Contains("control bar", StringComparison.OrdinalIgnoreCase) || + name.Contains("control-bar", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return manifest.Metadata?.Tags != null && + manifest.Metadata.Tags.Any(t => + t.Contains("controlbar", StringComparison.OrdinalIgnoreCase) || + t.Contains("control-bar", StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasControlBarFiles(string extractedDirectory) + { + if (!Directory.Exists(extractedDirectory)) + { + return false; + } + + return Directory.EnumerateFiles(extractedDirectory, "*ControlBar*.big", SearchOption.AllDirectories).Any() || + Directory.EnumerateFiles(extractedDirectory, "*ControlBar*.wnd", SearchOption.AllDirectories).Any(); + } + + private async Task ProcessVariantBigRootAsync( + string variantBigRoot, + string extractedDirectory, + string variantId, + string variantSuffix, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + var prebuiltBigs = Directory.GetFiles(variantBigRoot, "*.big", SearchOption.TopDirectoryOnly) + .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix)) + .ToArray(); + + if (prebuiltBigs.Length > 0) + { + await CopyPrebuiltBigsAsync(prebuiltBigs, extractedDirectory, repackedOutputs); + } + else + { + await RepackArtAndDataBigsAsync(variantBigRoot, extractedDirectory, variantId, variantSuffix, repackedOutputs, cancellationToken); + } + } + + private async Task CopyPrebuiltBigsAsync( + IReadOnlyList prebuiltBigs, + string extractedDirectory, + HashSet repackedOutputs) + { + logger.LogInformation("Using prebuilt Control Bar BIG files"); + foreach (var prebuiltBig in prebuiltBigs) + { + var bigName = Path.GetFileName(prebuiltBig); + var targetPath = Path.Combine(extractedDirectory, bigName); + + if (!string.Equals(Path.GetFullPath(prebuiltBig), Path.GetFullPath(targetPath), StringComparison.OrdinalIgnoreCase)) + { + await TryCopyFileWithRetryAsync(prebuiltBig, targetPath, logger); + } + + repackedOutputs.Add(bigName); + } + } + + private async Task RepackArtAndDataBigsAsync( + string variantBigRoot, + string extractedDirectory, + string variantId, + string variantSuffix, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + var artBigName = $"340_ControlBarProArt{variantSuffix}ZH.big"; + var dataBigName = $"340_ControlBarProData{variantSuffix}ZH.big"; + + var artBigPath = Path.Combine(extractedDirectory, artBigName); + var dataBigPath = Path.Combine(extractedDirectory, dataBigName); + + if (!File.Exists(artBigPath) || !File.Exists(dataBigPath)) + { + await BuildAndPackArtAndDataBigsAsync(variantBigRoot, extractedDirectory, variantId, artBigPath, dataBigPath, cancellationToken); + } + + if (File.Exists(artBigPath)) + { + repackedOutputs.Add(artBigName); + } + + if (File.Exists(dataBigPath)) + { + repackedOutputs.Add(dataBigName); + } + } + + private async Task BuildAndPackArtAndDataBigsAsync( + string variantBigRoot, + string extractedDirectory, + string variantId, + string artBigPath, + string dataBigPath, + CancellationToken cancellationToken) + { + logger.LogInformation("Repacking Control Bar variant {Variant} into Art/Data BIG files", variantId); + + var tempRoot = Path.Combine(extractedDirectory, $"cbpro-pack-{variantId}"); + var artPackRoot = Path.Combine(tempRoot, "ArtPack"); + var dataPackRoot = Path.Combine(tempRoot, "DataPack"); + + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + + Directory.CreateDirectory(artPackRoot); + Directory.CreateDirectory(dataPackRoot); + + CopySourceDirectoriesToPacks(variantBigRoot, artPackRoot, dataPackRoot); + + try + { + // Convert AVIF/WebP images to TGA prior to packing + await avifConverter.ConvertDirectoryAsync(artPackRoot, cancellationToken); + await avifConverter.ConvertDirectoryAsync(dataPackRoot, cancellationToken); + + var tempArtBig = Path.Combine(tempRoot, "temp_art.big"); + var tempDataBig = Path.Combine(tempRoot, "temp_data.big"); + + await BigFilePacker.PackAsync(artPackRoot, tempArtBig); + await BigFilePacker.PackAsync(dataPackRoot, tempDataBig); + + File.Move(tempArtBig, artBigPath, overwrite: true); + File.Move(tempDataBig, dataBigPath, overwrite: true); + } + finally + { + try + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to cleanup temporary pack directory {TempRoot}", tempRoot); + } + } + } + + private static void CopySourceDirectoriesToPacks(string variantBigRoot, string artPackRoot, string dataPackRoot) + { + var artSource = Path.Combine(variantBigRoot, "Art"); + var dataSource = Path.Combine(variantBigRoot, "Data"); + var windowSource = Path.Combine(variantBigRoot, GameContentConstants.WindowDirectoryName); + var genToolSource = Path.Combine(variantBigRoot, GameContentConstants.GenToolDirectoryName); + + if (Directory.Exists(artSource)) + { + CopyDirectory(artSource, Path.Combine(artPackRoot, "Art")); + } + + if (Directory.Exists(dataSource)) + { + CopyDirectory(dataSource, Path.Combine(dataPackRoot, "Data")); + } + + if (Directory.Exists(windowSource)) + { + CopyDirectory(windowSource, Path.Combine(dataPackRoot, GameContentConstants.WindowDirectoryName)); + } + + if (Directory.Exists(genToolSource)) + { + CopyDirectory(genToolSource, Path.Combine(dataPackRoot, GameContentConstants.GenToolDirectoryName)); + } + } + + private void CollectFlatPrebuiltBigs( + string extractedDirectory, + string variantSuffix, + HashSet repackedOutputs) + { + logger.LogInformation("Control Bar has flat structure, searching for prebuilt BIG files in root"); + var prebuiltCandidates = Directory.GetFiles(extractedDirectory, "*ControlBarPro*ZH.big", SearchOption.TopDirectoryOnly) + .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix)) + .ToArray(); + + var hasArtDataSplit = prebuiltCandidates.Any(p => + Path.GetFileName(p).Contains("Art", StringComparison.OrdinalIgnoreCase) || + Path.GetFileName(p).Contains("Data", StringComparison.OrdinalIgnoreCase)); + + if (hasArtDataSplit) + { + prebuiltCandidates = [.. prebuiltCandidates.Where(p => + { + var name = Path.GetFileName(p); + return name.Contains("Art", StringComparison.OrdinalIgnoreCase) || + name.Contains("Data", StringComparison.OrdinalIgnoreCase) || + name.Contains("-Fix", StringComparison.OrdinalIgnoreCase) || + name.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) || + name.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase); + })]; + } + + foreach (var candidate in prebuiltCandidates) + { + repackedOutputs.Add(Path.GetFileName(candidate)); + } + } + + private static bool IsMetadataOnlyBig(string fileName) + { + return fileName.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) || + fileName.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase); + } + + private async Task EnsureMetadataBigIncludedAsync( + string extractedDirectory, + string variantId, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + var existingMetadataFileName = repackedOutputs.FirstOrDefault(IsMetadataOnlyBig); + + if (existingMetadataFileName != null) + { + logger.LogInformation("Using existing Control Bar metadata file {FileName}", existingMetadataFileName); + return; + } + + var metadataFileName = GameContentConstants.ControlBarProBaseFileName; + var metadataTargetPath = Path.Combine(extractedDirectory, metadataFileName); + + if (!File.Exists(metadataTargetPath)) + { + await TryLocateAndCopyMetadataBigAsync(extractedDirectory, variantId, metadataFileName, metadataTargetPath); + } + + if (File.Exists(metadataTargetPath)) + { + repackedOutputs.Add(metadataFileName); + logger.LogInformation("Including Control Bar metadata file {FileName} in outputs", metadataFileName); + return; + } + + await WriteFallbackMetadataBigAsync(metadataTargetPath, metadataFileName, repackedOutputs, cancellationToken); + } + + private async Task TryLocateAndCopyMetadataBigAsync( + string extractedDirectory, + string variantId, + string metadataFileName, + string metadataTargetPath) + { + var metadataSearchPaths = new[] + { + Path.Combine(extractedDirectory, "ZH", metadataFileName), + Path.Combine(extractedDirectory, "CCG", metadataFileName), + Path.Combine(extractedDirectory, "ZH", variantId, metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, metadataFileName), + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigEnDirectoryName, metadataFileName), + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigDirectoryName, metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigEnDirectoryName, metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigDirectoryName, metadataFileName), + }; + + var foundSearchPath = metadataSearchPaths.FirstOrDefault(File.Exists); + if (foundSearchPath != null) + { + logger.LogInformation("Found Control Bar metadata file at {SourcePath}, copying to root", foundSearchPath); + await TryCopyFileWithRetryAsync(foundSearchPath, metadataTargetPath, logger); + } + } + + private async Task WriteFallbackMetadataBigAsync( + string metadataTargetPath, + string metadataFileName, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + logger.LogWarning("Control Bar metadata file not found, writing embedded fallback"); + try + { + var metadataBytes = Convert.FromBase64String(ControlBarMetadataBigBase64); + await File.WriteAllBytesAsync(metadataTargetPath, metadataBytes, cancellationToken); + repackedOutputs.Add(metadataFileName); + logger.LogInformation("Created Control Bar metadata file {FileName} from fallback", metadataFileName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create fallback Control Bar metadata file"); + } + } + + private static string DetermineVariantId(string extractedDirectory, ContentManifest manifest, string? requestedVariant) + { + if (!string.IsNullOrWhiteSpace(requestedVariant)) + { + return requestedVariant; + } + + var match = ExtractVariantToken(manifest.Id.Value) ?? ExtractVariantToken(manifest.Name); + if (!string.IsNullOrEmpty(match)) + { + return match; + } + + if (manifest.Metadata?.Tags != null) + { + var tagMatch = manifest.Metadata.Tags + .Select(ExtractVariantToken) + .FirstOrDefault(t => !string.IsNullOrEmpty(t)); + + if (!string.IsNullOrEmpty(tagMatch)) + { + return tagMatch; + } + } + + var existingResolution = KnownResolutionVariants.FirstOrDefault(candidate => + Directory.Exists(Path.Combine(extractedDirectory, "ZH", candidate)) || + Directory.Exists(Path.Combine(extractedDirectory, candidate))); + + return existingResolution ?? GameContentConstants.DefaultControlBarVariant; + } + + private static string? ExtractVariantToken(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + var match = WordVariantRegex.Match(input); + if (match.Success) + { + var token = match.Value.ToLowerInvariant(); + return token switch + { + "720" => "720p", + "900" => "900p", + "1080" => "1080p", + "1440" => "1440p", + "2160" => "4k", + _ => token, + }; + } + + var inlineMatch = InlineVariantRegex.Match(input); + return inlineMatch.Success ? inlineMatch.Value.ToLowerInvariant() : null; + } + + private static void CopyDirectory(string sourceDir, string targetDir) + { + Directory.CreateDirectory(targetDir); + + foreach (var file in Directory.GetFiles(sourceDir)) + { + File.Copy(file, Path.Combine(targetDir, Path.GetFileName(file)), overwrite: true); + } + + foreach (var dir in Directory.GetDirectories(sourceDir)) + { + CopyDirectory(dir, Path.Combine(targetDir, Path.GetFileName(dir))); + } + } + + private static async Task TryCopyFileWithRetryAsync(string source, string destination, ILogger logger, int maxRetries = 3, int delayMs = 100) + { + for (var attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + File.Copy(source, destination, overwrite: true); + return; + } + catch (IOException ex) when (attempt < maxRetries) + { + logger.LogWarning( + ex, + "File copy attempt {Attempt}/{MaxRetries} failed for {Source}: {Message}. Retrying...", + attempt, + maxRetries, + Path.GetFileName(source), + ex.Message); + await Task.Delay(delayMs); + } + } + } + + private void CleanupSourceDirectories(string extractedDirectory, HashSet repackedOutputs) + { + // Destructive cleanup must never run when only the fallback metadata BIG was + // produced; otherwise source content that failed to package would be deleted. + var hasPackagedContent = repackedOutputs.Any(name => !IsMetadataOnlyBig(name)); + + if (!hasPackagedContent) + { + logger.LogWarning( + "Skipping Control Bar source cleanup because no content BIG files were produced for {Directory}", + extractedDirectory); + return; + } + + try + { + var targetSourceDirNames = new[] { "ZH", "CCG", "Art", "Data", GameContentConstants.WindowDirectoryName, GameContentConstants.GenToolDirectoryName, "720p", "900p", "1080p", "1440p", "2160p", "4k" }; + foreach (var dirName in targetSourceDirNames) + { + var dirPath = Path.Combine(extractedDirectory, dirName); + if (Directory.Exists(dirPath)) + { + Directory.Delete(dirPath, recursive: true); + } + } + + var looseFiles = Directory.GetFiles(extractedDirectory, "*.*", SearchOption.TopDirectoryOnly); + foreach (var file in looseFiles) + { + var fileName = Path.GetFileName(file); + if (!repackedOutputs.Contains(fileName) && !fileName.EndsWith(".big", StringComparison.OrdinalIgnoreCase)) + { + File.Delete(file); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to clean up control bar source directories in {Directory}", extractedDirectory); + } + } +} diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs index ea79c8d72..47e09cdb5 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs @@ -24,9 +24,8 @@ namespace GenHub.Features.Content.Services.CommunityOutpost; public class CommunityOutpostManifestFactory( ILogger logger, IFileHashProvider hashProvider, - CompressedImageToTgaConverter avifConverter) : IPublisherManifestFactory + IControlBarPackageProcessor controlBarProcessor) : IPublisherManifestFactory { - private const string ControlBarMetadataBigBase64 = "QklHRngBAAAAAAACAAAAUwAAAFMAAAEkQ29udHJvbEJhclByby50eHQAAAABdwAAAAFHZW5Ub29sXGZ1bGx2aWV3cG9ydC5kYXQAAAAAAAAAAABDb250cm9sIEJhciBQcm8gZm9yIENPTU1BTkQgQU5EIENPTlFVRVIgR0VORVJBTFM6IFpFUk8gSE9VUg0KDQpBVVRIT1I6DQpFQSBHYW1lcywgRkFTLCB4ZXpvbg0KDQpPUklHSU5BTCBET1dOTE9BRCBVUkw6DQpodHRwOi8vZ2VudG9vbC5uZXQvZG93bmxvYWQvY29udHJvbGJhcnBybw0KDQpTT1VSQ0UgQ09ERSAmIEFTU0VUUzoNCmh0dHBzOi8vZ2l0aHViLmNvbS9UaGVTdXBlckhhY2tlcnMvR2VuZXJhbHNDb250cm9sQmFyDQoNCkRPTkFUSU9OIExJTks6DQpodHRwczovL3d3dy5wYXlwYWwubWUvZ2VudG9vbA0KMQ=="; private static readonly ConcurrentDictionary RegexCache = new(); private static Regex GetCachedRegex(string pattern) @@ -244,136 +243,6 @@ private static ContentInstallTarget DetermineFileInstallTarget( return defaultTarget; } - private static string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId) - { - var candidates = new[] - { - Path.Combine(extractedDirectory, "ZH", variantId, "BIG EN"), - Path.Combine(extractedDirectory, "ZH", variantId, "BIG"), - Path.Combine(extractedDirectory, "CCG", variantId, "BIG EN"), - Path.Combine(extractedDirectory, "CCG", variantId, "BIG"), - }; - - foreach (var candidate in candidates) - { - if (Directory.Exists(candidate)) - { - return candidate; - } - } - - return null; - } - - private static string GetControlBarVariantSuffix(string variantId) - { - return variantId.EndsWith("p", StringComparison.OrdinalIgnoreCase) - ? variantId[..^1] - : variantId; - } - - private static bool IsAllowedControlBarBig(string fileName, string variantSuffix) - { - return fileName.Equals($"340_ControlBarProArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals($"340_ControlBarProData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals($"340_ControlBarPro{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals($"340_ControlBarPro-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("400_ControlBarHDEnglishZH.big", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("400_ControlBarProCoreZH.big", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Attempts to copy a file with retry logic for transient file lock issues. - /// - private static async Task TryCopyFileWithRetryAsync(string source, string destination, ILogger logger, int maxRetries = 3, int delayMs = 100) - { - for (var attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - File.Copy(source, destination, overwrite: true); - return; - } - catch (IOException ex) when (attempt < maxRetries) - { - logger.LogWarning( - "File copy attempt {Attempt}/{MaxRetries} failed for {Source}: {Message}. Retrying...", - attempt, - maxRetries, - Path.GetFileName(source), - ex.Message); - await Task.Delay(delayMs * attempt); - } - } - - // Final attempt without catch - let it throw if it fails - File.Copy(source, destination, overwrite: true); - } - - private static void CopyDirectory(string sourceDir, string destinationDir) - { - // Recursion guard - var sourceInfo = new DirectoryInfo(sourceDir); - var destInfo = new DirectoryInfo(destinationDir); - if (destInfo.FullName.StartsWith(sourceInfo.FullName, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Cannot copy directory into itself: Source={sourceDir}, Dest={destinationDir}"); - } - - Directory.CreateDirectory(destinationDir); - - foreach (var file in Directory.GetFiles(sourceDir)) - { - try - { - var targetFile = Path.Combine(destinationDir, Path.GetFileName(file)); - File.Copy(file, targetFile, overwrite: true); - } - catch (IOException) - { - throw; - } - catch (UnauthorizedAccessException) - { - throw; - } - } - - foreach (var dir in Directory.GetDirectories(sourceDir)) - { - var targetDir = Path.Combine(destinationDir, Path.GetFileName(dir)); - CopyDirectory(dir, targetDir); - } - } - - private static HashSet CollectDependencyBigFiles(GenPatcherContentMetadata contentMetadata, GameType targetGame) - { - var dependencyBigFiles = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var dependency in contentMetadata.GetDependencies() - .Where(d => d.InstallBehavior == DependencyInstallBehavior.AutoInstall)) - { - var depId = dependency.Id.Value; - var lastDot = depId.LastIndexOf('.'); - if (lastDot > -1 && lastDot < depId.Length - 1) - { - var depCode = depId[(lastDot + 1)..]; - var depMetadata = GenPatcherContentRegistry.GetMetadata(depCode); - if (depMetadata.TargetGame != GameType.Unknown && depMetadata.TargetGame != targetGame) - { - continue; - } - - if (!string.IsNullOrEmpty(depMetadata.OutputFilename)) - { - dependencyBigFiles.Add(depMetadata.OutputFilename); - } - } - } - - return dependencyBigFiles; - } - /// /// Builds a manifest with all files from the extracted directory. /// If variant is provided, filters files based on variant's IncludePatterns and ExcludePatterns. @@ -415,9 +284,20 @@ private static HashSet CollectDependencyBigFiles(GenPatcherContentMetada contentMetadata.SupportsVariants && variant != null; - var controlBarRepackedOutputs = isControlBarVariant - ? await PrepareControlBarVariantAsync(extractedDirectory, contentMetadata, variant!, cancellationToken) - : new HashSet(StringComparer.OrdinalIgnoreCase); + HashSet controlBarRepackedOutputs; + if (isControlBarVariant) + { + var outputs = await controlBarProcessor.ProcessAndRepackControlBarAsync( + extractedDirectory, + originalManifest, + variant?.Id, + cancellationToken); + controlBarRepackedOutputs = new HashSet(outputs, StringComparer.OrdinalIgnoreCase); + } + else + { + controlBarRepackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase); + } if (controlBarRepackedOutputs.Count > 0) { @@ -573,218 +453,31 @@ private static HashSet CollectDependencyBigFiles(GenPatcherContentMetada } } - private async Task> PrepareControlBarVariantAsync( - string extractedDirectory, - GenPatcherContentMetadata contentMetadata, - ContentVariant variant, - CancellationToken cancellationToken) + private static HashSet CollectDependencyBigFiles(GenPatcherContentMetadata contentMetadata, GameType targetGame) { - var controlBarRepackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase); - var variantSuffix = GetControlBarVariantSuffix(variant.Id); - var variantBigRoot = FindControlBarVariantBigRoot(extractedDirectory, variant.Id); - - if (!string.IsNullOrEmpty(variantBigRoot)) - { - var prebuiltBigs = Directory.GetFiles(variantBigRoot, "*.big", SearchOption.TopDirectoryOnly) - .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix)) - .ToArray(); - - if (prebuiltBigs.Length > 0) - { - logger.LogInformation("Using prebuilt control bar BIG files from {VariantRoot}", variantBigRoot); - foreach (var prebuiltBig in prebuiltBigs) - { - var bigName = Path.GetFileName(prebuiltBig); - var targetPath = Path.Combine(extractedDirectory, bigName); - - if (!string.Equals(Path.GetFullPath(prebuiltBig), Path.GetFullPath(targetPath), StringComparison.OrdinalIgnoreCase)) - { - await TryCopyFileWithRetryAsync(prebuiltBig, targetPath, logger); - } - - controlBarRepackedOutputs.Add(bigName); - } - } - else - { - var artBigName = $"340_ControlBarProArt{variantSuffix}ZH.big"; - var dataBigName = $"340_ControlBarProData{variantSuffix}ZH.big"; - var artBigPath = Path.Combine(extractedDirectory, artBigName); - var dataBigPath = Path.Combine(extractedDirectory, dataBigName); - - if (!File.Exists(artBigPath) || !File.Exists(dataBigPath)) - { - logger.LogInformation("Repacking control bar variant {Variant} into Art/Data BIG files", variant.Name); - var artSource = Path.Combine(variantBigRoot, "Art"); - var dataSource = Path.Combine(variantBigRoot, "Data"); - var windowSource = Path.Combine(variantBigRoot, "Window"); - var genToolSource = Path.Combine(variantBigRoot, "GenTool"); - - var tempRoot = Path.Combine(extractedDirectory, $"cbpro-pack-{variant.Id}"); - var artPackRoot = Path.Combine(tempRoot, "ArtPack"); - var dataPackRoot = Path.Combine(tempRoot, "DataPack"); - - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } - - Directory.CreateDirectory(artPackRoot); - Directory.CreateDirectory(dataPackRoot); - - if (Directory.Exists(artSource)) - { - CopyDirectory(artSource, Path.Combine(artPackRoot, "Art")); - } - - if (Directory.Exists(dataSource)) - { - CopyDirectory(dataSource, Path.Combine(dataPackRoot, "Data")); - } - - if (Directory.Exists(windowSource)) - { - CopyDirectory(windowSource, Path.Combine(dataPackRoot, "Window")); - } - - if (Directory.Exists(genToolSource)) - { - CopyDirectory(genToolSource, Path.Combine(dataPackRoot, "GenTool")); - } - - try - { - await avifConverter.ConvertDirectoryAsync(artPackRoot, cancellationToken); - await avifConverter.ConvertDirectoryAsync(dataPackRoot, cancellationToken); - - await BigFilePacker.PackAsync(artPackRoot, artBigPath); - await BigFilePacker.PackAsync(dataPackRoot, dataBigPath); - } - finally - { - try - { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to cleanup temp root {TempRoot}", tempRoot); - } - } - } - - if (File.Exists(artBigPath)) - { - controlBarRepackedOutputs.Add(artBigName); - } - - if (File.Exists(dataBigPath)) - { - controlBarRepackedOutputs.Add(dataBigName); - } - } - } - else + var dependencyBigFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var dependency in contentMetadata.GetDependencies() + .Where(d => d.InstallBehavior == DependencyInstallBehavior.AutoInstall)) { - logger.LogInformation("Control bar has flat structure (cbpx-style), searching for prebuilt BIG files in root"); - var prebuiltCandidates = Directory.GetFiles(extractedDirectory, "*ControlBarPro*ZH.big", SearchOption.TopDirectoryOnly) - .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix)) - .ToArray(); - - var hasArtDataSplit = prebuiltCandidates.Any(p => - Path.GetFileName(p).StartsWith("340_ControlBarProArt", StringComparison.OrdinalIgnoreCase) || - Path.GetFileName(p).StartsWith("340_ControlBarProData", StringComparison.OrdinalIgnoreCase)); - - if (hasArtDataSplit) - { - prebuiltCandidates = [.. prebuiltCandidates - .Where(p => - { - var name = Path.GetFileName(p); - if (name.StartsWith("340_ControlBarProArt", StringComparison.OrdinalIgnoreCase) || - name.StartsWith("340_ControlBarProData", StringComparison.OrdinalIgnoreCase) || - name.Contains("-Fix", StringComparison.OrdinalIgnoreCase) || - name.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - logger.LogDebug("Excluding monolithic BIG {Name} in favor of Art/Data split files", name); - return false; - })]; - } - - if (prebuiltCandidates.Length > 0) + var depId = dependency.Id.Value; + var lastDot = depId.LastIndexOf('.'); + if (lastDot > -1 && lastDot < depId.Length - 1) { - logger.LogInformation( - "Using {Count} prebuilt control bar BIG files from flat structure: {Files}", - prebuiltCandidates.Length, - string.Join(", ", prebuiltCandidates.Select(Path.GetFileName))); - - foreach (var candidate in prebuiltCandidates) + var depCode = depId[(lastDot + 1)..]; + var depMetadata = GenPatcherContentRegistry.GetMetadata(depCode); + if (depMetadata.TargetGame != GameType.Unknown && depMetadata.TargetGame != targetGame) { - controlBarRepackedOutputs.Add(Path.GetFileName(candidate)); + continue; } - } - else - { - logger.LogWarning("No prebuilt control bar BIG files found for variant {Variant} in flat structure", variant.Name); - } - } - var metadataFileName = "340_ControlBarProZH.big"; - var metadataTargetPath = Path.Combine(extractedDirectory, metadataFileName); - - if (!File.Exists(metadataTargetPath)) - { - var metadataSearchPaths = new[] - { - Path.Combine(extractedDirectory, "ZH", metadataFileName), - Path.Combine(extractedDirectory, "CCG", metadataFileName), - Path.Combine(extractedDirectory, "ZH", variant.Id, metadataFileName), - Path.Combine(extractedDirectory, "CCG", variant.Id, metadataFileName), - Path.Combine(extractedDirectory, "ZH", variant.Id, "BIG EN", metadataFileName), - Path.Combine(extractedDirectory, "ZH", variant.Id, "BIG", metadataFileName), - Path.Combine(extractedDirectory, "CCG", variant.Id, "BIG EN", metadataFileName), - Path.Combine(extractedDirectory, "CCG", variant.Id, "BIG", metadataFileName), - }; - - foreach (var searchPath in metadataSearchPaths) - { - if (File.Exists(searchPath)) + if (!string.IsNullOrEmpty(depMetadata.OutputFilename)) { - logger.LogInformation("Found Control Bar metadata file at {SourcePath}, copying to root", searchPath); - await TryCopyFileWithRetryAsync(searchPath, metadataTargetPath, logger); - break; + dependencyBigFiles.Add(depMetadata.OutputFilename); } } } - if (File.Exists(metadataTargetPath)) - { - controlBarRepackedOutputs.Add(metadataFileName); - logger.LogInformation("Including Control Bar metadata file {FileName} in manifest", metadataFileName); - } - else - { - logger.LogWarning("Control Bar metadata file {FileName} not found in extracted content - creating fallback version", metadataFileName); - try - { - var metadataBytes = Convert.FromBase64String(ControlBarMetadataBigBase64); - await File.WriteAllBytesAsync(metadataTargetPath, metadataBytes, cancellationToken); - controlBarRepackedOutputs.Add(metadataFileName); - logger.LogInformation("Created Control Bar metadata file {FileName} from embedded fallback", metadataFileName); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to create Control Bar metadata file - manifest will be incomplete"); - } - } - - return controlBarRepackedOutputs; + return dependencyBigFiles; } private bool HasVariantBigFiles( diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs index 366cb6e41..ba3eba888 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs @@ -25,7 +25,8 @@ public class GitHubContentProvider( IEnumerable deliverers, ILogger logger, IContentValidator contentValidator, - IInstallationInstructionsService installationInstructionsService) + IInstallationInstructionsService installationInstructionsService, + IArchivePayloadProcessor archiveProcessor) : BaseContentProvider(contentValidator, installationInstructionsService, logger) { /// @@ -106,6 +107,13 @@ protected override async Task> PrepareContentIn // Ensure we have valid data before validation var resultManifest = deliveryResult.Data ?? manifest; + // Process payload archives and normalize directory structure safely + await archiveProcessor.ProcessPayloadAsync( + workingDirectory, + resultManifest.ContentType, + resultManifest.TargetGame, + cancellationToken); + // Validate the delivered content (full validation) // Forward the provider progress reporter to the validator for user-visible progress IProgress? validationProgress = null; diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs index acd40541f..cb64014f6 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs @@ -10,6 +10,7 @@ using GenHub.Core.Services.Providers; using GenHub.Core.Services.Providers.VersionSchemes; using GenHub.Features.Content.Services; +using GenHub.Features.Content.Services.Common; using GenHub.Features.Content.Services.CommunityOutpost; using GenHub.Features.Content.Services.ContentDeliverers; using GenHub.Features.Content.Services.ContentDiscoverers; @@ -385,5 +386,13 @@ private static void AddSharedComponents(IServiceCollection services) // Register installation instructions execution service services.AddSingleton(); + + // Register archive payload processor + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + // Register control bar packaging processor + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); } } diff --git a/scripts/build-check.sh b/scripts/build-check.sh new file mode 100755 index 000000000..aa985c90c --- /dev/null +++ b/scripts/build-check.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# build-check.sh - Serialized build/check script for GenHub on Linux/macOS. +# Linux counterpart to scripts/build-check.ps1. Uses flock to ensure only one +# build runs at a time and refuses to build while output DLLs are locked. + +set -euo pipefail + +MODE="check" +PROJECT="" +TIMEOUT_SECONDS=120 +VERBOSITY="quiet" + +log_status() { + local message="$1" + printf '\033[2m[build-check]\033[0m %s\n' "$message" + return 0 +} + +log_err() { + local message="$1" + printf '\033[2m[build-check]\033[0m \033[31mERROR: %s\033[0m\n' "$message" >&2 + return 0 +} + +usage() { + cat << 'EOF' +Usage: + ./scripts/build-check.sh # quick compile check on full solution + ./scripts/build-check.sh -p GenHub.Core/GenHub.Core.csproj + ./scripts/build-check.sh -m build # full build with output + ./scripts/build-check.sh -m restore # NuGet restore only + ./scripts/build-check.sh -t 300 # longer lock timeout +EOF + return 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + check|build|restore) + MODE="$1" + shift + ;; + -m|--mode) + MODE="${2:-}" + shift 2 + ;; + -p|--project) + PROJECT="${2:-}" + shift 2 + ;; + -t|--timeout) + TIMEOUT_SECONDS="${2:-120}" + shift 2 + ;; + -v|--verbosity) + VERBOSITY="${2:-quiet}" + shift 2 + ;; + -h|--help) + usage || exit 1 + ;; + *) + log_err "Unknown argument: $1" + usage || exit 1 + ;; + esac +done + +case "$MODE" in + check|build|restore) ;; + *) + log_err "Invalid mode '$MODE' (expected check, build, or restore)." + exit 1 + ;; +esac + +case "$VERBOSITY" in + quiet|minimal|normal|detailed|diagnostic) ;; + *) + log_err "Invalid verbosity '$VERBOSITY' (expected quiet, minimal, normal, detailed, or diagnostic)." + exit 1 + ;; +esac + +case "$TIMEOUT_SECONDS" in + ''|*[!0-9]*|0*) + log_err "Timeout must be a positive integer." + exit 1 + ;; + *) + ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOLUTION_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/GenHub" +SOLUTION_FILE="$SOLUTION_DIR/GenHub.sln" +LOCK_FILE="$SOLUTION_DIR/build.lock" +LOCK_TIMEOUT="$TIMEOUT_SECONDS" + +trap 'rm -f "$LOCK_FILE"; exec 9>&- 2>/dev/null || true' EXIT + +if ! command -v dotnet >/dev/null 2>&1; then + if [[ -x "$HOME/.dotnet/dotnet" ]]; then + export PATH="$HOME/.dotnet:$PATH" + else + log_err "dotnet CLI not found in PATH or ~/.dotnet." + exit 1 + fi +fi + +if [[ ! -f "$SOLUTION_FILE" ]]; then + log_err "Solution not found at: $SOLUTION_FILE" + exit 1 +fi + +if ! command -v flock >/dev/null 2>&1; then + log_err "flock not found. Install util-linux (usually preinstalled on Linux)." + exit 1 +fi + +TARGET="$SOLUTION_FILE" +NO_DEPENDENCIES=() +if [[ -n "$PROJECT" ]]; then + case "$PROJECT" in + ..*|*..*|/*) + log_err "Project must be a relative path under $SOLUTION_DIR (no '..' segments or absolute paths)." + exit 1 + ;; + *) + TARGET="$SOLUTION_DIR/$PROJECT" + ;; + esac + + if [[ ! -f "$TARGET" ]]; then + log_err "Project not found: $TARGET" + exit 1 + fi + NO_DEPENDENCIES=(--no-dependencies) +fi + +log_status "Acquiring build lock (timeout: ${TIMEOUT_SECONDS}s)..." + +exec 9>"$LOCK_FILE" +if ! flock -w "$LOCK_TIMEOUT" 9; then + log_err "Timed out waiting for build lock after ${TIMEOUT_SECONDS}s." + log_err "Another agent or process is currently building." + exit 3 +fi + +CURRENT_TIME="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +printf '{"pid": %d, "mode": "%s", "project": "%s", "startedAt": "%s"}\n' \ + "$$" "$MODE" "${PROJECT:-GenHub.sln}" "$CURRENT_TIME" >"$LOCK_FILE" + +log_status "Build lock acquired." + +DOTNET_ARGS=(--nologo --verbosity "$VERBOSITY" -maxcpucount:2) + +EXIT_CODE=0 +case "$MODE" in + check) + log_status "Running compile check on: $(basename "$TARGET")" + dotnet build "$TARGET" --no-restore "${DOTNET_ARGS[@]}" "${NO_DEPENDENCIES[@]}" || EXIT_CODE=$? + ;; + build) + log_status "Running full build on: $(basename "$TARGET")" + dotnet build "$TARGET" "${DOTNET_ARGS[@]}" || EXIT_CODE=$? + ;; + restore) + log_status "Running NuGet restore on: $(basename "$TARGET")" + dotnet restore "$TARGET" --verbosity "$VERBOSITY" || EXIT_CODE=$? + ;; + *) + log_err "Unhandled mode: $MODE" + EXIT_CODE=1 + ;; +esac + +if [[ $EXIT_CODE -eq 0 ]]; then + log_status "Completed successfully with no errors." +else + log_err "Build/check failed with exit code: $EXIT_CODE" +fi + +exit "$EXIT_CODE"