From ba0ba0d95e110439d3cd80155fa12a3f4c57278d Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 13:54:50 +0200 Subject: [PATCH 01/11] fix(review): resolve review feedback, wire payload processors, and rebase with development --- GenHub/Directory.Packages.props | 2 +- .../GenHub.Core/Constants/CatalogConstants.cs | 69 +- .../Constants/GameContentConstants.cs | 156 ++ .../GenHub.Core/Helpers/ContentPathPolicy.cs | 168 +++ .../Content/IArchivePayloadProcessor.cs | 52 + .../Content/IControlBarPackageProcessor.cs | 58 + .../Common/ArchivePayloadProcessorTests.cs | 546 +++++++ .../CommunityOutpostManifestFactoryTests.cs | 5 +- .../Content/GitHubContentProviderTests.cs | 8 +- .../Common/ControlBarPackageProcessorTests.cs | 198 +++ .../Helpers/ContentPathPolicyTests.cs | 78 + .../Common/ArchivePayloadProcessor.cs | 1304 +++++++++++++++++ .../Common/ControlBarPackageProcessor.cs | 511 +++++++ .../CommunityOutpostManifestFactory.cs | 365 +---- .../Services/GitHub/GitHubContentProvider.cs | 10 +- .../ContentPipelineModule.cs | 9 + 16 files changed, 3196 insertions(+), 343 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/GameContentConstants.cs create mode 100644 GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index 8c7e0e70c..b9240c8c1 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -56,4 +56,4 @@ - + \ No newline at end of file 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..97f322ce6 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/GameContentConstants.cs @@ -0,0 +1,156 @@ +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", + ]; + + /// + /// 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/Helpers/ContentPathPolicy.cs b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs new file mode 100644 index 000000000..057fb3c9b --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs @@ -0,0 +1,168 @@ +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 (File.Exists(current)) + { + var fileInfo = new FileInfo(current); + if (fileInfo.LinkTarget != null) + { + var target = fileInfo.ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + { + var relativeSuffix = Path.GetRelativePath(current, path); + return relativeSuffix == "." ? target.FullName : Path.GetFullPath(Path.Combine(target.FullName, relativeSuffix)); + } + } + + break; + } + + if (Directory.Exists(current)) + { + var dirInfo = new DirectoryInfo(current); + if (dirInfo.LinkTarget != null) + { + var target = dirInfo.ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + { + var relativeSuffix = Path.GetRelativePath(current, path); + return relativeSuffix == "." ? target.FullName : Path.GetFullPath(Path.Combine(target.FullName, relativeSuffix)); + } + } + } + + current = Path.GetDirectoryName(current); + } + } + catch + { + // Fallback to path if resolution fails + } + + return path; + } +} 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..39b928f6f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs @@ -0,0 +1,198 @@ +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); + Assert.False(File.Exists(Path.Combine(_testDir, "339_ControlBarProLemonEditionHideIpZH.big.BAK"))); + Assert.False(File.Exists(Path.Combine(_testDir, "ReadMe.txt"))); + } + + /// + /// 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/Helpers/ContentPathPolicyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs new file mode 100644 index 000000000..089133591 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public class ContentPathPolicyTests +{ + 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); + } + + /// + /// 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..9e9a33632 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -0,0 +1,1304 @@ +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(); + + try + { + 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); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to extract archive: {ArchivePath}", archivePath); + throw; + } + } + } + + 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) + { + var buffer = new byte[8192]; + long offset = 0; + int read = 0; + int matchIndex = 0; + + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + for (int i = 0; i < read; i++) + { + if (buffer[i] == signature[matchIndex]) + { + matchIndex++; + if (matchIndex == signature.Length) + { + return offset + i - signature.Length + 1; + } + } + else + { + if (matchIndex > 0) + { + i -= matchIndex; + matchIndex = 0; + } + } + } + + offset += read; + } + + return -1; + } + + private static IReadOnlyList FindArchiveFiles(string rootDirectory, ContentType? contentType = null) + { + var allFiles = Directory.GetFiles(rootDirectory, "*", SearchOption.AllDirectories); + var archives = new List(); + + foreach (var file in allFiles) + { + if (IsArchiveFile(file, contentType)) + { + archives.Add(file); + } + } + + return archives; + } + + 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}"); + } + + totalUncompressedSize += entry.Size; + if (totalUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) + { + throw new InvalidDataException( + $"Archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); + } + + 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) + { + 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); + } + + entry.WriteToFile(destinationPath, new ExtractionOptions + { + ExtractFullPath = false, + Overwrite = true, + }); + } + } + + 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}"); + } + + totalUncompressedSize += entry.Length; + if (totalUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) + { + throw new InvalidDataException( + $"Archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); + } + + 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) + { + 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); + } + + entry.ExtractToFile(destinationPath, overwrite: true); + } + + return true; + } + catch (InvalidDataException) + { + throw; + } + catch + { + return false; + } + } + + 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 (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 (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) + { + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); + + var blocks = new List<(long Pos, int CompSize, byte CompType, long DataStart)>(); + var blockIdx = 0; + while (stream.Position < stream.Length - 13) + { + var pos = stream.Position; + _ = blockIdx == 0 ? reader.ReadInt16() : reader.ReadInt32(); + var compSize = reader.ReadInt32(); + _ = reader.ReadInt32(); + var compType = reader.ReadByte(); + var dataLength = compSize - 5; + var dataStart = stream.Position; + + blocks.Add((pos, compSize, compType, dataStart)); + blockIdx++; + + if (dataLength > 0 && stream.Position + dataLength <= stream.Length) + { + stream.Position += dataLength; + } + else + { + break; + } + } + + if (blocks.Count < 2) + { + return (null, -1); + } + + var payloadOffset = blocks[^1].DataStart; + var tableBlock = blocks[^2]; + if (tableBlock.CompType == 1) + { + stream.Position = tableBlock.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(), payloadOffset); + } + + return (null, payloadOffset); + } + + 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(); + + var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, rec.Name); + if (!pathResult.Success) + { + 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; + + long written = 0; + + if (headerRead >= 2 && header[0] == 'B' && header[1] == 'Z') + { + using var bz2 = SharpCompress.Compressors.BZip2.BZip2Stream.Create( + stream, + SharpCompress.Compressors.CompressionMode.Decompress, + decompressConcatenated: false, + leaveOpen: true); + + using var outStream = File.Create(destinationPath); + while (written < rec.UncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, rec.UncompressedSize - written); + var readBytes = bz2.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + } + else if (headerRead >= 2 && header[0] == 0x78 && (header[1] == 0xDA || header[1] == 0x9C || header[1] == 0x01 || header[1] == 0x5E)) + { + stream.Position = filePos + 2; // skip zlib header + using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); + using var outStream = File.Create(destinationPath); + while (written < rec.UncompressedSize) + { + var toRead = (int)Math.Min(copyBuffer.Length, rec.UncompressedSize - written); + var readBytes = def.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) + { + break; + } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + } + else + { + using var outStream = File.Create(destinationPath); + 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."); + } + + extractedCount++; + } + + return extractedCount; + } + + 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; + + for (var i = 0; i < tableData.Length - 4; i++) + { + if (tableData[i] == '.' && i >= 40) + { + var start = i; + while (start > 0 && tableData[start - 1] != 0 && tableData[start - 1] >= 32 && tableData[start - 1] <= 126) + { + start--; + } + + var end = i; + while (end < tableData.Length && tableData[end] != 0 && tableData[end] >= 32 && tableData[end] <= 126) + { + end++; + } + + var name = Encoding.Latin1.GetString(tableData, start, end - start); + if (name.Contains('.') && + !name.StartsWith(' ') && + name.Length > 3 && + !name.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase) && + !name.EndsWith("Intrnl.exe", StringComparison.OrdinalIgnoreCase) && + start >= 40) + { + var ext = Path.GetExtension(name); + if (!string.IsNullOrEmpty(ext) && ext.Length <= 5) + { + var uncompSize = BitConverter.ToUInt32(tableData, start - 40); + var streamOffset = BitConverter.ToUInt32(tableData, start - 36); + var compSize = BitConverter.ToUInt32(tableData, start - 32); + + if (uncompSize > 0 && + compSize > 0 && + (ulong)uncompSize <= (ulong)CatalogConstants.MaxZipUncompressedSizeBytes && + payloadOffset + streamOffset + compSize <= stream.Length && + !records.Exists(r => r.Name == name && r.StreamOffset == streamOffset)) + { + if (records.Count >= CatalogConstants.MaxZipEntryCount) + { + throw new InvalidDataException( + $"Smart Install Maker archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); + } + + cumulativeUncompressedSize += uncompSize; + if (cumulativeUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) + { + throw new InvalidDataException( + $"Smart Install Maker archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); + } + + records.Add((name, uncompSize, streamOffset, compSize)); + } + } + + i = end; + } + } + } + + return records; + } + + 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) + { + // Use a sibling staging directory on the same filesystem/volume for fast, safe move without nesting collisions + var tempStaging = targetDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + "_staging_" + Guid.NewGuid().ToString("N"); + + try + { + Directory.Move(sourceDirectory, tempStaging); + + foreach (var subFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories)) + { + 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); + continue; + } + + var dir = destinationDir ?? targetDirectory; + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(destinationPath); + var ext = Path.GetExtension(destinationPath); + var counter = 1; + string newDestPath; + do + { + newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); + counter++; + } + while (File.Exists(newDestPath)); + + File.Move(subFile, newDestPath); + } + else + { + File.Move(subFile, destinationPath); + } + } + } + finally + { + if (Directory.Exists(tempStaging)) + { + Directory.Delete(tempStaging, recursive: true); + } + } + } + + 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; + } + + int bytesRead1; + while ((bytesRead1 = s1.Read(buffer1, 0, bufferSize)) > 0) + { + 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).OrderByDescending(d => d.Length)) + { + if (Directory.Exists(subDir) && !Directory.EnumerateFileSystemEntries(subDir).Any()) + { + 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); + break; + } + } + } + + 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)) + { + File.Delete(gibFile); + logger.LogInformation("Removed redundant inactive file '{GibFile}' as '{BigFile}' already exists", gibFile, bigFile); + } + 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 offset, 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 offsetInBuffer, int count) + { + if (_position >= length) + { + return 0; + } + + var toRead = (int)Math.Min(count, length - _position); + baseStream.Position = offset + _position; + var read = baseStream.Read(buffer, offsetInBuffer, toRead); + _position += read; + return read; + } + + public override long Seek(long offsetFromOrigin, SeekOrigin origin) + { + var target = origin switch + { + SeekOrigin.Begin => offsetFromOrigin, + SeekOrigin.Current => _position + offsetFromOrigin, + SeekOrigin.End => length + offsetFromOrigin, + _ => 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 offsetInBuffer, 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..52d17862f --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs @@ -0,0 +1,511 @@ +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.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"]; + + /// + public bool IsControlBarContent(string extractedDirectory, ContentManifest manifest) + { + if (manifest.ContentType is ContentType.Addon or ContentType.Mod) + { + var id = manifest.Id.Value.ToLowerInvariant(); + if (id.Contains("controlbar") || id.Contains("cbpr") || id.Contains("cbpx")) + { + return true; + } + + var name = manifest.Name.ToLowerInvariant(); + if (name.Contains("controlbar") || name.Contains("control bar") || name.Contains("control-bar")) + { + return true; + } + + if (manifest.Metadata?.Tags != null && + manifest.Metadata.Tags.Any(t => t.Contains("controlbar", StringComparison.OrdinalIgnoreCase) || + t.Contains("control-bar", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + + if (Directory.Exists(extractedDirectory)) + { + if (Directory.GetFiles(extractedDirectory, "*ControlBar*.big", SearchOption.AllDirectories).Length > 0) + { + return true; + } + + if (Directory.GetFiles(extractedDirectory, "*ControlBar*.wnd", SearchOption.AllDirectories).Length > 0) + { + return true; + } + } + + return false; + } + + /// + 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)) + { + 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); + } + + repackedOutputs.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); + + logger.LogInformation( + "Repacking Control Bar variant {Variant} into Art/Data BIG files: {ArtBig}, {DataBig}", + variantId, + artBigName, + dataBigName); + + 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-{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); + + 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 + { + // Convert AVIF/WebP images to TGA prior to packing + 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 temporary pack directory {TempRoot}", tempRoot); + } + } + + if (File.Exists(artBigPath)) + { + repackedOutputs.Add(artBigName); + } + + if (File.Exists(dataBigPath)) + { + repackedOutputs.Add(dataBigName); + } + } + } + else + { + // Check for flat structure prebuilt BIG files + 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.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase) || + name.Equals("340_ControlBarProLemonEditionZH.big", StringComparison.OrdinalIgnoreCase); + })]; + } + + foreach (var candidate in prebuiltCandidates) + { + repackedOutputs.Add(Path.GetFileName(candidate)); + } + } + + // Check if an existing metadata / base BIG file is already included in outputs + var existingMetadataFileName = repackedOutputs.FirstOrDefault(name => + name.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase) || + name.Equals("340_ControlBarProLemonEditionZH.big", StringComparison.OrdinalIgnoreCase)); + + if (existingMetadataFileName != null) + { + logger.LogInformation("Using existing Control Bar metadata file {FileName}", existingMetadataFileName); + } + else + { + // Explicitly ensure metadata BIG file (340_ControlBarProZH.big) is included + 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", variantId, metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, metadataFileName), + Path.Combine(extractedDirectory, "ZH", variantId, "BIG EN", metadataFileName), + Path.Combine(extractedDirectory, "ZH", variantId, "BIG", metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, "BIG EN", metadataFileName), + Path.Combine(extractedDirectory, "CCG", variantId, "BIG", metadataFileName), + }; + + foreach (var searchPath in metadataSearchPaths) + { + if (File.Exists(searchPath)) + { + logger.LogInformation("Found Control Bar metadata file at {SourcePath}, copying to root", searchPath); + await TryCopyFileWithRetryAsync(searchPath, metadataTargetPath, logger); + break; + } + } + } + + if (File.Exists(metadataTargetPath)) + { + repackedOutputs.Add(metadataFileName); + logger.LogInformation("Including Control Bar metadata file {FileName} in outputs", metadataFileName); + } + else + { + logger.LogWarning("Control Bar metadata file not found, writing embedded fallback"); + try + { + var metadataBytes = Convert.FromBase64String(ControlBarMetadataBigBase64); + File.WriteAllBytes(metadataTargetPath, metadataBytes); + 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"); + } + } + } + + // Cleanup raw unpacked source directories so only the packaged files remain + CleanupSourceDirectories(extractedDirectory, repackedOutputs); + + return [.. repackedOutputs]; + } + + /// + public string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId) + { + var rawSuffix = GetControlBarVariantSuffix(variantId); + var candidates = new[] + { + Path.Combine(extractedDirectory, "ZH", variantId, "BIG EN"), + Path.Combine(extractedDirectory, "ZH", variantId, "BIG"), + Path.Combine(extractedDirectory, "ZH", variantId), + Path.Combine(extractedDirectory, "ZH", rawSuffix, "BIG EN"), + Path.Combine(extractedDirectory, "ZH", rawSuffix, "BIG"), + Path.Combine(extractedDirectory, "ZH", rawSuffix), + Path.Combine(extractedDirectory, "CCG", variantId, "BIG EN"), + Path.Combine(extractedDirectory, "CCG", variantId, "BIG"), + Path.Combine(extractedDirectory, "CCG", variantId), + Path.Combine(extractedDirectory, "CCG", rawSuffix, "BIG EN"), + Path.Combine(extractedDirectory, "CCG", rawSuffix, "BIG"), + Path.Combine(extractedDirectory, "CCG", rawSuffix), + Path.Combine(extractedDirectory, variantId, "BIG EN"), + Path.Combine(extractedDirectory, variantId, "BIG"), + Path.Combine(extractedDirectory, variantId), + Path.Combine(extractedDirectory, rawSuffix, "BIG EN"), + Path.Combine(extractedDirectory, rawSuffix, "BIG"), + Path.Combine(extractedDirectory, rawSuffix), + }; + + foreach (var candidate in candidates) + { + if (Directory.Exists(candidate)) + { + return candidate; + } + } + + if (Directory.Exists(Path.Combine(extractedDirectory, "Window")) || + Directory.Exists(Path.Combine(extractedDirectory, "Art")) || + Directory.Exists(Path.Combine(extractedDirectory, "Data")) || + Directory.Exists(Path.Combine(extractedDirectory, "GenTool"))) + { + 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("340_ControlBarProZH.big", 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("340_ControlBarProLemonEditionZH.big", 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 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) + { + foreach (var tag in manifest.Metadata.Tags) + { + var tagMatch = ExtractVariantToken(tag); + if (!string.IsNullOrEmpty(tagMatch)) + { + return tagMatch; + } + } + } + + // Check if resolution subfolders exist in extracted content + foreach (var candidate in KnownResolutionVariants) + { + if (Directory.Exists(Path.Combine(extractedDirectory, "ZH", candidate)) || + Directory.Exists(Path.Combine(extractedDirectory, candidate))) + { + return candidate; + } + } + + return "1080p"; + } + + private static string? ExtractVariantToken(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + var match = Regex.Match(input, @"\b(720p?|900p?|1080p?|1440p?|2160p?|4k)\b", RegexOptions.IgnoreCase); + if (match.Success) + { + var token = match.Value.ToLowerInvariant(); + return token switch + { + "720" => "720p", + "900" => "900p", + "1080" => "1080p", + "1440" => "1440p", + "2160" => "4k", + _ => token, + }; + } + + var inlineMatch = Regex.Match(input, @"(720p|900p|1080p|1440p|2160p|4k)", RegexOptions.IgnoreCase); + if (inlineMatch.Success) + { + return inlineMatch.Value.ToLowerInvariant(); + } + + return 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( + "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) + { + if (repackedOutputs.Count == 0) + { + return; + } + + try + { + var targetSourceDirNames = new[] { "ZH", "CCG", "Art", "Data", "Window", "GenTool", "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()); } } From 6dd8a77895aa3c911dcc838fd9261423fdbe556b Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 14:01:37 +0200 Subject: [PATCH 02/11] fix(sfx): resolve DeepSource complexity and uninitialized variable warnings --- .../Common/ControlBarPackageProcessorTests.cs | 2 - .../Common/ArchivePayloadProcessor.cs | 364 +++++++++++++----- .../Common/ControlBarPackageProcessor.cs | 116 +++--- 3 files changed, 334 insertions(+), 148 deletions(-) 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 index 39b928f6f..9a21da8a5 100644 --- 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 @@ -167,8 +167,6 @@ public async Task ProcessAndRepackControlBarAsync_WithLemonEditionPrebuiltBigFil Assert.Contains("340_ControlBarProLemonEditionData1080ZH.big", outputFiles); Assert.Contains("340_ControlBarProLemonEditionZH.big", outputFiles); Assert.DoesNotContain("340_ControlBarProZH.big", outputFiles); - Assert.False(File.Exists(Path.Combine(_testDir, "339_ControlBarProLemonEditionHideIpZH.big.BAK"))); - Assert.False(File.Exists(Path.Combine(_testDir, "ReadMe.txt"))); } /// diff --git a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs index 9e9a33632..59865d3b7 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -452,13 +452,6 @@ private static void ExtractSharpCompressArchive( $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); } - totalUncompressedSize += entry.Size; - if (totalUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) - { - throw new InvalidDataException( - $"Archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); - } - if (Path.IsPathRooted(entry.Key)) { throw new InvalidDataException($"Archive entry has an unsafe path: {entry.Key}"); @@ -478,11 +471,33 @@ private static void ExtractSharpCompressArchive( Directory.CreateDirectory(destinationDir); } - entry.WriteToFile(destinationPath, new ExtractionOptions + using (var entryStream = entry.OpenEntryStream()) { - ExtractFullPath = false, - Overwrite = true, - }); + 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]; + int read; + 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); } } @@ -524,13 +539,6 @@ private static bool TryExtractZipArchive( $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); } - totalUncompressedSize += entry.Length; - if (totalUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) - { - throw new InvalidDataException( - $"Archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); - } - if (Path.IsPathRooted(entry.FullName)) { throw new InvalidDataException($"Archive entry has an unsafe path: {entry.FullName}"); @@ -549,11 +557,18 @@ private static bool TryExtractZipArchive( Directory.CreateDirectory(destinationDir); } - entry.ExtractToFile(destinationPath, overwrite: true); + using (var entryStream = entry.Open()) + { + CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); + } } return true; } + catch (OperationCanceledException) + { + throw; + } catch (InvalidDataException) { throw; @@ -590,6 +605,10 @@ private static bool TryExtractSubStreamArchive( ExtractSharpCompressArchive(archive, extractPath, cancellationToken); return true; } + catch (OperationCanceledException) + { + throw; + } catch (InvalidDataException) { throw; @@ -640,6 +659,10 @@ private static bool TryExtractSmartInstallMakerArchive( PromoteDirectoryContents(stagingDir, extractPath); return true; } + catch (OperationCanceledException) + { + throw; + } catch (InvalidDataException) { throw; @@ -668,20 +691,24 @@ private static (byte[]? TableData, long PayloadOffset) ReadSmartInstallMakerMeta { using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); - var blocks = new List<(long Pos, int CompSize, byte CompType, long DataStart)>(); - var blockIdx = 0; - while (stream.Position < stream.Length - 13) + (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; - _ = blockIdx == 0 ? reader.ReadInt16() : reader.ReadInt32(); + _ = blockCount == 0 ? reader.ReadInt16() : reader.ReadInt32(); var compSize = reader.ReadInt32(); _ = reader.ReadInt32(); var compType = reader.ReadByte(); var dataLength = compSize - 5; var dataStart = stream.Position; - blocks.Add((pos, compSize, compType, dataStart)); - blockIdx++; + secondToLastBlock = lastBlock; + lastBlock = (pos, compSize, compType, dataStart); + blockCount++; if (dataLength > 0 && stream.Position + dataLength <= stream.Length) { @@ -693,13 +720,13 @@ private static (byte[]? TableData, long PayloadOffset) ReadSmartInstallMakerMeta } } - if (blocks.Count < 2) + if (secondToLastBlock == null || lastBlock == null) { return (null, -1); } - var payloadOffset = blocks[^1].DataStart; - var tableBlock = blocks[^2]; + var payloadOffset = lastBlock.Value.DataStart; + var tableBlock = secondToLastBlock.Value; if (tableBlock.CompType == 1) { stream.Position = tableBlock.DataStart + 2; // skip zlib 78-DA header @@ -822,6 +849,29 @@ private static int ExtractSmartInstallMakerPayload( } } + if (written != rec.UncompressedSize) + { + // Fallback to raw copy if sniffed decompressor failed but raw payload is available + if (filePos + rec.UncompressedSize <= stream.Length) + { + 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( @@ -844,64 +894,132 @@ private static int ExtractSmartInstallMakerPayload( for (var i = 0; i < tableData.Length - 4; i++) { - if (tableData[i] == '.' && i >= 40) + if (tableData[i] != '.' || i < 40) { - var start = i; - while (start > 0 && tableData[start - 1] != 0 && tableData[start - 1] >= 32 && tableData[start - 1] <= 126) + continue; + } + + if (!TryExtractSimCandidateName(tableData, i, out var name, out var nextIndex, out var startOffset)) + { + continue; + } + + i = nextIndex; + + if (!IsValidSimEntryName(name)) + { + continue; + } + + if (TryReadSimRecord(tableData, startOffset, name, stream, payloadOffset, records, out var record)) + { + if (records.Count >= CatalogConstants.MaxZipEntryCount) { - start--; + throw new InvalidDataException( + $"Smart Install Maker archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); } - var end = i; - while (end < tableData.Length && tableData[end] != 0 && tableData[end] >= 32 && tableData[end] <= 126) + cumulativeUncompressedSize += record.UncompressedSize; + if (cumulativeUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) { - end++; + throw new InvalidDataException( + $"Smart Install Maker archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); } - var name = Encoding.Latin1.GetString(tableData, start, end - start); - if (name.Contains('.') && - !name.StartsWith(' ') && - name.Length > 3 && - !name.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase) && - !name.EndsWith("Intrnl.exe", StringComparison.OrdinalIgnoreCase) && - start >= 40) - { - var ext = Path.GetExtension(name); - if (!string.IsNullOrEmpty(ext) && ext.Length <= 5) - { - var uncompSize = BitConverter.ToUInt32(tableData, start - 40); - var streamOffset = BitConverter.ToUInt32(tableData, start - 36); - var compSize = BitConverter.ToUInt32(tableData, start - 32); - - if (uncompSize > 0 && - compSize > 0 && - (ulong)uncompSize <= (ulong)CatalogConstants.MaxZipUncompressedSizeBytes && - payloadOffset + streamOffset + compSize <= stream.Length && - !records.Exists(r => r.Name == name && r.StreamOffset == streamOffset)) - { - if (records.Count >= CatalogConstants.MaxZipEntryCount) - { - throw new InvalidDataException( - $"Smart Install Maker archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); - } + records.Add(record); + } + } - cumulativeUncompressedSize += uncompSize; - if (cumulativeUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) - { - throw new InvalidDataException( - $"Smart Install Maker archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); - } + return records; + } - records.Add((name, uncompSize, streamOffset, compSize)); - } - } + 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--; + } - i = end; - } - } + var end = dotIndex; + while (end < tableData.Length && tableData[end] != 0 && tableData[end] >= 32 && tableData[end] <= 126) + { + end++; } - return records; + 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) @@ -972,8 +1090,13 @@ private static bool DirectoryContainsMapFilesDirectly(string directory) private static void PromoteDirectoryContents(string sourceDirectory, string targetDirectory) { - // Use a sibling staging directory on the same filesystem/volume for fast, safe move without nesting collisions - var tempStaging = targetDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + 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 @@ -1001,18 +1124,7 @@ private static void PromoteDirectoryContents(string sourceDirectory, string targ continue; } - var dir = destinationDir ?? targetDirectory; - var fileNameWithoutExt = Path.GetFileNameWithoutExtension(destinationPath); - var ext = Path.GetExtension(destinationPath); - var counter = 1; - string newDestPath; - do - { - newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); - counter++; - } - while (File.Exists(newDestPath)); - + var newDestPath = GetNonCollidingDestinationPath(destinationPath); File.Move(subFile, newDestPath); } else @@ -1021,15 +1133,73 @@ private static void PromoteDirectoryContents(string sourceDirectory, string targ } } } + catch + { + try + { + if (Directory.Exists(tempStaging)) + { + if (!Directory.Exists(sourceDirectory)) + { + Directory.Move(tempStaging, sourceDirectory); + } + else + { + 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 + } + + throw; + } finally { if (Directory.Exists(tempStaging)) { - Directory.Delete(tempStaging, recursive: true); + 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 = string.Empty; + do + { + newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); + counter++; + } + while (File.Exists(newDestPath)); + + return newDestPath; + } + private static bool FilesHaveIdenticalContent(string file1, string file2) { const int bufferSize = 65536; @@ -1044,7 +1214,7 @@ private static bool FilesHaveIdenticalContent(string file1, string file2) return false; } - int bytesRead1; + var bytesRead1 = 0; while ((bytesRead1 = s1.Read(buffer1, 0, bufferSize)) > 0) { var bytesRead2 = s2.Read(buffer2, 0, bufferSize); @@ -1158,7 +1328,6 @@ private void RouteGameSpecificSubdirectories( targetGame); PromoteDirectoryContents(subDir, extractedDirectory); - break; } } } @@ -1229,8 +1398,17 @@ private void NormalizeGibExtensions(string extractedDirectory, ContentType conte var bigFile = Path.ChangeExtension(gibFile, ".big"); if (File.Exists(bigFile)) { - File.Delete(gibFile); - logger.LogInformation("Removed redundant inactive file '{GibFile}' as '{BigFile}' already exists", gibFile, 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 { diff --git a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs index 52d17862f..95cebb186 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs @@ -113,70 +113,79 @@ public async Task> ProcessAndRepackControlBarAsync( var artBigPath = Path.Combine(extractedDirectory, artBigName); var dataBigPath = Path.Combine(extractedDirectory, dataBigName); - logger.LogInformation( - "Repacking Control Bar variant {Variant} into Art/Data BIG files: {ArtBig}, {DataBig}", - variantId, - artBigName, - dataBigName); - - 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-{variantId}"); - var artPackRoot = Path.Combine(tempRoot, "ArtPack"); - var dataPackRoot = Path.Combine(tempRoot, "DataPack"); - - if (Directory.Exists(tempRoot)) + if (!File.Exists(artBigPath) || !File.Exists(dataBigPath)) { - Directory.Delete(tempRoot, recursive: true); - } + logger.LogInformation( + "Repacking Control Bar variant {Variant} into Art/Data BIG files: {ArtBig}, {DataBig}", + variantId, + artBigName, + dataBigName); + + 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-{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); + 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(artSource)) + { + CopyDirectory(artSource, Path.Combine(artPackRoot, "Art")); + } - if (Directory.Exists(windowSource)) - { - CopyDirectory(windowSource, Path.Combine(dataPackRoot, "Window")); - } + if (Directory.Exists(dataSource)) + { + CopyDirectory(dataSource, Path.Combine(dataPackRoot, "Data")); + } - if (Directory.Exists(genToolSource)) - { - CopyDirectory(genToolSource, Path.Combine(dataPackRoot, "GenTool")); - } + if (Directory.Exists(windowSource)) + { + CopyDirectory(windowSource, Path.Combine(dataPackRoot, "Window")); + } - try - { - // Convert AVIF/WebP images to TGA prior to packing - await avifConverter.ConvertDirectoryAsync(artPackRoot, cancellationToken); - await avifConverter.ConvertDirectoryAsync(dataPackRoot, cancellationToken); + if (Directory.Exists(genToolSource)) + { + CopyDirectory(genToolSource, Path.Combine(dataPackRoot, "GenTool")); + } - await BigFilePacker.PackAsync(artPackRoot, artBigPath); - await BigFilePacker.PackAsync(dataPackRoot, dataBigPath); - } - finally - { try { - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } + // 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); } - catch (Exception ex) + finally { - logger.LogWarning(ex, "Failed to cleanup temporary pack directory {TempRoot}", tempRoot); + try + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to cleanup temporary pack directory {TempRoot}", tempRoot); + } } } @@ -210,6 +219,7 @@ public async Task> ProcessAndRepackControlBarAsync( var name = Path.GetFileName(p); return name.Contains("Art", StringComparison.OrdinalIgnoreCase) || name.Contains("Data", StringComparison.OrdinalIgnoreCase) || + name.Contains("-Fix", StringComparison.OrdinalIgnoreCase) || name.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase) || name.Equals("340_ControlBarProLemonEditionZH.big", StringComparison.OrdinalIgnoreCase); })]; From a25e3cd939f6f68babb0e8ca3ca3aa4b99664e2f Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 14:55:34 +0200 Subject: [PATCH 03/11] fix(sfx): resolve DeepSource complexity, using blocks, and uninitialized variable warnings --- .../Common/ArchivePayloadProcessor.cs | 217 ++++++++++-------- 1 file changed, 116 insertions(+), 101 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs index 59865d3b7..e97cc23ac 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -471,10 +471,8 @@ private static void ExtractSharpCompressArchive( Directory.CreateDirectory(destinationDir); } - using (var entryStream = entry.OpenEntryStream()) - { - CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); - } + using var entryStream = entry.OpenEntryStream(); + CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); } } @@ -486,7 +484,7 @@ private static void CopyEntryWithCap( { using var dest = File.Create(destinationPath); var buffer = new byte[81920]; - int read; + var read = 0; while ((read = source.Read(buffer, 0, buffer.Length)) > 0) { cancellationToken.ThrowIfCancellationRequested(); @@ -557,10 +555,8 @@ private static bool TryExtractZipArchive( Directory.CreateDirectory(destinationDir); } - using (var entryStream = entry.Open()) - { - CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); - } + using var entryStream = entry.Open(); + CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken); } return true; @@ -765,123 +761,142 @@ private static int ExtractSmartInstallMakerPayload( foreach (var rec in records) { cancellationToken.ThrowIfCancellationRequested(); + ExtractSingleSmartInstallMakerRecord(stream, payloadOffset, rec, extractRoot, copyBuffer); + extractedCount++; + } - var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, rec.Name); - if (!pathResult.Success) - { - throw new InvalidDataException($"Smart Install Maker entry has an unsafe path: {rec.Name}"); - } + return extractedCount; + } - var destinationPath = pathResult.Data!; - var destinationDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(destinationDir)) - { - Directory.CreateDirectory(destinationDir); - } + 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) + { + throw new InvalidDataException($"Smart Install Maker entry has an unsafe path: {rec.Name}"); + } - 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."); - } + var destinationPath = pathResult.Data!; + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } - stream.Position = filePos; - var header = new byte[2]; - var headerRead = stream.Read(header, 0, 2); - stream.Position = filePos; + 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; - long written = 0; + var written = TryDecompressSmartInstallMakerRecord(stream, filePos, header, headerRead, destinationPath, rec.UncompressedSize, copyBuffer); - if (headerRead >= 2 && header[0] == 'B' && header[1] == 'Z') + 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) { - using var bz2 = SharpCompress.Compressors.BZip2.BZip2Stream.Create( - stream, - SharpCompress.Compressors.CompressionMode.Decompress, - decompressConcatenated: false, - leaveOpen: true); - - using var outStream = File.Create(destinationPath); - while (written < rec.UncompressedSize) + var toRead = (int)Math.Min(copyBuffer.Length, rec.UncompressedSize - written); + var readBytes = stream.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) { - var toRead = (int)Math.Min(copyBuffer.Length, rec.UncompressedSize - written); - var readBytes = bz2.Read(copyBuffer, 0, toRead); - if (readBytes <= 0) - { - break; - } - - outStream.Write(copyBuffer, 0, readBytes); - written += readBytes; + break; } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; } - else if (headerRead >= 2 && header[0] == 0x78 && (header[1] == 0xDA || header[1] == 0x9C || header[1] == 0x01 || header[1] == 0x5E)) + } + + 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) + { + long written = 0; + + if (headerRead >= 2 && header[0] == 'B' && header[1] == 'Z') + { + using var bz2 = SharpCompress.Compressors.BZip2.BZip2Stream.Create( + stream, + SharpCompress.Compressors.CompressionMode.Decompress, + decompressConcatenated: false, + leaveOpen: true); + + using var outStream = File.Create(destinationPath); + while (written < uncompressedSize) { - stream.Position = filePos + 2; // skip zlib header - using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); - using var outStream = File.Create(destinationPath); - while (written < rec.UncompressedSize) + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = bz2.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) { - var toRead = (int)Math.Min(copyBuffer.Length, rec.UncompressedSize - written); - var readBytes = def.Read(copyBuffer, 0, toRead); - if (readBytes <= 0) - { - break; - } - - outStream.Write(copyBuffer, 0, readBytes); - written += readBytes; + break; } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; } - else + } + else if (headerRead >= 2 && header[0] == 0x78 && (header[1] == 0xDA || header[1] == 0x9C || header[1] == 0x01 || header[1] == 0x5E)) + { + stream.Position = filePos + 2; // skip zlib header + using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); + using var outStream = File.Create(destinationPath); + while (written < uncompressedSize) { - using var outStream = File.Create(destinationPath); - while (written < rec.UncompressedSize) + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = def.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) { - 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; + break; } - } - if (written != rec.UncompressedSize) + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; + } + } + else + { + using var outStream = File.Create(destinationPath); + while (written < uncompressedSize) { - // Fallback to raw copy if sniffed decompressor failed but raw payload is available - if (filePos + rec.UncompressedSize <= stream.Length) + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = stream.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) { - 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; - } + break; } - } - if (written != rec.UncompressedSize) - { - throw new InvalidDataException( - $"Smart Install Maker entry '{rec.Name}' decompressed size mismatch: expected {rec.UncompressedSize} bytes, got {written} bytes."); + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; } - - extractedCount++; } - return extractedCount; + return written; } private static List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> ParseSmartInstallMakerFileTable( From 3a83a72b36275eec42a54216544845f3e515b15a Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 05:15:58 +0000 Subject: [PATCH 04/11] fix(tests): construct ControlBarPackageProcessor for manifest factory after rebase --- .../CommunityOutpost/CommunityOutpostDelivererTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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, From d31c3907a74872d581165e3553709c37760b12e1 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 05:15:58 +0000 Subject: [PATCH 05/11] chore(scripts): add Linux build-check script mirroring build-check.ps1 --- scripts/build-check.sh | 156 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100755 scripts/build-check.sh diff --git a/scripts/build-check.sh b/scripts/build-check.sh new file mode 100755 index 000000000..6e43d06a5 --- /dev/null +++ b/scripts/build-check.sh @@ -0,0 +1,156 @@ +#!/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. +# +# 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 + +set -u + +MODE="check" +PROJECT="" +TIMEOUT_SECONDS=120 +VERBOSITY="quiet" + +usage() { + sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 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 + ;; + *) + echo "[build-check] ERROR: Unknown argument: $1" >&2 + usage + ;; + esac +done + +case "$MODE" in + check|build|restore) ;; + *) + echo "[build-check] ERROR: Invalid mode '$MODE' (expected check, build, or restore)." >&2 + 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)) + +log_status() { + printf '\033[2m[build-check]\033[0m %s\n' "$1" +} + +log_err() { + printf '\033[2m[build-check]\033[0m \033[31mERROR: %s\033[0m\n' "$1" >&2 +} + +cleanup() { + rm -f "$LOCK_FILE" + if [[ -n "${LOCK_FD:-}" ]]; then + eval "exec $LOCK_FD>&-" + fi +} +trap cleanup 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 + TARGET="$SOLUTION_DIR/$PROJECT" + 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 + +printf '{"pid": %d, "mode": "%s", "project": "%s", "startedAt": "%s"}\n' \ + "$$" "$MODE" "${PROJECT:-GenHub.sln}" "$(date -Iseconds)" >"$LOCK_FILE" + +log_status "Build lock acquired." + +DOTNET_ARGS=(--nologo --verbosity "$VERBOSITY" -maxcpucount:2) + +case "$MODE" in + check) + log_status "Running compile check on: $(basename "$TARGET")" + dotnet build "$TARGET" --no-restore "${DOTNET_ARGS[@]}" "${NO_DEPENDENCIES[@]}" + ;; + build) + log_status "Running full build on: $(basename "$TARGET")" + dotnet build "$TARGET" "${DOTNET_ARGS[@]}" + ;; + restore) + log_status "Running NuGet restore on: $(basename "$TARGET")" + dotnet restore "$TARGET" --verbosity "$VERBOSITY" + ;; +esac + +EXIT_CODE=$? + +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 From 0746cbbabd350e5d5b22963bd620b1c603472fe4 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 05:30:00 +0000 Subject: [PATCH 06/11] fix(review): resolve sonarcloud quality gate, deepsource shell check, and reduce cognitive complexity --- .../Constants/GameContentConstants.cs | 35 + .../GenHub.Core/Helpers/ContentPathPolicy.cs | 65 +- .../Helpers/ContentPathPolicyTests.cs | 20 +- .../Common/ArchivePayloadProcessor.cs | 441 ++++++------ .../Common/ControlBarPackageProcessor.cs | 637 ++++++++++-------- scripts/build-check.sh | 77 ++- 6 files changed, 744 insertions(+), 531 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/GameContentConstants.cs b/GenHub/GenHub.Core/Constants/GameContentConstants.cs index 97f322ce6..146ef84c8 100644 --- a/GenHub/GenHub.Core/Constants/GameContentConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameContentConstants.cs @@ -122,6 +122,41 @@ public static class GameContentConstants "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. /// diff --git a/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs index 057fb3c9b..a165d5b67 100644 --- a/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs +++ b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs @@ -125,34 +125,14 @@ private static string ResolveRealPath(string path) var current = path; while (!string.IsNullOrEmpty(current)) { - if (File.Exists(current)) + if (TryResolveLink(current, path, out var resolvedPath)) { - var fileInfo = new FileInfo(current); - if (fileInfo.LinkTarget != null) - { - var target = fileInfo.ResolveLinkTarget(returnFinalTarget: true); - if (target != null) - { - var relativeSuffix = Path.GetRelativePath(current, path); - return relativeSuffix == "." ? target.FullName : Path.GetFullPath(Path.Combine(target.FullName, relativeSuffix)); - } - } - - break; + return resolvedPath; } - if (Directory.Exists(current)) + if (File.Exists(current)) { - var dirInfo = new DirectoryInfo(current); - if (dirInfo.LinkTarget != null) - { - var target = dirInfo.ResolveLinkTarget(returnFinalTarget: true); - if (target != null) - { - var relativeSuffix = Path.GetRelativePath(current, path); - return relativeSuffix == "." ? target.FullName : Path.GetFullPath(Path.Combine(target.FullName, relativeSuffix)); - } - } + break; } current = Path.GetDirectoryName(current); @@ -165,4 +145,41 @@ private static string ResolveRealPath(string path) 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.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs index 089133591..01fff88e1 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs @@ -8,7 +8,7 @@ namespace GenHub.Tests.Core.Helpers; /// /// Unit tests for . /// -public class ContentPathPolicyTests +public sealed class ContentPathPolicyTests : IDisposable { private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "GenHubTests_PathPolicy_" + Guid.NewGuid().ToString("N")); @@ -20,6 +20,22 @@ 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. /// @@ -59,7 +75,7 @@ public void ResolveContainedFile_PathEscapesRoot_ReturnsFailure(string malicious [InlineData(" ")] public void ResolveContainedFile_NullOrEmptyRelativePath_ReturnsFailure(string? invalidPath) { - var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, invalidPath!); + var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, invalidPath); Assert.False(result.Success); } diff --git a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs index e97cc23ac..36c2b07f9 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -225,7 +225,7 @@ private static bool IsSelfExtractingArchive(string filePath) } catch { - // Not a ZIP SFX + // Not a zip sfx } try @@ -271,33 +271,40 @@ private static bool IsSelfExtractingArchive(string filePath) private static long FindSignatureOffset(Stream stream, byte[] signature) { var buffer = new byte[8192]; - long offset = 0; - int read = 0; + long streamOffset = 0; + int read; int matchIndex = 0; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) { - for (int i = 0; i < read; i++) + var index = 0; + while (index < read) { - if (buffer[i] == signature[matchIndex]) + if (buffer[index] == signature[matchIndex]) { matchIndex++; if (matchIndex == signature.Length) { - return offset + i - signature.Length + 1; + return streamOffset + index - signature.Length + 1; } + + index++; } else { if (matchIndex > 0) { - i -= matchIndex; + index = index - matchIndex + 1; matchIndex = 0; } + else + { + index++; + } } } - offset += read; + streamOffset += read; } return -1; @@ -305,18 +312,9 @@ private static long FindSignatureOffset(Stream stream, byte[] signature) private static IReadOnlyList FindArchiveFiles(string rootDirectory, ContentType? contentType = null) { - var allFiles = Directory.GetFiles(rootDirectory, "*", SearchOption.AllDirectories); - var archives = new List(); - - foreach (var file in allFiles) - { - if (IsArchiveFile(file, contentType)) - { - archives.Add(file); - } - } - - return archives; + return Directory.GetFiles(rootDirectory, "*", SearchOption.AllDirectories) + .Where(file => IsArchiveFile(file, contentType)) + .ToList(); } private static void EnsureValidArchivePayload(string archivePath) @@ -458,13 +456,12 @@ private static void ExtractSharpCompressArchive( } var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, entry.Key); - if (!pathResult.Success) + if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data)) { throw new InvalidDataException($"Archive entry has an unsafe path: {entry.Key}"); } - var destinationPath = pathResult.Data!; - + var destinationPath = pathResult.Data; var destinationDir = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrEmpty(destinationDir)) { @@ -537,26 +534,7 @@ private static bool TryExtractZipArchive( $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); } - 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) - { - 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); + ExtractSingleZipEntry(entry, extractRoot, ref totalUncompressedSize, cancellationToken); } return true; @@ -575,6 +553,34 @@ private static bool TryExtractZipArchive( } } + 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, @@ -685,8 +691,26 @@ private static bool TryExtractSmartInstallMakerArchive( private static (byte[]? TableData, long PayloadOffset) ReadSmartInstallMakerMetadata(Stream stream) { - using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); + 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; @@ -716,36 +740,29 @@ private static (byte[]? TableData, long PayloadOffset) ReadSmartInstallMakerMeta } } - if (secondToLastBlock == null || lastBlock == null) - { - return (null, -1); - } + return (secondToLastBlock, lastBlock); + } - var payloadOffset = lastBlock.Value.DataStart; - var tableBlock = secondToLastBlock.Value; - if (tableBlock.CompType == 1) - { - stream.Position = tableBlock.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) + 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) { - totalDecompressed += r; - if (totalDecompressed > CatalogConstants.MaxCatalogSizeBytes) - { - throw new InvalidDataException("Smart Install Maker metadata table exceeds maximum allowed size."); - } - - ms.Write(buf, 0, r); + throw new InvalidDataException("Smart Install Maker metadata table exceeds maximum allowed size."); } - return (ms.ToArray(), payloadOffset); + ms.Write(buf, 0, r); } - return (null, payloadOffset); + return ms.ToArray(); } private static int ExtractSmartInstallMakerPayload( @@ -776,12 +793,12 @@ private static void ExtractSingleSmartInstallMakerRecord( byte[] copyBuffer) { var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, rec.Name); - if (!pathResult.Success) + 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 destinationPath = pathResult.Data; var destinationDir = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrEmpty(destinationDir)) { @@ -837,63 +854,82 @@ private static long TryDecompressSmartInstallMakerRecord( uint uncompressedSize, byte[] copyBuffer) { - long written = 0; - if (headerRead >= 2 && header[0] == 'B' && header[1] == 'Z') { - using var bz2 = SharpCompress.Compressors.BZip2.BZip2Stream.Create( - stream, - SharpCompress.Compressors.CompressionMode.Decompress, - decompressConcatenated: false, - leaveOpen: true); + return DecompressBz2SmartInstallMakerRecord(stream, destinationPath, uncompressedSize, copyBuffer); + } - using var outStream = File.Create(destinationPath); - while (written < uncompressedSize) - { - var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); - var readBytes = bz2.Read(copyBuffer, 0, toRead); - if (readBytes <= 0) - { - break; - } + 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); + } - outStream.Write(copyBuffer, 0, readBytes); - written += readBytes; + 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; } - else if (headerRead >= 2 && header[0] == 0x78 && (header[1] == 0xDA || header[1] == 0x9C || header[1] == 0x01 || header[1] == 0x5E)) + + 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) { - stream.Position = filePos + 2; // skip zlib header - using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); - using var outStream = File.Create(destinationPath); - while (written < uncompressedSize) + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = def.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) { - 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; + break; } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; } - else + + 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) { - using var outStream = File.Create(destinationPath); - while (written < uncompressedSize) + var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written); + var readBytes = stream.Read(copyBuffer, 0, toRead); + if (readBytes <= 0) { - 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; + break; } + + outStream.Write(copyBuffer, 0, readBytes); + written += readBytes; } return written; @@ -906,20 +942,23 @@ private static long TryDecompressSmartInstallMakerRecord( { var records = new List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)>(); var cumulativeUncompressedSize = 0L; + var index = 0; - for (var i = 0; i < tableData.Length - 4; i++) + while (index < tableData.Length - 4) { - if (tableData[i] != '.' || i < 40) + if (tableData[index] != '.' || index < 40) { + index++; continue; } - if (!TryExtractSimCandidateName(tableData, i, out var name, out var nextIndex, out var startOffset)) + if (!TryExtractSimCandidateName(tableData, index, out var name, out var nextIndex, out var startOffset)) { + index++; continue; } - i = nextIndex; + index = nextIndex; if (!IsValidSimEntryName(name)) { @@ -1086,7 +1125,7 @@ private static bool ContainsRecognizedGameContent(string directory) .Select(Path.GetFileName) .Where(name => !string.IsNullOrEmpty(name)); - if (subDirs.Any(name => GameContentConstants.RecognizedGameDirectories.Contains(name!, StringComparer.OrdinalIgnoreCase))) + if (subDirs.Any(name => GameContentConstants.RecognizedGameDirectories.Contains(name, StringComparer.OrdinalIgnoreCase))) { return true; } @@ -1095,7 +1134,7 @@ private static bool ContainsRecognizedGameContent(string directory) .Select(Path.GetExtension) .Where(ext => !string.IsNullOrEmpty(ext)); - return files.Any(ext => GameContentConstants.RecognizedGameFileExtensions.Contains(ext!, StringComparer.OrdinalIgnoreCase)); + return files.Any(ext => GameContentConstants.RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase)); } private static bool DirectoryContainsMapFilesDirectly(string directory) @@ -1120,80 +1159,95 @@ private static void PromoteDirectoryContents(string sourceDirectory, string targ foreach (var subFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories)) { - var relativePath = Path.GetRelativePath(tempStaging, subFile); - var destinationPath = Path.Combine(targetDirectory, relativePath); - var destinationDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(destinationDir)) - { - Directory.CreateDirectory(destinationDir); - } + PromoteSingleStagedFile(subFile, tempStaging, targetDirectory); + } + } + catch + { + RollbackStaging(tempStaging, sourceDirectory); + throw; + } + finally + { + CleanupStaging(tempStaging); + } + } - if (File.Exists(destinationPath)) - { - var destInfo = new FileInfo(destinationPath); - var srcInfo = new FileInfo(subFile); + 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 (destInfo.Length == srcInfo.Length && FilesHaveIdenticalContent(subFile, destinationPath)) - { - File.Delete(subFile); - continue; - } + if (File.Exists(destinationPath)) + { + var destInfo = new FileInfo(destinationPath); + var srcInfo = new FileInfo(subFile); - var newDestPath = GetNonCollidingDestinationPath(destinationPath); - File.Move(subFile, newDestPath); - } - else - { - File.Move(subFile, destinationPath); - } + if (destInfo.Length == srcInfo.Length && FilesHaveIdenticalContent(subFile, destinationPath)) + { + File.Delete(subFile); + return; } + + var newDestPath = GetNonCollidingDestinationPath(destinationPath); + File.Move(subFile, newDestPath); } - catch + else { - try + File.Move(subFile, destinationPath); + } + } + + private static void RollbackStaging(string tempStaging, string sourceDirectory) + { + try + { + if (!Directory.Exists(tempStaging)) { - if (Directory.Exists(tempStaging)) - { - if (!Directory.Exists(sourceDirectory)) - { - Directory.Move(tempStaging, sourceDirectory); - } - else - { - 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); - } - } - } + return; } - catch + + if (!Directory.Exists(sourceDirectory)) { - // Best effort rollback + Directory.Move(tempStaging, sourceDirectory); + return; } - throw; - } - finally - { - if (Directory.Exists(tempStaging)) + foreach (var remainingFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories)) { - try - { - Directory.Delete(tempStaging, recursive: true); - } - catch + var rel = Path.GetRelativePath(tempStaging, remainingFile); + var backPath = Path.Combine(sourceDirectory, rel); + var dir = Path.GetDirectoryName(backPath); + if (!string.IsNullOrEmpty(dir)) { - // Best effort cleanup + 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 } } } @@ -1251,12 +1305,11 @@ private static void CleanupEmptyDirectories(string rootDirectory) { try { - foreach (var subDir in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories).OrderByDescending(d => d.Length)) + foreach (var subDir in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length) + .Where(d => Directory.Exists(d) && !Directory.EnumerateFileSystemEntries(d).Any())) { - if (Directory.Exists(subDir) && !Directory.EnumerateFileSystemEntries(subDir).Any()) - { - Directory.Delete(subDir); - } + Directory.Delete(subDir); } } catch @@ -1438,7 +1491,7 @@ private void NormalizeGibExtensions(string extractedDirectory, ContentType conte } } - private sealed class SubStream(Stream baseStream, long offset, long length) : Stream + private sealed class SubStream(Stream baseStream, long streamOffset, long length) : Stream { private long _position; @@ -1463,7 +1516,7 @@ public override long Position public override void Flush() => baseStream.Flush(); - public override int Read(byte[] buffer, int offsetInBuffer, int count) + public override int Read(byte[] buffer, int offset, int count) { if (_position >= length) { @@ -1471,19 +1524,19 @@ public override int Read(byte[] buffer, int offsetInBuffer, int count) } var toRead = (int)Math.Min(count, length - _position); - baseStream.Position = offset + _position; - var read = baseStream.Read(buffer, offsetInBuffer, toRead); + baseStream.Position = streamOffset + _position; + var read = baseStream.Read(buffer, offset, toRead); _position += read; return read; } - public override long Seek(long offsetFromOrigin, SeekOrigin origin) + public override long Seek(long offset, SeekOrigin origin) { var target = origin switch { - SeekOrigin.Begin => offsetFromOrigin, - SeekOrigin.Current => _position + offsetFromOrigin, - SeekOrigin.End => length + offsetFromOrigin, + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => length + offset, _ => throw new ArgumentOutOfRangeException(nameof(origin)), }; Position = target; @@ -1492,6 +1545,6 @@ public override long Seek(long offsetFromOrigin, SeekOrigin origin) public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offsetInBuffer, int count) => 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 index 95cebb186..181f6f3d6 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs @@ -5,6 +5,7 @@ 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; @@ -23,46 +24,14 @@ public class ControlBarPackageProcessor( 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) { - if (manifest.ContentType is ContentType.Addon or ContentType.Mod) - { - var id = manifest.Id.Value.ToLowerInvariant(); - if (id.Contains("controlbar") || id.Contains("cbpr") || id.Contains("cbpx")) - { - return true; - } - - var name = manifest.Name.ToLowerInvariant(); - if (name.Contains("controlbar") || name.Contains("control bar") || name.Contains("control-bar")) - { - return true; - } - - if (manifest.Metadata?.Tags != null && - manifest.Metadata.Tags.Any(t => t.Contains("controlbar", StringComparison.OrdinalIgnoreCase) || - t.Contains("control-bar", StringComparison.OrdinalIgnoreCase))) - { - return true; - } - } - - if (Directory.Exists(extractedDirectory)) - { - if (Directory.GetFiles(extractedDirectory, "*ControlBar*.big", SearchOption.AllDirectories).Length > 0) - { - return true; - } - - if (Directory.GetFiles(extractedDirectory, "*ControlBar*.wnd", SearchOption.AllDirectories).Length > 0) - { - return true; - } - } - - return false; + return HasControlBarManifestMetadata(manifest) || HasControlBarFiles(extractedDirectory); } /// @@ -82,218 +51,16 @@ public async Task> ProcessAndRepackControlBarAsync( var repackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase); var variantBigRoot = FindControlBarVariantBigRoot(extractedDirectory, variantId); - 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); - } - - repackedOutputs.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: {ArtBig}, {DataBig}", - variantId, - artBigName, - dataBigName); - - 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-{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); - - 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 - { - // 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); - } - } - } - - if (File.Exists(artBigPath)) - { - repackedOutputs.Add(artBigName); - } - - if (File.Exists(dataBigPath)) - { - repackedOutputs.Add(dataBigName); - } - } - } - else - { - // Check for flat structure prebuilt BIG files - 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("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase) || - name.Equals("340_ControlBarProLemonEditionZH.big", StringComparison.OrdinalIgnoreCase); - })]; - } - - foreach (var candidate in prebuiltCandidates) - { - repackedOutputs.Add(Path.GetFileName(candidate)); - } - } - - // Check if an existing metadata / base BIG file is already included in outputs - var existingMetadataFileName = repackedOutputs.FirstOrDefault(name => - name.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase) || - name.Equals("340_ControlBarProLemonEditionZH.big", StringComparison.OrdinalIgnoreCase)); - - if (existingMetadataFileName != null) - { - logger.LogInformation("Using existing Control Bar metadata file {FileName}", existingMetadataFileName); + await ProcessVariantBigRootAsync(variantBigRoot, extractedDirectory, variantId, variantSuffix, repackedOutputs, cancellationToken); } else { - // Explicitly ensure metadata BIG file (340_ControlBarProZH.big) is included - 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", variantId, metadataFileName), - Path.Combine(extractedDirectory, "CCG", variantId, metadataFileName), - Path.Combine(extractedDirectory, "ZH", variantId, "BIG EN", metadataFileName), - Path.Combine(extractedDirectory, "ZH", variantId, "BIG", metadataFileName), - Path.Combine(extractedDirectory, "CCG", variantId, "BIG EN", metadataFileName), - Path.Combine(extractedDirectory, "CCG", variantId, "BIG", metadataFileName), - }; - - foreach (var searchPath in metadataSearchPaths) - { - if (File.Exists(searchPath)) - { - logger.LogInformation("Found Control Bar metadata file at {SourcePath}, copying to root", searchPath); - await TryCopyFileWithRetryAsync(searchPath, metadataTargetPath, logger); - break; - } - } - } - - if (File.Exists(metadataTargetPath)) - { - repackedOutputs.Add(metadataFileName); - logger.LogInformation("Including Control Bar metadata file {FileName} in outputs", metadataFileName); - } - else - { - logger.LogWarning("Control Bar metadata file not found, writing embedded fallback"); - try - { - var metadataBytes = Convert.FromBase64String(ControlBarMetadataBigBase64); - File.WriteAllBytes(metadataTargetPath, metadataBytes); - 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"); - } - } + CollectFlatPrebuiltBigs(extractedDirectory, variantSuffix, repackedOutputs); } - // Cleanup raw unpacked source directories so only the packaged files remain + await EnsureMetadataBigIncludedAsync(extractedDirectory, variantId, repackedOutputs, cancellationToken); CleanupSourceDirectories(extractedDirectory, repackedOutputs); return [.. repackedOutputs]; @@ -305,38 +72,36 @@ public async Task> ProcessAndRepackControlBarAsync( var rawSuffix = GetControlBarVariantSuffix(variantId); var candidates = new[] { - Path.Combine(extractedDirectory, "ZH", variantId, "BIG EN"), - Path.Combine(extractedDirectory, "ZH", variantId, "BIG"), + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigDirectoryName), Path.Combine(extractedDirectory, "ZH", variantId), - Path.Combine(extractedDirectory, "ZH", rawSuffix, "BIG EN"), - Path.Combine(extractedDirectory, "ZH", rawSuffix, "BIG"), + Path.Combine(extractedDirectory, "ZH", rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "ZH", rawSuffix, GameContentConstants.BigDirectoryName), Path.Combine(extractedDirectory, "ZH", rawSuffix), - Path.Combine(extractedDirectory, "CCG", variantId, "BIG EN"), - Path.Combine(extractedDirectory, "CCG", variantId, "BIG"), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigDirectoryName), Path.Combine(extractedDirectory, "CCG", variantId), - Path.Combine(extractedDirectory, "CCG", rawSuffix, "BIG EN"), - Path.Combine(extractedDirectory, "CCG", rawSuffix, "BIG"), + Path.Combine(extractedDirectory, "CCG", rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, "CCG", rawSuffix, GameContentConstants.BigDirectoryName), Path.Combine(extractedDirectory, "CCG", rawSuffix), - Path.Combine(extractedDirectory, variantId, "BIG EN"), - Path.Combine(extractedDirectory, variantId, "BIG"), + Path.Combine(extractedDirectory, variantId, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, variantId, GameContentConstants.BigDirectoryName), Path.Combine(extractedDirectory, variantId), - Path.Combine(extractedDirectory, rawSuffix, "BIG EN"), - Path.Combine(extractedDirectory, rawSuffix, "BIG"), + Path.Combine(extractedDirectory, rawSuffix, GameContentConstants.BigEnDirectoryName), + Path.Combine(extractedDirectory, rawSuffix, GameContentConstants.BigDirectoryName), Path.Combine(extractedDirectory, rawSuffix), }; - foreach (var candidate in candidates) + var existingCandidate = candidates.FirstOrDefault(Directory.Exists); + if (existingCandidate != null) { - if (Directory.Exists(candidate)) - { - return candidate; - } + return existingCandidate; } - if (Directory.Exists(Path.Combine(extractedDirectory, "Window")) || + 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, "GenTool"))) + Directory.Exists(Path.Combine(extractedDirectory, GameContentConstants.GenToolDirectoryName))) { return extractedDirectory; } @@ -367,17 +132,325 @@ public bool IsAllowedControlBarBig(string fileName, string variantSuffix) || 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(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("340_ControlBarProLemonEditionZH.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 async Task EnsureMetadataBigIncludedAsync( + string extractedDirectory, + string variantId, + HashSet repackedOutputs, + CancellationToken cancellationToken) + { + var existingMetadataFileName = repackedOutputs.FirstOrDefault(name => + name.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) || + name.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase)); + + 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)) @@ -393,27 +466,21 @@ private static string DetermineVariantId(string extractedDirectory, ContentManif if (manifest.Metadata?.Tags != null) { - foreach (var tag in manifest.Metadata.Tags) - { - var tagMatch = ExtractVariantToken(tag); - if (!string.IsNullOrEmpty(tagMatch)) - { - return tagMatch; - } - } - } + var tagMatch = manifest.Metadata.Tags + .Select(ExtractVariantToken) + .FirstOrDefault(t => !string.IsNullOrEmpty(t)); - // Check if resolution subfolders exist in extracted content - foreach (var candidate in KnownResolutionVariants) - { - if (Directory.Exists(Path.Combine(extractedDirectory, "ZH", candidate)) || - Directory.Exists(Path.Combine(extractedDirectory, candidate))) + if (!string.IsNullOrEmpty(tagMatch)) { - return candidate; + return tagMatch; } } - return "1080p"; + 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) @@ -423,7 +490,7 @@ private static string DetermineVariantId(string extractedDirectory, ContentManif return null; } - var match = Regex.Match(input, @"\b(720p?|900p?|1080p?|1440p?|2160p?|4k)\b", RegexOptions.IgnoreCase); + var match = WordVariantRegex.Match(input); if (match.Success) { var token = match.Value.ToLowerInvariant(); @@ -438,13 +505,8 @@ private static string DetermineVariantId(string extractedDirectory, ContentManif }; } - var inlineMatch = Regex.Match(input, @"(720p|900p|1080p|1440p|2160p|4k)", RegexOptions.IgnoreCase); - if (inlineMatch.Success) - { - return inlineMatch.Value.ToLowerInvariant(); - } - - return null; + var inlineMatch = InlineVariantRegex.Match(input); + return inlineMatch.Success ? inlineMatch.Value.ToLowerInvariant() : null; } private static void CopyDirectory(string sourceDir, string targetDir) @@ -474,6 +536,7 @@ private static async Task TryCopyFileWithRetryAsync(string source, string destin catch (IOException ex) when (attempt < maxRetries) { logger.LogWarning( + ex, "File copy attempt {Attempt}/{MaxRetries} failed for {Source}: {Message}. Retrying...", attempt, maxRetries, @@ -493,7 +556,7 @@ private void CleanupSourceDirectories(string extractedDirectory, HashSet try { - var targetSourceDirNames = new[] { "ZH", "CCG", "Art", "Data", "Window", "GenTool", "720p", "900p", "1080p", "1440p", "2160p", "4k" }; + 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); diff --git a/scripts/build-check.sh b/scripts/build-check.sh index 6e43d06a5..f7c60aaf0 100755 --- a/scripts/build-check.sh +++ b/scripts/build-check.sh @@ -2,15 +2,8 @@ # 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. -# -# 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 -set -u +set -euo pipefail MODE="check" PROJECT="" @@ -18,8 +11,15 @@ TIMEOUT_SECONDS=120 VERBOSITY="quiet" usage() { - sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' - exit 1 + 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 @@ -45,11 +45,11 @@ while [[ $# -gt 0 ]]; do shift 2 ;; -h|--help) - usage + usage || exit 1 ;; *) echo "[build-check] ERROR: Unknown argument: $1" >&2 - usage + usage || exit 1 ;; esac done @@ -62,25 +62,41 @@ case "$MODE" in ;; esac +case "$VERBOSITY" in + quiet|minimal|normal|detailed|diagnostic) ;; + *) + echo "[build-check] ERROR: Invalid verbosity '$VERBOSITY' (expected quiet, minimal, normal, detailed, or diagnostic)." >&2 + exit 1 + ;; +esac + +if ! [[ "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || [[ "$TIMEOUT_SECONDS" -le 0 ]]; then + echo "[build-check] ERROR: Timeout must be a positive integer." >&2 + exit 1 +fi + 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)) +LOCK_TIMEOUT="$TIMEOUT_SECONDS" log_status() { - printf '\033[2m[build-check]\033[0m %s\n' "$1" + local message="$1" + printf '\033[2m[build-check]\033[0m %s\n' "$message" + return 0 } log_err() { - printf '\033[2m[build-check]\033[0m \033[31mERROR: %s\033[0m\n' "$1" >&2 + local message="$1" + printf '\033[2m[build-check]\033[0m \033[31mERROR: %s\033[0m\n' "$message" >&2 + return 0 } cleanup() { rm -f "$LOCK_FILE" - if [[ -n "${LOCK_FD:-}" ]]; then - eval "exec $LOCK_FD>&-" - fi + exec 9>&- 2>/dev/null || true + return 0 } trap cleanup EXIT @@ -106,7 +122,17 @@ fi TARGET="$SOLUTION_FILE" NO_DEPENDENCIES=() if [[ -n "$PROJECT" ]]; then - TARGET="$SOLUTION_DIR/$PROJECT" + if [[ "$PROJECT" =~ \.\. ]]; then + log_err "Path traversal not allowed in project argument." + exit 1 + fi + + if [[ "$PROJECT" = /* ]]; then + TARGET="$PROJECT" + else + TARGET="$SOLUTION_DIR/$PROJECT" + fi + if [[ ! -f "$TARGET" ]]; then log_err "Project not found: $TARGET" exit 1 @@ -130,23 +156,26 @@ 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[@]}" + 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[@]}" + dotnet build "$TARGET" "${DOTNET_ARGS[@]}" || EXIT_CODE=$? ;; restore) log_status "Running NuGet restore on: $(basename "$TARGET")" - dotnet restore "$TARGET" --verbosity "$VERBOSITY" + dotnet restore "$TARGET" --verbosity "$VERBOSITY" || EXIT_CODE=$? + ;; + *) + log_err "Unhandled mode: $MODE" + EXIT_CODE=1 ;; esac -EXIT_CODE=$? - if [[ $EXIT_CODE -eq 0 ]]; then log_status "Completed successfully with no errors." else From 508c2262dba9814052f8e0cba8e23c7d0075e8a8 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 05:45:59 +0000 Subject: [PATCH 07/11] fix(review): resolve deepsource complexity and shell script findings --- .../Common/ArchivePayloadProcessor.cs | 91 +++++++++++-------- scripts/build-check.sh | 41 +++++---- 2 files changed, 75 insertions(+), 57 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs index 36c2b07f9..d0f468e3e 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -946,45 +946,58 @@ private static long DecompressRawSmartInstallMakerRecord(Stream stream, string d while (index < tableData.Length - 4) { - if (tableData[index] != '.' || index < 40) - { - index++; - continue; - } + index = ProcessNextSimCandidate(tableData, index, stream, payloadOffset, records, ref cumulativeUncompressedSize); + } - if (!TryExtractSimCandidateName(tableData, index, out var name, out var nextIndex, out var startOffset)) - { - index++; - continue; - } + return records; + } - index = nextIndex; + 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 (!IsValidSimEntryName(name)) - { - continue; - } + if (!TryExtractSimCandidateName(tableData, index, out var name, out var nextIndex, out var startOffset)) + { + return index + 1; + } - if (TryReadSimRecord(tableData, startOffset, name, stream, payloadOffset, records, out var record)) - { - if (records.Count >= CatalogConstants.MaxZipEntryCount) - { - throw new InvalidDataException( - $"Smart Install Maker archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}"); - } + if (IsValidSimEntryName(name) && + TryReadSimRecord(tableData, startOffset, name, stream, payloadOffset, records, out var record)) + { + ValidateAndAddSimRecord(record, records, ref cumulativeUncompressedSize); + } - cumulativeUncompressedSize += record.UncompressedSize; - if (cumulativeUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes) - { - throw new InvalidDataException( - $"Smart Install Maker archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes"); - } + return nextIndex; + } - records.Add(record); - } + 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}"); } - return records; + 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( @@ -1258,13 +1271,12 @@ private static string GetNonCollidingDestinationPath(string destinationPath) var fileNameWithoutExt = Path.GetFileNameWithoutExtension(destinationPath); var ext = Path.GetExtension(destinationPath); var counter = 1; - var newDestPath = string.Empty; - do + var newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); + while (File.Exists(newDestPath)) { - newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); counter++; + newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}"); } - while (File.Exists(newDestPath)); return newDestPath; } @@ -1283,9 +1295,14 @@ private static bool FilesHaveIdenticalContent(string file1, string file2) return false; } - var bytesRead1 = 0; - while ((bytesRead1 = s1.Read(buffer1, 0, bufferSize)) > 0) + while (true) { + var bytesRead1 = s1.Read(buffer1, 0, bufferSize); + if (bytesRead1 <= 0) + { + break; + } + var bytesRead2 = s2.Read(buffer2, 0, bufferSize); if (bytesRead1 != bytesRead2) { diff --git a/scripts/build-check.sh b/scripts/build-check.sh index f7c60aaf0..26ddb9151 100755 --- a/scripts/build-check.sh +++ b/scripts/build-check.sh @@ -10,6 +10,18 @@ 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: @@ -48,7 +60,7 @@ while [[ $# -gt 0 ]]; do usage || exit 1 ;; *) - echo "[build-check] ERROR: Unknown argument: $1" >&2 + log_err "Unknown argument: $1" usage || exit 1 ;; esac @@ -57,7 +69,7 @@ done case "$MODE" in check|build|restore) ;; *) - echo "[build-check] ERROR: Invalid mode '$MODE' (expected check, build, or restore)." >&2 + log_err "Invalid mode '$MODE' (expected check, build, or restore)." exit 1 ;; esac @@ -65,13 +77,13 @@ esac case "$VERBOSITY" in quiet|minimal|normal|detailed|diagnostic) ;; *) - echo "[build-check] ERROR: Invalid verbosity '$VERBOSITY' (expected quiet, minimal, normal, detailed, or diagnostic)." >&2 + log_err "Invalid verbosity '$VERBOSITY' (expected quiet, minimal, normal, detailed, or diagnostic)." exit 1 ;; esac if ! [[ "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || [[ "$TIMEOUT_SECONDS" -le 0 ]]; then - echo "[build-check] ERROR: Timeout must be a positive integer." >&2 + log_err "Timeout must be a positive integer." exit 1 fi @@ -81,18 +93,6 @@ SOLUTION_FILE="$SOLUTION_DIR/GenHub.sln" LOCK_FILE="$SOLUTION_DIR/build.lock" LOCK_TIMEOUT="$TIMEOUT_SECONDS" -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 -} - cleanup() { rm -f "$LOCK_FILE" exec 9>&- 2>/dev/null || true @@ -122,12 +122,12 @@ fi TARGET="$SOLUTION_FILE" NO_DEPENDENCIES=() if [[ -n "$PROJECT" ]]; then - if [[ "$PROJECT" =~ \.\. ]]; then + if [[ "$PROJECT" == *".."* ]]; then log_err "Path traversal not allowed in project argument." exit 1 fi - if [[ "$PROJECT" = /* ]]; then + if [[ "$PROJECT" == /* ]]; then TARGET="$PROJECT" else TARGET="$SOLUTION_DIR/$PROJECT" @@ -149,8 +149,9 @@ if ! flock -w "$LOCK_TIMEOUT" 9; then 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}" "$(date -Iseconds)" >"$LOCK_FILE" + "$$" "$MODE" "${PROJECT:-GenHub.sln}" "$CURRENT_TIME" >"$LOCK_FILE" log_status "Build lock acquired." @@ -182,4 +183,4 @@ else log_err "Build/check failed with exit code: $EXIT_CODE" fi -exit $EXIT_CODE +exit "$EXIT_CODE" From 9a5932d22a084075f7cdaf7d678a7c1c32ead25f Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 05:53:07 +0000 Subject: [PATCH 08/11] fix(review): resolve DeepSource shell and C# findings - Replace bash regex comparisons with case globs to satisfy SH-3015 - Inline cleanup into EXIT trap to satisfy SH-2329 - Initialize 'read' variable to satisfy CS-W1022 --- .../Services/Common/ArchivePayloadProcessor.cs | 2 +- scripts/build-check.sh | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs index d0f468e3e..437368810 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -272,7 +272,7 @@ private static long FindSignatureOffset(Stream stream, byte[] signature) { var buffer = new byte[8192]; long streamOffset = 0; - int read; + int read = 0; int matchIndex = 0; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) diff --git a/scripts/build-check.sh b/scripts/build-check.sh index 26ddb9151..bb2738f81 100755 --- a/scripts/build-check.sh +++ b/scripts/build-check.sh @@ -82,10 +82,12 @@ case "$VERBOSITY" in ;; esac -if ! [[ "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || [[ "$TIMEOUT_SECONDS" -le 0 ]]; then - log_err "Timeout must be a positive integer." - exit 1 -fi +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" @@ -93,12 +95,7 @@ SOLUTION_FILE="$SOLUTION_DIR/GenHub.sln" LOCK_FILE="$SOLUTION_DIR/build.lock" LOCK_TIMEOUT="$TIMEOUT_SECONDS" -cleanup() { - rm -f "$LOCK_FILE" - exec 9>&- 2>/dev/null || true - return 0 -} -trap cleanup EXIT +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 From a6709f745f3a784b14782ddef932addc6d490496 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 06:15:00 +0000 Subject: [PATCH 09/11] fix(review): replace [[ == ]] comparisons with case statements for SH-3014 --- scripts/build-check.sh | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/scripts/build-check.sh b/scripts/build-check.sh index bb2738f81..159f7b856 100755 --- a/scripts/build-check.sh +++ b/scripts/build-check.sh @@ -119,16 +119,17 @@ fi TARGET="$SOLUTION_FILE" NO_DEPENDENCIES=() if [[ -n "$PROJECT" ]]; then - if [[ "$PROJECT" == *".."* ]]; then - log_err "Path traversal not allowed in project argument." - exit 1 - fi + case "$PROJECT" in + *..*) + log_err "Path traversal not allowed in project argument." + exit 1 + ;; + esac - if [[ "$PROJECT" == /* ]]; then - TARGET="$PROJECT" - else - TARGET="$SOLUTION_DIR/$PROJECT" - fi + case "$PROJECT" in + /*) TARGET="$PROJECT" ;; + *) TARGET="$SOLUTION_DIR/$PROJECT" ;; + esac if [[ ! -f "$TARGET" ]]; then log_err "Project not found: $TARGET" From 7a99d5ed35c53e62ccae09676c7a750d70c4c76f Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 06:20:45 +0000 Subject: [PATCH 10/11] fix(review): resolve SonarCloud and DeepSource findings, gate destructive cleanup - ArchivePayloadProcessor: remove log-and-rethrow (S2139); caller logs with context - ArchivePayloadProcessor: rewrite FindSignatureOffset with span search and overlap carry (S3776) - ArchivePayloadProcessor: filter empty directories before ordering (S6607) - ControlBarPackageProcessor: skip destructive source cleanup when only fallback metadata was produced - build-check.sh: replace POSIX-undefined == pattern matches with case; add default case (S131) Reviewed-by: ox-alpha (opencode/x-preview-f-free) --- .../Common/ArchivePayloadProcessor.cs | 65 ++++++------------- .../Common/ControlBarPackageProcessor.cs | 11 +++- scripts/build-check.sh | 14 ++-- 3 files changed, 38 insertions(+), 52 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs index 437368810..de1debc5b 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -63,20 +63,12 @@ public Task ExtractArchivesSafelyAsync( { cancellationToken.ThrowIfCancellationRequested(); - try - { - 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); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to extract archive: {ArchivePath}", archivePath); - throw; - } + 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); } } @@ -270,41 +262,26 @@ private static bool IsSelfExtractingArchive(string filePath) private static long FindSignatureOffset(Stream stream, byte[] signature) { + // 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[8192]; long streamOffset = 0; - int read = 0; - int matchIndex = 0; + var buffered = 0; + var read = 0; - while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + while ((read = stream.Read(buffer.AsSpan(buffered))) > 0) { - var index = 0; - while (index < read) + var available = buffered + read; + var index = buffer.AsSpan(0, available).IndexOf(signature); + if (index >= 0) { - if (buffer[index] == signature[matchIndex]) - { - matchIndex++; - if (matchIndex == signature.Length) - { - return streamOffset + index - signature.Length + 1; - } - - index++; - } - else - { - if (matchIndex > 0) - { - index = index - matchIndex + 1; - matchIndex = 0; - } - else - { - index++; - } - } + return streamOffset + index; } - streamOffset += read; + buffered = Math.Min(available, overlap); + buffer.AsSpan(available - buffered, buffered).CopyTo(buffer); + streamOffset += available - buffered; } return -1; @@ -1323,8 +1300,8 @@ private static void CleanupEmptyDirectories(string rootDirectory) try { foreach (var subDir in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories) - .OrderByDescending(d => d.Length) - .Where(d => Directory.Exists(d) && !Directory.EnumerateFileSystemEntries(d).Any())) + .Where(d => Directory.Exists(d) && !Directory.EnumerateFileSystemEntries(d).Any()) + .OrderByDescending(d => d.Length)) { Directory.Delete(subDir); } diff --git a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs index 181f6f3d6..0569967e9 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs @@ -549,8 +549,17 @@ private static async Task TryCopyFileWithRetryAsync(string source, string destin private void CleanupSourceDirectories(string extractedDirectory, HashSet repackedOutputs) { - if (repackedOutputs.Count == 0) + // 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 => + !name.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) && + !name.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase)); + + if (!hasPackagedContent) { + logger.LogWarning( + "Skipping Control Bar source cleanup because no content BIG files were produced for {Directory}", + extractedDirectory); return; } diff --git a/scripts/build-check.sh b/scripts/build-check.sh index 159f7b856..aa985c90c 100755 --- a/scripts/build-check.sh +++ b/scripts/build-check.sh @@ -87,6 +87,8 @@ case "$TIMEOUT_SECONDS" in log_err "Timeout must be a positive integer." exit 1 ;; + *) + ;; esac SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -120,15 +122,13 @@ TARGET="$SOLUTION_FILE" NO_DEPENDENCIES=() if [[ -n "$PROJECT" ]]; then case "$PROJECT" in - *..*) - log_err "Path traversal not allowed in project argument." + ..*|*..*|/*) + log_err "Project must be a relative path under $SOLUTION_DIR (no '..' segments or absolute paths)." exit 1 ;; - esac - - case "$PROJECT" in - /*) TARGET="$PROJECT" ;; - *) TARGET="$SOLUTION_DIR/$PROJECT" ;; + *) + TARGET="$SOLUTION_DIR/$PROJECT" + ;; esac if [[ ! -f "$TARGET" ]]; then From fe129604268a037241a5a569a8990db01a603bcb Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 26 Aug 2026 06:34:50 +0000 Subject: [PATCH 11/11] fix(review): centralize metadata-only BIG check, guard empty signature, use named buffer constant - ControlBarPackageProcessor: extract IsMetadataOnlyBig so the cleanup gate and metadata detection share one source of truth - ArchivePayloadProcessor: return early for empty signatures in FindSignatureOffset - IoConstants: add SignatureScanBufferSize and use it for signature scanning Reviewed-by: ox-alpha (opencode/x-preview-f-free) --- GenHub/Directory.Packages.props | 2 +- GenHub/GenHub.Core/Constants/IoConstants.cs | 5 +++++ .../Services/Common/ArchivePayloadProcessor.cs | 7 ++++++- .../Services/Common/ControlBarPackageProcessor.cs | 14 ++++++++------ 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index b9240c8c1..8c7e0e70c 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -56,4 +56,4 @@ - \ No newline at end of file + 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/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs index de1debc5b..77c7973ed 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs @@ -262,10 +262,15 @@ private static bool IsSelfExtractingArchive(string filePath) 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[8192]; + var buffer = new byte[IoConstants.SignatureScanBufferSize]; long streamOffset = 0; var buffered = 0; var read = 0; diff --git a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs index 0569967e9..68fc6559a 100644 --- a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs +++ b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs @@ -371,15 +371,19 @@ private void CollectFlatPrebuiltBigs( } } + 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(name => - name.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) || - name.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase)); + var existingMetadataFileName = repackedOutputs.FirstOrDefault(IsMetadataOnlyBig); if (existingMetadataFileName != null) { @@ -551,9 +555,7 @@ private void CleanupSourceDirectories(string extractedDirectory, HashSet { // 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 => - !name.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) && - !name.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase)); + var hasPackagedContent = repackedOutputs.Any(name => !IsMetadataOnlyBig(name)); if (!hasPackagedContent) {