diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f8993..e631892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ ship. ### Added +- **Source SDK builds.** Workspace consumers can opt into `CheatEngineSdkSourceRoot` to build Client and SDK together + through project references. Source builds record the SDK Engine version and Git commit from its PE metadata, + preserve plugin profile checks, and reject mismatched runtime identities. Normal NuGet builds retain their existing + package identity checks. + ### Changed - **Local process enumeration.** Ignore nonpositive operating-system process IDs, including the Windows idle diff --git a/Directory.Build.targets b/Directory.Build.targets index 6d0d2b8..6aa7272 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,4 +1,9 @@ + + + - diff --git a/README.md b/README.md index a88537f..ee18235 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,34 @@ The aggregate `ICheatEngineClient` gives an enabled plugin access to runtime fac dispatch, process selection, typed memory, AOB scans, inspection, address tables, and protected Lua operations. It never exposes a `LuaState`, CE object handle, raw native pointer, or SDK ownership wrapper to plugin code. +### Building with an SDK source checkout + +NuGet remains the default. A workspace that builds Client and SDK from source can place `CheatEngine.Source.props` +beside the Client checkout with these settings: + +```xml + + + $(MSBuildThisFileDirectory)CheatEngine.SDK + $(MSBuildThisFileDirectory)locks + + +``` + +The SDK root must contain its Engine and Hosting runtime projects. The optional lock directory keeps this source +graph separate from Client's committed NuGet locks. Parent-workspace settings are read during both restore and build; +`CheatEngineImportSourceSettings=false` disables the import. Command-line properties can select the same settings. +Clone complete Git history and tags so MinVer can derive the SDK assembly version and source revision. + +Source consumers reference the Client facade project. A plugin also directly references the SDK Engine and Hosting +projects, imports the SDK and Client consumer build assets, and references their plugin generators as analyzers; +project references do not propagate NuGet build assets or analyzers. The SDK's checked-in native bridge remains part +of the plugin output. Source mode reads the consumed Engine PE metadata without executing it and requires that exact +version and commit at runtime. It does not claim a NuGet package hash or native qualification. + +Package publication requires the normal NuGet graph; `CHEATENGINECLIENT9051` refuses source-mode packing or an invalid +SDK source root. Restore and validate the package graph before publishing Client packages. + ## Why this project exists `CheatEngine.SDK` deliberately owns the difficult boundary: the generated Cheat Engine entry point, Lua protection, diff --git a/eng/CheatEngineSdk.props b/eng/CheatEngineSdk.props index c1ab5dc..7f5708b 100644 --- a/eng/CheatEngineSdk.props +++ b/eng/CheatEngineSdk.props @@ -1,4 +1,8 @@ + + + + true + <_CheatEngineSdkSourceDirectory Condition="'$(CheatEngineSdkSourceMode)' == 'true'">$([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::GetFullPath('$(CheatEngineSdkSourceRoot)')))) + $(_CheatEngineSdkSourceDirectory)libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj + $(_CheatEngineSdkSourceDirectory)libs/CheatEngine.SDK.Hosting/CheatEngine.SDK.Hosting.csproj + $(CheatEngineSourceLockFileDirectory)/$(MSBuildProjectName).lock.json 2.0.0 3.0.0 [$(CheatEngineSdkVersion),$(CheatEngineSdkUpperBound)) <_CheatEngineClientSupportedSdkMajor>2 + + + diff --git a/eng/SourceSdkIdentity.targets b/eng/SourceSdkIdentity.targets new file mode 100644 index 0000000..c4521e8 --- /dev/null +++ b/eng/SourceSdkIdentity.targets @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + (?[0-9]+)\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)\+(?[0-9a-f]{40})$"); + if (!identity.Success || identity.Groups["major"].Value != SupportedMajor) + throw new InvalidDataException("The source SDK must declare its supported major, semantic version and full Git commit in AssemblyInformationalVersion."); + SdkVersion = identity.Groups["version"].Value; + SourceCommit = identity.Groups["commit"].Value; + } +} +catch (Exception exception) +{ + Log.LogError(null, "CHEATENGINECLIENT9050", null, AssemblyPath, 0, 0, 0, 0, + "Cannot establish the consumed source SDK identity: {0}", exception.Message); + return false; +} + ]]> + + + + + <_CheatEngineClientSourceEngine Include="@(ReferenceCopyLocalPaths)" + Condition="'%(ReferenceCopyLocalPaths.Filename)%(ReferenceCopyLocalPaths.Extension)' == 'CheatEngine.SDK.Engine.dll'"/> + + + + + + + + + + + + + diff --git a/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj b/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj index e46d5da..ee09b8a 100644 --- a/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj +++ b/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj @@ -6,7 +6,9 @@ - + + diff --git a/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj index 186362c..0042386 100644 --- a/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj +++ b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj @@ -11,7 +11,9 @@ - + + + @@ -38,7 +40,7 @@ Property functions: https://learn.microsoft.com/visualstudio/msbuild/property-functions AssemblyMetadata items: https://learn.microsoft.com/dotnet/core/project-sdk/msbuild-props#assemblymetadata --> - + <_CheatEngineClientSdkLockFile>$(MSBuildThisFileDirectory)packages.lock.json <_CheatEngineClientSdkLockJson Condition="Exists('$(_CheatEngineClientSdkLockFile)')">$([System.IO.File]::ReadAllText('$(_CheatEngineClientSdkLockFile)')) <_CheatEngineClientLockedSdkVersion Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_CheatEngineClientSdkLockJson)', '"CheatEngine\.SDK"\s*:\s*\{[^}]*?"resolved"\s*:\s*"[0-9A-Za-z.+-]+"'))">$([System.Text.RegularExpressions.Regex]::Replace('$(_CheatEngineClientSdkLockJson)', '(?s)^.*?"CheatEngine\.SDK"\s*:\s*\{[^}]*?"resolved"\s*:\s*"([0-9A-Za-z.+-]+)".*$', '$1')) @@ -59,7 +61,7 @@ <_CheatEngineClientEmbedConsumedSdk Condition="'$(_CheatEngineClientConsumedSdkProblem)' == ''">true - + @@ -71,9 +73,11 @@ producing a Core assembly whose package gates would all report Unknown. Design-time builds are exempt. --> + Condition="'$(DesignTimeBuild)' != 'true' and '$(CheatEngineSdkSourceMode)' != 'true'"> + + diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs b/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs index 8d976d7..568d5e3 100644 --- a/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs +++ b/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs @@ -21,6 +21,11 @@ namespace CheatEngine.Client.Core.Infrastructure; /// this source. /// /// +/// With CheatEngineSdkSourceRoot, the build instead reads the referenced Engine assembly's PE metadata. +/// It records source kind, version, Git commit and supported major, with no NuGet content hash. A source build +/// requires that exact informational version at runtime; another source revision is not interchangeable. +/// +/// /// The package gate follows the dependency range the Client packages declare: it is /// when the SemVer version of the loaded /// CheatEngine.SDK.Engine informational version (build metadata ignored) has the supported major and is at @@ -48,6 +53,12 @@ internal sealed class ConsumedSdkIdentity /// The assembly-metadata key of the consumed CheatEngine.SDK NuGet content hash (SHA-512, base64). internal const string ContentHashKey = "CheatEngine.Client.ConsumedSdk.ContentHashSha512"; + /// The assembly-metadata key distinguishing source builds from package builds. + internal const string KindKey = "CheatEngine.Client.ConsumedSdk.Kind"; + + /// The metadata value for a source project dependency. + internal const string SourceKind = "source"; + /// The assembly-metadata key of the one CheatEngine.SDK major this Client supports. internal const string SupportedMajorKey = "CheatEngine.Client.ConsumedSdk.SupportedMajor"; @@ -69,8 +80,9 @@ internal sealed class ConsumedSdkIdentity /// The embedded NuGet content hash of the consumed package. /// The embedded supported major, as the decimal text of the assembly metadata. /// The informational version of the loaded CheatEngine.SDK.Engine. + /// Whether the consumed identity was read from a source-built Engine assembly. internal ConsumedSdkIdentity(string? version, string? sourceCommit, string? contentHashSha512, - string? supportedMajor, string? loadedInformationalVersion) + string? supportedMajor, string? loadedInformationalVersion, bool source = false) { Version = Normalize(version); SourceCommit = Normalize(sourceCommit); @@ -79,6 +91,7 @@ internal ConsumedSdkIdentity(string? version, string? sourceCommit, string? cont ? major : null; LoadedInformationalVersion = Normalize(loadedInformationalVersion); + IsSource = source; (PackageGate, IdentityLabel, ExactReviewedIdentity) = Evaluate(); } @@ -109,6 +122,12 @@ internal string? ContentHashSha512 get; } + /// Gets whether this build consumed an SDK source project instead of a NuGet package. + internal bool IsSource + { + get; + } + /// Gets the embedded CheatEngine.SDK major this Client supports, or . internal int? SupportedMajor { @@ -121,9 +140,9 @@ internal string? LoadedInformationalVersion get; } - /// Gets whether the version, commit, content hash and supported major were all embedded at build. - internal bool IsEmbedded => Version is not null && SourceCommit is not null && ContentHashSha512 is not null && - SupportedMajor is not null; + /// Gets whether the identity is complete; a source build has no NuGet content hash. + internal bool IsEmbedded => Version is not null && SourceCommit is not null && SupportedMajor is not null && + (IsSource || ContentHashSha512 is not null); /// /// Gets the informational version of the reviewed package, or when not embedded. @@ -189,6 +208,19 @@ internal string IdentityLabel } string supported = $"{SupportedMajor}.x at or above {Version}"; + if (IsSource) + { + bool exact = string.Equals(LoadedInformationalVersion, ExpectedInformationalVersion, StringComparison.Ordinal); + return exact + ? (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Satisfied, + $"The loaded CheatEngine.SDK.Engine {LoadedInformationalVersion} matches the source build " + + "consumed by this Client build; its identity was read from the Engine PE metadata at build time."), + "the consumed source build", true) + : (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Missing, + $"The loaded CheatEngine.SDK.Engine {LoadedInformationalVersion} does not match the source build " + + $"consumed by this Client build ({ExpectedInformationalVersion})."), "another source build", false); + } + if (string.Equals(LoadedInformationalVersion, ExpectedInformationalVersion, StringComparison.Ordinal)) { return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Satisfied, @@ -216,6 +248,7 @@ private static ConsumedSdkIdentity ReadCurrent() string? version = null; string? sourceCommit = null; string? contentHash = null; + bool source = false; string? supportedMajor = null; foreach (AssemblyMetadataAttribute metadata in typeof(ConsumedSdkIdentity).Assembly .GetCustomAttributes()) @@ -231,6 +264,9 @@ private static ConsumedSdkIdentity ReadCurrent() case ContentHashKey: contentHash = metadata.Value; break; + case KindKey: + source = string.Equals(metadata.Value, SourceKind, StringComparison.Ordinal); + break; case SupportedMajorKey: supportedMajor = metadata.Value; break; @@ -239,7 +275,7 @@ private static ConsumedSdkIdentity ReadCurrent() string? loaded = typeof(RuntimeInfo).Assembly.GetCustomAttribute() ?.InformationalVersion; - return new ConsumedSdkIdentity(version, sourceCommit, contentHash, supportedMajor, loaded); + return new ConsumedSdkIdentity(version, sourceCommit, contentHash, supportedMajor, loaded, source); } private static string? Normalize(string? value) @@ -369,12 +405,7 @@ private static int CompareIdentifiers(string left, string right) : string.CompareOrdinal(left, right); } - if (leftNumeric != rightNumeric) - { - return leftNumeric ? -1 : 1; - } - - return string.CompareOrdinal(left, right); + return leftNumeric != rightNumeric ? leftNumeric ? -1 : 1 : string.CompareOrdinal(left, right); } /// diff --git a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj index eb0c741..44ad229 100644 --- a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj +++ b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj @@ -20,7 +20,9 @@ ReferenceOutputAssembly="false" PrivateAssets="all" PackAsAnalyzer="true"/> - + + diff --git a/libs/CheatEngine.Client.Hosting/README.md b/libs/CheatEngine.Client.Hosting/README.md index a3e6167..8b52a4b 100644 --- a/libs/CheatEngine.Client.Hosting/README.md +++ b/libs/CheatEngine.Client.Hosting/README.md @@ -172,8 +172,8 @@ deployment folder"). `CheatEngineClientSkipPluginProjectValidation=true` skips t | Code | Severity | Reported when | Fix | |---|---|---|---| -| `CECLIENT001` | Error | The plugin project has no direct `PackageReference` to `CheatEngine.SDK` | Reference `CheatEngine.SDK` 2.x directly: its entry-point generator and Lua bridge run only for a direct reference | -| `CECLIENT002` | Error | The plugin project has no direct `PackageReference` to `CheatEngine.Client`, for example when it references `CheatEngine.Client.Hosting` only | Reference `CheatEngine.Client` directly | +| `CECLIENT001` | Error | The plugin has neither a direct SDK package reference nor both Engine and Hosting project references below `CheatEngineSdkSourceRoot` | Reference SDK 2.x directly, or use its source projects with explicit generator and bridge imports | +| `CECLIENT002` | Error | The plugin has neither a direct Client package reference nor the canonical Client facade project reference | Reference `CheatEngine.Client` directly | | `CECLIENT003` | Error | The compiled assembly declares no `[CheatEnginePlugin]` type derived from `CheatEngineClientPlugin` | Declare exactly one | | `CECLIENT004` | Error | The compiled assembly declares more than one such type | Keep one plugin type per assembly | | `CECLIENT005` | Error | `TargetFramework` is not exactly `net10.0`, for example `net10.0-windows` | Target `net10.0` | @@ -188,7 +188,7 @@ deployment folder"). `CheatEngineClientSkipPluginProjectValidation=true` skips t | `CECLIENT014` | Error | Deployment: `CheatEngine.Client.Hosting.dll` is not in the build output | Reference `CheatEngine.Client` and keep package assemblies copied to the output | | `CECLIENT015` | Error | Deployment: `cheatengine-sdk-lua-bridge.dll` is not in the build output, because the direct `CheatEngine.SDK` build asset did not copy it | Reference `CheatEngine.SDK` directly and keep its bridge `Content` item | | `CECLIENT016` | Error | Deployment: the output holds no managed file, or staging or replacing a destination file failed; the message gives the reason | Check the destination folder and deploy while the plugin is disabled | -| `CECLIENT017` | Error; Warning with `CheatEngineClientAllowUnsupportedSdk=true` | The plugin resolves a `CheatEngine.SDK` major this release does not support (3.x or later) | Reference `CheatEngine.SDK` 2.x, or a Client release that supports that SDK | +| `CECLIENT017` | Error; Warning with `CheatEngineClientAllowUnsupportedSdk=true` | The plugin resolves an unsupported SDK major (3.x or later), or a source Engine assembly whose identity cannot be established | Reference SDK 2.x, or a Client release that supports that SDK | A `CheatEngine.SDK` below 2.0.0 never reaches these checks: the restore fails with `NU1605`. The SDK's own build and analyzer diagnostics (`CESDK...`) are documented in the diff --git a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets index 3502f1b..189e639 100644 --- a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets +++ b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets @@ -20,19 +20,35 @@ + + <_CheatEngineClientExpectedEngineSource Condition="'$(CheatEngineSdkSourceRoot)' != ''">$([MSBuild]::NormalizePath('$(CheatEngineSdkSourceRoot)', 'libs', 'CheatEngine.SDK.Engine', 'CheatEngine.SDK.Engine.csproj')) + <_CheatEngineClientExpectedHostingSource Condition="'$(CheatEngineSdkSourceRoot)' != ''">$([MSBuild]::NormalizePath('$(CheatEngineSdkSourceRoot)', 'libs', 'CheatEngine.SDK.Hosting', 'CheatEngine.SDK.Hosting.csproj')) + <_CheatEngineClientExpectedFacadeSource>$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '../../../src/CheatEngine.Client/CheatEngine.Client.csproj')) + <_CheatEngineClientDirectPackageReference Include="@(PackageReference)"/> <_CheatEngineClientDirectSdkReference Include="@(_CheatEngineClientDirectPackageReference)" Condition="'%(Identity)' == 'CheatEngine.SDK'"/> <_CheatEngineClientDirectClientReference Include="@(_CheatEngineClientDirectPackageReference)" Condition="'%(Identity)' == 'CheatEngine.Client'"/> + <_CheatEngineClientDirectSdkEngineSourceReference Include="@(ProjectReference)" + Condition="'$(CheatEngineSdkSourceRoot)' != '' and '%(ProjectReference.FullPath)' == '$(_CheatEngineClientExpectedEngineSource)'"/> + <_CheatEngineClientDirectSdkHostingSourceReference Include="@(ProjectReference)" + Condition="'$(CheatEngineSdkSourceRoot)' != '' and '%(ProjectReference.FullPath)' == '$(_CheatEngineClientExpectedHostingSource)'"/> + <_CheatEngineClientDirectClientSourceReference Include="@(ProjectReference)" + Condition="'%(ProjectReference.FullPath)' == '$(_CheatEngineClientExpectedFacadeSource)'"/> - - + <_CheatEngineClientHasDirectSdkSourceReference>false + <_CheatEngineClientHasDirectSdkSourceReference Condition="'@(_CheatEngineClientDirectSdkEngineSourceReference)' != '' and '@(_CheatEngineClientDirectSdkHostingSourceReference)' != ''">true + <_CheatEngineClientHasDirectClientSourceReference>false + <_CheatEngineClientHasDirectClientSourceReference Condition="'@(_CheatEngineClientDirectClientSourceReference)' != ''">true + + - @@ -71,7 +87,7 @@ <_CheatEngineClientResolvedSdkAsset Remove="@(_CheatEngineClientResolvedSdkAsset)"/> @@ -80,10 +96,20 @@ <_CheatEngineClientResolvedSdkAsset Include="@(RuntimeCopyLocalItems)" Condition="'%(RuntimeCopyLocalItems.NuGetPackageId)' == 'CheatEngine.SDK'"/> + + <_CheatEngineClientSourceSdkAssembly Include="@(ReferencePath)" + Condition="'$(CheatEngineSdkSourceRoot)' != '' and '%(ReferencePath.Filename)%(ReferencePath.Extension)' == 'CheatEngine.SDK.Engine.dll'"/> + + + + <_CheatEngineClientResolvedSdkVersion>@(_CheatEngineClientResolvedSdkAsset->'%(NuGetPackageVersion)'->Distinct()) + <_CheatEngineClientResolvedSdkVersion Condition="'$(CheatEngineSdkSourceRoot)' != ''">@(_CheatEngineClientSourceSdkIdentity->'%(Version)') <_CheatEngineClientResolvedSdkMajor>$([System.Text.RegularExpressions.Regex]::Match('$(_CheatEngineClientResolvedSdkVersion)', '^[0-9]+').Value) <_CheatEngineClientUnsupportedSdk>false + <_CheatEngineClientUnsupportedSdk Condition="'$(CheatEngineSdkSourceRoot)' != '' and '$(_CheatEngineClientResolvedSdkMajor)' == ''">true + <_CheatEngineClientUnsupportedSdk Condition="'$(CheatEngineSdkSourceRoot)' != '' and '$(_CheatEngineClientResolvedSdkMajor)' != '' and '$(_CheatEngineClientResolvedSdkMajor)' != '$([MSBuild]::Subtract($(_CheatEngineClientSdkUpperMajor), 1))'">true <_CheatEngineClientUnsupportedSdk Condition="'$(_CheatEngineClientResolvedSdkMajor)' != '' and $(_CheatEngineClientResolvedSdkMajor) >= $(_CheatEngineClientSdkUpperMajor)">true Runs the build-transitive source-reference profile guard without restoring or compiling a consumer. +public sealed class SourceConsumerProfileTests +{ + private const string ValidationTarget = "CheatEngineClientValidatePluginProjectProfile"; + private const string HostingTargets = "libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets"; + private const string ClientFacade = "src/CheatEngine.Client/CheatEngine.Client.csproj"; + + [Fact] + public async Task CanonicalClientFacadeAndExactSdkSourceProjectsPassAsync() + { + using TemporaryDirectory directory = new("source-consumer-profile-valid"); + string sourceRoot = await CreateSdkSourceRootAsync(directory); + string project = await CreateConsumerProjectAsync(directory, sourceRoot, + [RepositoryLayout.Combine(ClientFacade), EngineProject(sourceRoot), HostingProject(sourceRoot)]); + + DotNetProcessResult result = await RunValidationAsync(project); + + Assert.True(result.ExitCode == 0, result.ToString()); + Assert.DoesNotContain("CECLIENT001", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("CECLIENT002", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task MissingClientFacadeFailsWithCECLIENT002Async() + { + using TemporaryDirectory directory = new("source-consumer-profile-missing-client"); + string sourceRoot = await CreateSdkSourceRootAsync(directory); + string project = await CreateConsumerProjectAsync(directory, sourceRoot, + [EngineProject(sourceRoot), HostingProject(sourceRoot)]); + + DotNetProcessResult result = await RunValidationAsync(project); + + Assert.True(result.ExitCode != 0, result.ToString()); + Assert.Contains("error CECLIENT002", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("error CECLIENT001", result.StandardOutput, StringComparison.Ordinal); + } + + [Theory] + [InlineData("CheatEngine.SDK.Engine")] + [InlineData("CheatEngine.SDK.Hosting")] + public async Task MissingEitherSdkSourceProjectFailsWithCECLIENT001Async(string missingProject) + { + using TemporaryDirectory directory = new($"source-consumer-profile-missing-{missingProject}"); + string sourceRoot = await CreateSdkSourceRootAsync(directory); + List references = [RepositoryLayout.Combine(ClientFacade), EngineProject(sourceRoot), HostingProject(sourceRoot)]; + references.RemoveAll(reference => string.Equals(Path.GetFileNameWithoutExtension(reference), missingProject, + StringComparison.Ordinal)); + string project = await CreateConsumerProjectAsync(directory, sourceRoot, references); + + DotNetProcessResult result = await RunValidationAsync(project); + + Assert.True(result.ExitCode != 0, result.ToString()); + Assert.Contains("error CECLIENT001", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("error CECLIENT002", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task SameNamedSdkProjectOutsideConfiguredSourceRootFailsWithCECLIENT001Async() + { + using TemporaryDirectory directory = new("source-consumer-profile-outside-root"); + string sourceRoot = await CreateSdkSourceRootAsync(directory); + string outside = directory.CreateDirectory("outside-source-root"); + string outsideEngine = await CreateProjectFileAsync(outside, "CheatEngine.SDK.Engine"); + string project = await CreateConsumerProjectAsync(directory, sourceRoot, + [RepositoryLayout.Combine(ClientFacade), outsideEngine, HostingProject(sourceRoot)]); + + DotNetProcessResult result = await RunValidationAsync(project); + + Assert.True(result.ExitCode != 0, result.ToString()); + Assert.Contains("error CECLIENT001", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("error CECLIENT002", result.StandardOutput, StringComparison.Ordinal); + } + + [Theory] + [InlineData(2, true)] + [InlineData(1, false)] + [InlineData(3, false)] + [InlineData(0, false)] + public async Task SourceMajorGuardReadsTheResolvedEngineMetadataAsync(int major, bool accepted) + { + using TemporaryDirectory directory = new("source-consumer-major"); + string engine = Path.Combine(directory.Path, "CheatEngine.SDK.Engine.dll"); + if (major != 0) + { + MetadataBuilder metadata = new(); + metadata.AddModule(0, metadata.GetOrAddString("CheatEngine.SDK.Engine.dll"), + metadata.GetOrAddGuid(Guid.NewGuid()), default, default); + metadata.AddAssembly(metadata.GetOrAddString("CheatEngine.SDK.Engine"), new Version(major, 0, 0, 0), + default, default, (AssemblyFlags) 0, AssemblyHashAlgorithm.None); + metadata.AddTypeDefinition(TypeAttributes.NotPublic, default, metadata.GetOrAddString(""), + default, MetadataTokens.FieldDefinitionHandle(1), MetadataTokens.MethodDefinitionHandle(1)); + ManagedPEBuilder pe = new(new PEHeaderBuilder(imageCharacteristics: Characteristics.ExecutableImage | Characteristics.Dll), + new MetadataRootBuilder(metadata), new BlobBuilder(), flags: CorFlags.ILOnly); + BlobBuilder image = new(); + pe.Serialize(image); + await File.WriteAllBytesAsync(engine, image.ToArray(), TestContext.Current.CancellationToken); + } + string reference = major == 0 ? "" : $""; + string project = Path.Combine(directory.Path, "SourceMajor.proj"); + await File.WriteAllTextAsync(project, $""" + + + true + {Escape(directory.Path)} + + {reference} + + + """, TestContext.Current.CancellationToken); + + DotNetProcessResult result = await DotNetProcess.RunAsync(directory.Path, "msbuild", project, + "-t:CheatEngineClientValidateResolvedSdkMajor", "-nologo", "-nodeReuse:false", "-verbosity:minimal"); + + if (accepted) + { + Assert.True(result.ExitCode == 0, result.ToString()); + } + else + { + Assert.True(result.ExitCode != 0, result.ToString()); + Assert.Contains("error CECLIENT017", result.StandardOutput, StringComparison.Ordinal); + } + } + + private static async Task CreateSdkSourceRootAsync(TemporaryDirectory directory) + { + string root = directory.CreateDirectory("sdk-source-root"); + await CreateProjectFileAsync(Path.Combine(root, "libs", "CheatEngine.SDK.Engine"), "CheatEngine.SDK.Engine"); + await CreateProjectFileAsync(Path.Combine(root, "libs", "CheatEngine.SDK.Hosting"), "CheatEngine.SDK.Hosting"); + return root; + } + + private static async Task CreateConsumerProjectAsync(TemporaryDirectory directory, string sourceRoot, + IReadOnlyList references) + { + string project = Path.Combine(directory.Path, "SourceConsumer.proj"); + string projectReferences = string.Join(Environment.NewLine, references.Select(reference => + $" ")); + string targets = RepositoryLayout.Combine(HostingTargets); + string source = $""" + + + net10.0 + 14.0 + x64 + true + {Escape(sourceRoot)} + + + {projectReferences} + + + + """; + await File.WriteAllTextAsync(project, source, new UTF8Encoding(false), TestContext.Current.CancellationToken); + return project; + } + + private static async Task CreateProjectFileAsync(string directory, string name) + { + Directory.CreateDirectory(directory); + string project = Path.Combine(directory, $"{name}.csproj"); + await File.WriteAllTextAsync(project, "", new UTF8Encoding(false), TestContext.Current.CancellationToken); + return project; + } + + private static Task RunValidationAsync(string project) + { + return DotNetProcess.RunAsync(Path.GetDirectoryName(project)!, "msbuild", project, $"-t:{ValidationTarget}", + "-nologo", "-nodeReuse:false", "-verbosity:minimal"); + } + + private static string EngineProject(string sourceRoot) => + Path.Combine(sourceRoot, "libs", "CheatEngine.SDK.Engine", "CheatEngine.SDK.Engine.csproj"); + + private static string HostingProject(string sourceRoot) => + Path.Combine(sourceRoot, "libs", "CheatEngine.SDK.Hosting", "CheatEngine.SDK.Hosting.csproj"); + + private static string Escape(string value) => value.Replace("&", "&", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal); +}