diff --git a/README.md b/README.md index 732d93c..e1f0dd3 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ are in [`docs/compatibility.md`](docs/compatibility.md). | Area | Current support | Durable limitations | |---|---|---| | Simulation kernel | Virtual clock, seeded randomness, simulated network, cooperative scheduler lanes, node lifecycle, diagnostics, rendezvous primitives, and controlled scheduling. | Application hosting and transport models are consumer-owned; Clockwork ships no dedicated hosting or HTTP package. | -| Build and CLI instrumentation | Opt-in, out-of-place Cecil rewriting through `Clockwork.Instrumentation.Build` and `Clockwork.Tool`; direct-call analyzers; deterministic manifests and content-verified incremental outputs. | ReadyToRun inputs are reduced to IL before rewriting; instrument before single-file bundling, trimming, or NativeAOT. Authenticode is not re-applied; signed inputs require a matching strong-name key. | +| Build and CLI instrumentation | Opt-in, out-of-place Cecil rewriting through `Clockwork.Instrumentation.Build` and `Clockwork.Tool`; direct-call analyzers; deterministic manifests and content-verified incremental outputs. | ReadyToRun inputs are reduced to IL before rewriting; instrument before single-file bundling, trimming, or NativeAOT. Rewritten strong names and closure references are stripped automatically; Authenticode is not re-applied. | | Deterministic BCL rules | `clockwork.bcl.deterministic` controls the exact time, identity, and random signatures in the generated inventory. | APIs outside the inventory retain no determinism claim. | | Controlled concurrency | `clockwork.tasks.controlled` controls async builders/awaiters, task combinators and waits, `Task.Run`, all .NET 10 `TaskFactory`/`TaskFactory.StartNew` overloads, `Thread`, `ThreadPool`, `Parallel`, `Monitor`, `System.Threading.Lock`, and `SemaphoreSlim`. Debug and Release lowering are conformance-tested. | Synchronous `ValueTask` blocking, custom task schedulers, unsupported task-creation options, native-overlapped thread-pool work, and OS-specific thread controls are rejected. | | Synchronization | Full .NET 10 `Interlocked` and `Volatile`; `SpinWait`; events and wait handles; registered waits; `ReaderWriterLockSlim`; `ManualResetEventSlim`; unnamed kernel `Mutex`/`Semaphore`; `SpinLock`; `ExecutionContext`; `SynchronizationContext`; `Barrier`; and `CountdownEvent`. | Named/cross-process primitives, open-existing APIs, raw handles, raw `SynchronizationContext.Wait`, and `WaitAll` arrays containing a `Mutex` are rejected. | @@ -51,6 +51,32 @@ dotnet pack src/Clockwork/Clockwork.csproj --configuration Release The NuGet package ID is `Clockwork.Simulation`. Until packages are published, clone the repository or add it as a Git submodule and reference `src/Clockwork/Clockwork.csproj`. +## Instrumented simulation test projects + +Keep ordinary and simulation tests in separate projects. Only simulation test projects reference +`Clockwork.Instrumentation.Build` and opt into staged execution: + +```xml + + true + true + + + + + +``` + +Clockwork snapshots the project's ordinary test output under `obj`, rewrites its complete eligible +managed closure out of place, and then deploys it to the simulation test project's `bin` directory. +Strong-name identities, intra-closure references, and friend-assembly key qualifiers are stripped +automatically from rewritten assemblies. Test-host implementation assemblies (Microsoft Testing +Platform, xUnit, NUnit, MSTest, and TUnit), Clockwork's simulation kernel, and the test entry assembly +are copied unchanged because they execute before a simulation exists. Consequently, `dotnet build` followed by +`dotnet test --no-build` runs the rewritten test copy naturally. Production project outputs and +projects without the opt-in remain ordinary IL. Do not enable instrumentation globally at the +solution command line. + ## Optional race exploration instrumentation Race exploration is a build-time opt-in separate from ordinary controlled rewriting: diff --git a/docs/compatibility.md b/docs/compatibility.md index 2eec886..6fcc2bd 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -214,6 +214,24 @@ schema version 3; `copiedAssets` entries are objects containing `relativePath` a is rejected rather than interpreted through a compatibility shape. Manifests are limited to 16 MiB, 4,096 assembly entries, 65,536 copied assets, and 8,192 UTF-16 characters per string. +**Instrumented test projects.** Executable test projects can set +`ClockworkInstrumentedTestProject=true`. After an ordinary project build, the package snapshots the +complete test output under `obj` and automatically selects the test assembly plus every +eligible managed assembly in its resolved closure. It rewrites that complete simulation closure out +of place and validates every manifest assembly and rewrite signature before deploying the result to +that simulation test project's `bin` directory. Strong-name identities are stripped automatically +from rewritten assemblies, together with their intra-closure reference tokens and +`InternalsVisibleTo` public-key qualifiers. Test-host implementation assemblies are automatically +excluded because discovery and runner startup execute before a simulation exists. The test entry +assembly and Clockwork kernel are also copied unchanged so async test methods can create the +simulation before controlled code runs; the complete application/dependency closure remains eligible +for rewriting. The next build restores the pristine snapshot first, so +incremental compilation and instrumentation-mode changes never consume a previously rewritten input. +Because the rewritten test copy occupies the project's normal module path, `dotnet build` followed +by `dotnet test --no-build` uses it without runner-specific dispatch hooks. Production project +outputs and non-opted-in test projects remain ordinary IL. Instrumentation must be selected per +project, never using a solution-wide `ClockworkInstrumentationEnabled` global property. + **Task package requires the .NET 10 SDK.** The task and its Cecil-based engine target `net10.0` and load only under `dotnet build` / `dotnet msbuild`; .NET Framework MSBuild (classic `msbuild.exe`) cannot host them. The `Clockwork.Tool` CLI exposes `rewrite` @@ -227,16 +245,16 @@ validated strictly for schema, types, and signatures; **no arbitrary code is exe from configuration**. Multiple rule sets merge deterministically by a defined precedence shared by built-in, application, and third-party rules. Instrumentation configuration files must declare `"schemaVersion": 2`. Its exact optional fields are `ruleSets`, `mode`, `builtInRuleSets`, -`builtInIncludeFamilies`, `builtInExcludeFamilies`, `include`, `exclude`, `targetRuntime`, and -`strongNameKeyPath`; unknown fields are rejected. Version 1 is rejected, and there are no migration +`builtInIncludeFamilies`, `builtInExcludeFamilies`, `include`, `exclude`, and `targetRuntime`; +unknown fields are rejected. Version 1 is rejected, and there are no migration or compatibility aliases. **Strong naming (build/tool scope).** Signed, public-signed, and delay-signed inputs are -detected. A signed input is re-signed when `ClockworkStrongNameKeyPath` (or the JSON/CLI -equivalent) supplies usable private-key material whose public-key token matches the input; -otherwise the build fails clearly rather than emitting a broken identity. Unsigned inputs remain -unsigned even when a key is configured. Public-key-token consistency across a rewritten dependency -closure is verified. **Authenticode** signatures are detected +detected. Clockwork automatically strips strong-name identities from every rewritten assembly and +removes matching public-key tokens from references within the rewritten closure. Friend-assembly +public-key qualifiers are removed at the same time, so the transformed closure remains internally +consistent without signing keys. This is safe for isolated simulation/test artifacts; instrumented +assemblies are not production replacements. **Authenticode** signatures are detected and reported as unsupported - they are never re-applied, and a rewritten assembly does not retain its Authenticode signature; re-sign such outputs with your own toolchain after instrumentation. @@ -591,9 +609,9 @@ cancels all remaining registrations without invoking user callbacks. completed bundles or native images. Instrument the resolved IL closure before single-file bundling, trimming, crossgen/ReadyToRun, or NativeAOT. Rewriting an already bundled, trimmed, ReadyToRun, or NativeAOT output is unsupported. -- **Signed assemblies.** Rewriting invalidates existing signatures. The build/tool path can fail or - re-sign a strong-named closure with a supplied key and verifies public-key-token consistency. - Authenticode is detected but not re-applied; consumers must apply it after instrumentation. +- **Signed assemblies.** Rewritten strong-name identities and matching closure references are + stripped automatically. Authenticode is detected but not re-applied; consumers must apply it + after instrumentation if an instrumented artifact must be redistributed. - **Nondeterministic BCL surface beyond the rule inventory.** Only the exact signatures in [`rule-inventory.md`](rule-inventory.md) are rewritten. Documented holes include `Stopwatch` instance APIs and `GetElapsedTime(long, long)`; unlisted `RandomNumberGenerator` overloads; and diff --git a/src/Clockwork.Instrumentation.Build/ClockworkInstrumentTask.cs b/src/Clockwork.Instrumentation.Build/ClockworkInstrumentTask.cs index 5608f9a..b61111b 100644 --- a/src/Clockwork.Instrumentation.Build/ClockworkInstrumentTask.cs +++ b/src/Clockwork.Instrumentation.Build/ClockworkInstrumentTask.cs @@ -52,9 +52,6 @@ public sealed class ClockworkInstrumentTask : MSBuildTask /// Gets or sets the instrumentation mode (Controlled or RaceExploration). public string InstrumentationMode { get; set; } = nameof(Configuration.InstrumentationMode.Controlled); - /// Gets or sets the strong-name key path used to re-sign signed inputs. - public string? StrongNameKeyPath { get; set; } - /// Gets or sets the target runtime version rules are evaluated against, or empty to disable filtering. public string? TargetRuntime { get; set; } @@ -161,13 +158,28 @@ private InstrumentationConfiguration BuildConfiguration() ? InstrumentationConfigurationLoader.Load(path) : new InstrumentationConfiguration { - IncludePatterns = ToPatternArray(IncludePatterns), - ExcludePatterns = ToPatternArray(ExcludePatterns), Mode = ParseEnum(InstrumentationMode, nameof(InstrumentationMode)), TargetRuntime = ParseVersion(TargetRuntime), - StrongNameKeyPath = NullIfEmpty(StrongNameKeyPath), }; + System.Collections.Immutable.ImmutableArray taskIncludes = ToPatternArray(IncludePatterns); + if (!taskIncludes.IsDefaultOrEmpty) + { + configuration = configuration with + { + IncludePatterns = [.. configuration.IncludePatterns, .. taskIncludes], + }; + } + + System.Collections.Immutable.ImmutableArray taskExcludes = ToPatternArray(ExcludePatterns); + if (!taskExcludes.IsDefaultOrEmpty) + { + configuration = configuration with + { + ExcludePatterns = [.. configuration.ExcludePatterns, .. taskExcludes], + }; + } + System.Collections.Immutable.ImmutableArray taskRuleSets = ToPatternArray(RuleSetPaths); if (!taskRuleSets.IsDefaultOrEmpty) { diff --git a/src/Clockwork.Instrumentation.Build/ClockworkValidateInstrumentedTestTask.cs b/src/Clockwork.Instrumentation.Build/ClockworkValidateInstrumentedTestTask.cs new file mode 100644 index 0000000..aee1515 --- /dev/null +++ b/src/Clockwork.Instrumentation.Build/ClockworkValidateInstrumentedTestTask.cs @@ -0,0 +1,131 @@ +using Clockwork.Instrumentation.Inspection; +using Clockwork.Instrumentation.Orchestration; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using MSBuildTask = Microsoft.Build.Utilities.Task; + +namespace Clockwork.Instrumentation.Build; + +/// +/// Verifies that every managed assembly selected from an instrumented test closure was successfully +/// rewritten and that its staged entry assembly is runnable. +/// +public sealed class ClockworkValidateInstrumentedTestTask : MSBuildTask +{ + /// Gets or sets the staged instrumented closure directory. + [Required] + public string StagingDirectory { get; set; } = string.Empty; + + /// Gets or sets the closure manifest path. + [Required] + public string ManifestPath { get; set; } = string.Empty; + + /// Gets or sets the expected closure-relative entry assembly path. + [Required] + public string EntryAssemblyName { get; set; } = string.Empty; + + /// + public override bool Execute() + { + ClosureManifest manifest; + try + { + manifest = ClosureManifestJson.Read(ManifestPath, out _); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or ClosureManifestFormatException) + { + Log.LogError( + null, + "CWR0202", + null, + ManifestPath, + 0, + 0, + 0, + 0, + $"Clockwork could not validate the instrumented test manifest: {exception.Message}"); + return false; + } + + string stagingRoot = Path.GetFullPath(StagingDirectory); + string expectedEntry = NormalizeRelative( + EntryAssemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + ? EntryAssemblyName + : EntryAssemblyName + ".dll"); + if (!string.Equals(manifest.EntryRelativePath, expectedEntry, StringComparison.Ordinal)) + { + LogError( + "CWR0203", + $"Instrumented test manifest entry '{manifest.EntryRelativePath ?? ""}' does not match '{expectedEntry}'."); + } + + foreach (ClosureManifestEntry entry in manifest.Assemblies) + { + if ((!entry.WasRewritten && !entry.WasNoOp) || entry.ErrorCount != 0) + { + LogError( + "CWR0204", + $"Assembly '{entry.RelativePath}' was not successfully instrumented according to '{ManifestPath}'."); + continue; + } + + if (!TryResolveWithinRoot(stagingRoot, entry.RelativePath, out string stagedPath)) + { + LogError("CWR0205", $"Manifest assembly path '{entry.RelativePath}' escapes the staged closure."); + continue; + } + + if (!File.Exists(stagedPath)) + { + LogError("CWR0206", $"Staged test assembly '{stagedPath}' was not found."); + continue; + } + + if (!AssemblyInspector.TryReadMarker(stagedPath, out _)) + { + LogError( + "CWR0207", + $"Staged test assembly '{stagedPath}' does not carry a Clockwork rewrite signature."); + } + } + + bool entryRewritten = manifest.Assemblies.Any(entry => + string.Equals(entry.RelativePath, expectedEntry, StringComparison.Ordinal)); + bool entryCopied = manifest.CopiedAssets.Any(entry => + string.Equals(entry.RelativePath, expectedEntry, StringComparison.Ordinal)); + if (!entryRewritten && !entryCopied) + { + LogError("CWR0208", $"Entry assembly '{expectedEntry}' was neither instrumented nor copied."); + } + + return !Log.HasLoggedErrors; + } + + private void LogError(string code, string message) => + Log.LogError(null, code, null, null, 0, 0, 0, 0, message); + + private static string NormalizeRelative(string path) => + path.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + + private static bool TryResolveWithinRoot(string root, string relativePath, out string fullPath) + { + fullPath = string.Empty; + if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath)) + { + return false; + } + + string candidate = Path.GetFullPath(Path.Combine(root, relativePath)); + string relative = Path.GetRelativePath(root, candidate); + if (relative == ".." || + relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) || + Path.IsPathRooted(relative)) + { + return false; + } + + fullPath = candidate; + return true; + } +} diff --git a/src/Clockwork.Instrumentation.Build/build/Clockwork.Instrumentation.Build.props b/src/Clockwork.Instrumentation.Build/build/Clockwork.Instrumentation.Build.props index 268f751..69d8008 100644 --- a/src/Clockwork.Instrumentation.Build/build/Clockwork.Instrumentation.Build.props +++ b/src/Clockwork.Instrumentation.Build/build/Clockwork.Instrumentation.Build.props @@ -10,6 +10,8 @@ false + + false Controlled + + $(MSBuildThisFileDirectory)..\tasks\net10.0\Clockwork.Instrumentation.Build.dll + + + AssemblyFile="$(ClockworkInstrumentationTaskAssembly)" /> + + + true + + $(IntermediateOutputPath)clockwork\source\ + $([MSBuild]::NormalizeDirectory($(MSBuildProjectDirectory), $(ClockworkUninstrumentedTestDirectory))) + $(ClockworkUninstrumentedTestDirectoryFullPath) $(TargetDir) $(TargetName) @@ -24,6 +36,8 @@ --> $(IntermediateOutputPath)clockwork\instrumented\ + $([MSBuild]::NormalizeDirectory($(MSBuildProjectDirectory), $(ClockworkStagingDirectory))) + $([MSBuild]::NormalizePath($(ClockworkStagingDirectoryFullPath), $(TargetFileName))) $(IntermediateOutputPath)clockwork\clockwork.manifest.json @@ -36,6 +50,63 @@ $(MSBuildProjectDirectory)\clockwork.config.json + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ClockworkPristineFile Include="$(ClockworkUninstrumentedTestDirectoryFullPath)**\*" /> + + + + + + + + + <_ClockworkOrdinaryTestFile Include="$(TargetDir)**\*" /> + + + + + + @@ -69,7 +140,6 @@ IncludePatterns="@(ClockworkInclude)" ExcludePatterns="@(ClockworkExclude)" InstrumentationMode="$(ClockworkInstrumentationMode)" - StrongNameKeyPath="$(ClockworkStrongNameKeyPath)" TargetRuntime="$(ClockworkTargetRuntime)" EntryAssemblyName="$(ClockworkEntryAssemblyName)" ManifestPath="$(ClockworkManifestPath)" @@ -81,4 +151,37 @@ + + + + + + + + + + + + <_ClockworkInstrumentedTestFile Include="$(ClockworkStagingDirectoryFullPath)**\*" /> + + + + + + + diff --git a/src/Clockwork.Instrumentation/Attributes/ClockworkRewriteSignatureAttribute.cs b/src/Clockwork.Instrumentation/Attributes/ClockworkRewriteSignatureAttribute.cs index 93fca70..5080e04 100644 --- a/src/Clockwork.Instrumentation/Attributes/ClockworkRewriteSignatureAttribute.cs +++ b/src/Clockwork.Instrumentation/Attributes/ClockworkRewriteSignatureAttribute.cs @@ -2,8 +2,11 @@ namespace Clockwork.Instrumentation.Attributes; /// /// -/// Assembly-level marker applied by to an assembly it has -/// rewritten. Its presence records the engine version, the identity and version of the rule set +/// Legacy assembly-level marker read by for compatibility +/// with previously rewritten assemblies. New rewrites use the framework's +/// so loading a rewritten assembly never +/// requires loading Clockwork's build-time instrumentation assembly. Its presence records the +/// engine version, the identity and version of the rule set /// that was applied, a stable signature hash of that rule set (see /// ), and the semantic rewrite-options fingerprint. /// diff --git a/src/Clockwork.Instrumentation/Attributes/RewriteSignatureMetadata.cs b/src/Clockwork.Instrumentation/Attributes/RewriteSignatureMetadata.cs new file mode 100644 index 0000000..eccd4b1 --- /dev/null +++ b/src/Clockwork.Instrumentation/Attributes/RewriteSignatureMetadata.cs @@ -0,0 +1,56 @@ +using System.Text.Json; + +namespace Clockwork.Instrumentation.Attributes; + +internal static class RewriteSignatureMetadata +{ + public const string Key = "Clockwork.RewriteSignature"; + + public static string Encode( + string engineVersion, + string ruleSetId, + string ruleSetVersion, + string signature, + string optionsFingerprint) => + JsonSerializer.Serialize( + new[] { engineVersion, ruleSetId, ruleSetVersion, signature, optionsFingerprint }); + + public static bool TryDecode( + string? value, + out string engineVersion, + out string ruleSetId, + out string ruleSetVersion, + out string signature, + out string optionsFingerprint) + { + engineVersion = string.Empty; + ruleSetId = string.Empty; + ruleSetVersion = string.Empty; + signature = string.Empty; + optionsFingerprint = string.Empty; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + try + { + string[]? values = JsonSerializer.Deserialize(value); + if (values is not { Length: 5 } || values.Any(item => item is null)) + { + return false; + } + + engineVersion = values[0]; + ruleSetId = values[1]; + ruleSetVersion = values[2]; + signature = values[3]; + optionsFingerprint = values[4]; + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/Clockwork.Instrumentation/Configuration/InstrumentationConfiguration.cs b/src/Clockwork.Instrumentation/Configuration/InstrumentationConfiguration.cs index e7e4b32..a79a292 100644 --- a/src/Clockwork.Instrumentation/Configuration/InstrumentationConfiguration.cs +++ b/src/Clockwork.Instrumentation/Configuration/InstrumentationConfiguration.cs @@ -6,8 +6,7 @@ namespace Clockwork.Instrumentation.Configuration; /// /// The declarative, serializable configuration that drives the build task and CLI: which rule-set -/// documents to load, which assemblies in a closure to include or exclude, and the strong-name key -/// used to re-sign signed inputs. Like +/// documents to load and which assemblies in a closure to include or exclude. Like /// it is pure data - loading it never executes arbitrary code - /// and it exposes a stable so it can participate in incremental /// build keys and idempotence markers. @@ -70,12 +69,6 @@ public sealed record InstrumentationConfiguration /// public Version? TargetRuntime { get; init; } - /// - /// Gets the path of the strong-name key (.snk) used to re-sign signed inputs. Resolved - /// relative to the configuration file when loaded from disk. Unsigned inputs remain unsigned. - /// - public string? StrongNameKeyPath { get; init; } - internal string? SourcePath { get; init; } /// @@ -100,7 +93,6 @@ public string ToCanonicalString() canonical.AddStringArray(nameof(IncludePatterns), IncludePatterns); canonical.AddStringArray(nameof(ExcludePatterns), ExcludePatterns); canonical.AddString(nameof(TargetRuntime), TargetRuntime?.ToString()); - canonical.AddString(nameof(StrongNameKeyPath), StrongNameKeyPath); return canonical.ToString(); } } diff --git a/src/Clockwork.Instrumentation/Configuration/InstrumentationConfigurationLoader.cs b/src/Clockwork.Instrumentation/Configuration/InstrumentationConfigurationLoader.cs index 9166b73..8ce2a72 100644 --- a/src/Clockwork.Instrumentation/Configuration/InstrumentationConfigurationLoader.cs +++ b/src/Clockwork.Instrumentation/Configuration/InstrumentationConfigurationLoader.cs @@ -6,7 +6,7 @@ namespace Clockwork.Instrumentation.Configuration; /// /// Loads and strictly validates an from a JSON document. -/// Relative rule-set and key paths are resolved against the configuration file's directory so a +/// Relative rule-set paths are resolved against the configuration file's directory so a /// configuration file is self-contained and portable. Unknown or duplicate properties, unknown enum /// values, wrong JSON types, and missing required fields are hard s. /// @@ -80,8 +80,7 @@ public static InstrumentationConfiguration Parse(string json, string? baseDirect "builtInExcludeFamilies", "include", "exclude", - "targetRuntime", - "strongNameKeyPath"); + "targetRuntime"); int schema = GetRequiredInt(root, "schemaVersion", origin); if (schema != InstrumentationConfiguration.CurrentSchemaVersion) @@ -102,14 +101,6 @@ public static InstrumentationConfiguration Parse(string json, string? baseDirect ImmutableArray include = GetStringArray(root, "include", origin); ImmutableArray exclude = GetStringArray(root, "exclude", origin); Version? targetRuntime = GetOptionalVersion(root, "targetRuntime", origin); - string? keyPath = GetOptionalString(root, "strongNameKeyPath", origin); - if (keyPath is not null && normalizedBaseDirectory is not null) - { - keyPath = CombineAndNormalizePath( - normalizedBaseDirectory, - keyPath, - $"{origin}: 'strongNameKeyPath'"); - } return new InstrumentationConfiguration { @@ -121,7 +112,6 @@ public static InstrumentationConfiguration Parse(string json, string? baseDirect IncludePatterns = include, ExcludePatterns = exclude, TargetRuntime = targetRuntime, - StrongNameKeyPath = keyPath, }; } } diff --git a/src/Clockwork.Instrumentation/Diagnostics/RewriteDiagnosticIds.cs b/src/Clockwork.Instrumentation/Diagnostics/RewriteDiagnosticIds.cs index d31d2d2..04f504e 100644 --- a/src/Clockwork.Instrumentation/Diagnostics/RewriteDiagnosticIds.cs +++ b/src/Clockwork.Instrumentation/Diagnostics/RewriteDiagnosticIds.cs @@ -52,6 +52,9 @@ public static class RewriteDiagnosticIds /// An Authenticode-signed input's signature cannot be preserved across a rewrite and is dropped. public const string AuthenticodeDropped = "CWR0104"; + /// A rewritten assembly's strong-name identity was stripped and closure references were retargeted. + public const string StrongNameStripped = "CWR0105"; + /// /// A rewritten call into an uncontrolled (non-rewritten, non-BCL, non-shim) assembly returns a /// / or other diff --git a/src/Clockwork.Instrumentation/Inspection/AssemblyInspection.cs b/src/Clockwork.Instrumentation/Inspection/AssemblyInspection.cs index b57e212..d57d9da 100644 --- a/src/Clockwork.Instrumentation/Inspection/AssemblyInspection.cs +++ b/src/Clockwork.Instrumentation/Inspection/AssemblyInspection.cs @@ -4,8 +4,7 @@ namespace Clockwork.Instrumentation.Inspection; /// -/// The idempotence marker values recorded on an assembly the engine has rewritten (a decoded -/// ). +/// The decoded idempotence metadata recorded on an assembly the engine has rewritten. /// /// The engine version that performed the rewrite. /// The identity of the applied rule set. diff --git a/src/Clockwork.Instrumentation/Inspection/AssemblyInspector.cs b/src/Clockwork.Instrumentation/Inspection/AssemblyInspector.cs index 1a839ec..b86ec50 100644 --- a/src/Clockwork.Instrumentation/Inspection/AssemblyInspector.cs +++ b/src/Clockwork.Instrumentation/Inspection/AssemblyInspector.cs @@ -17,6 +17,8 @@ public static class AssemblyInspector { private static readonly string MarkerAttributeFullName = typeof(ClockworkRewriteSignatureAttribute).FullName!; + private static readonly string AssemblyMetadataAttributeFullName = + typeof(System.Reflection.AssemblyMetadataAttribute).FullName!; /// Inspects the assembly (or non-managed file) at . /// The file to inspect. @@ -55,8 +57,8 @@ public static AssemblyInspection Inspect(string path) } /// - /// Reads the idempotence marker () applied by the - /// engine to an assembly it rewrote, without loading the assembly into the runtime. + /// Reads the idempotence metadata applied by the engine to an assembly it rewrote, including the + /// legacy shape, without loading the assembly. /// /// The assembly to read. /// The recorded marker, when present. @@ -70,6 +72,29 @@ public static bool TryReadMarker(string path, out InstrumentationMarker marker) using AssemblyDefinition definition = AssemblyDefinition.ReadAssembly(path); foreach (CustomAttribute attribute in definition.CustomAttributes) { + if (attribute.AttributeType.FullName == AssemblyMetadataAttributeFullName && + attribute.ConstructorArguments.Count == 2 && + string.Equals( + attribute.ConstructorArguments[0].Value as string, + RewriteSignatureMetadata.Key, + StringComparison.Ordinal) && + RewriteSignatureMetadata.TryDecode( + attribute.ConstructorArguments[1].Value as string, + out string engineVersion, + out string ruleSetId, + out string ruleSetVersion, + out string signature, + out string optionsFingerprint)) + { + marker = new InstrumentationMarker( + engineVersion, + ruleSetId, + ruleSetVersion, + signature, + optionsFingerprint); + return true; + } + if (attribute.AttributeType.FullName != MarkerAttributeFullName) { continue; diff --git a/src/Clockwork.Instrumentation/Orchestration/InstrumentationRunner.cs b/src/Clockwork.Instrumentation/Orchestration/InstrumentationRunner.cs index ad1c5c6..fbf32f9 100644 --- a/src/Clockwork.Instrumentation/Orchestration/InstrumentationRunner.cs +++ b/src/Clockwork.Instrumentation/Orchestration/InstrumentationRunner.cs @@ -15,7 +15,7 @@ namespace Clockwork.Instrumentation.Orchestration; /// /// The deterministic orchestrator that turns an application output/publish directory into an /// instrumented closure staged in a separate directory. It discovers the closure, strips ReadyToRun -/// inputs to IL, re-signs signed inputs when key material is available, rewrites managed IL with the +/// inputs to IL, strips rewritten strong-name identities consistently, rewrites managed IL with the /// , copies every non-rewritten asset verbatim, emits a deterministic /// closure manifest, and maintains an incremental cache keyed by every input's content hash plus the /// engine, rule-set, and configuration signatures. The source directory is never modified. @@ -92,32 +92,12 @@ public static InstrumentationResult Run(InstrumentationRequest request) cachePath, configuration); - StrongNameKeyLoadResult keyLoad = - StrongNameKeyLoader.LoadConfigured(configuration.StrongNameKeyPath); - StrongNameKey? key = keyLoad.Key; var topLevel = new List(); - if (keyLoad.Diagnostic is { } keyDiagnostic) - { - topLevel.Add(keyDiagnostic); - } - - if (topLevel.Count > 0) - { - DeleteIfExists(request.CachePath); - return new InstrumentationResult - { - Succeeded = false, - WasIncrementalHit = false, - StagingDirectory = stagingDirectory, - ManifestPath = request.ManifestPath, - Diagnostics = [.. topLevel], - }; - } string incrementalKey; try { - incrementalKey = ComputeIncrementalKey(plan, configuration, request.RuleSet, key); + incrementalKey = ComputeIncrementalKey(plan, configuration, request.RuleSet); } catch (ClosureException) { @@ -145,6 +125,18 @@ public static InstrumentationResult Run(InstrumentationRequest request) DeleteIfExists(request.CachePath); + ImmutableArray replacementPaths = + ResolveReplacementAssemblies(sourceDirectory, request.RuleSet, configuration); + HashSet replacementNames = ResolveReplacementClosureNames(sourceDirectory, replacementPaths); + ImmutableArray rewrittenStrongNames = + DiscoverRewrittenStrongNameAssemblyNames(plan, replacementNames); + HashSet protectedStrongNames = DiscoverCopiedStrongNameReferences( + plan, + replacementNames, + rewrittenStrongNames.ToHashSet(StringComparer.Ordinal)); + ImmutableArray strongNameAssemblyNames = + [.. rewrittenStrongNames.Where(name => !protectedStrongNames.Contains(name))]; + PrepareStagingDirectory(stagingDirectory); var copied = new List(); @@ -156,9 +148,6 @@ public static InstrumentationResult Run(InstrumentationRequest request) copied.Add(asset.RelativePath); } - ImmutableArray replacementPaths = - ResolveReplacementAssemblies(sourceDirectory, request.RuleSet, configuration); - HashSet replacementNames = ResolveReplacementClosureNames(sourceDirectory, replacementPaths); bool containsControlledTaskRules = BuiltInRuleSets.ContainsControlledTaskRules(request.RuleSet); var options = new RewriteOptions { @@ -167,6 +156,7 @@ public static InstrumentationResult Run(InstrumentationRequest request) TargetRuntime = configuration.TargetRuntime, HardenExceptionHandlers = containsControlledTaskRules, InstrumentRaceExploration = configuration.Mode == InstrumentationMode.RaceExploration, + StrongNameAssemblyNames = strongNameAssemblyNames, }; var assemblyResults = new List(); @@ -191,8 +181,7 @@ public static InstrumentationResult Run(InstrumentationRequest request) request.CachePath, configuration, request.RuleSet, - options, - key)); + options)); } if (configuration.Mode == InstrumentationMode.RaceExploration) @@ -249,8 +238,7 @@ private static AssemblyInstrumentationResult ProcessAssembly( string cachePath, InstrumentationConfiguration configuration, Rules.RewriteRuleSet ruleSet, - RewriteOptions options, - StrongNameKey? key) + RewriteOptions options) { string inputPath = asset.SourcePath; string outputPath = ToStagingPath(stagingDirectory, asset.RelativePath); @@ -329,26 +317,14 @@ private static AssemblyInstrumentationResult ProcessAssembly( } StrongNameInfo strongName = StrongNameInspector.Inspect(inputPath); - bool willReSign = false; - if (strongName.HasPublicKey) + if (strongName.HasPublicKey && + options.StrongNameAssemblyNames.Contains( + System.Reflection.AssemblyName.GetAssemblyName(inputPath).Name!, + StringComparer.Ordinal)) { - if (key is null) - { - diagnostics.Add(RewriteDiagnostic.Error( - RewriteDiagnosticIds.StrongNameReSignRequired, - $"'{asset.RelativePath}' is strong-named ({strongName.Status}, token {strongName.PublicKeyToken}) but no usable signing key is available for re-signing.")); - return new AssemblyInstrumentationResult(asset.RelativePath, false, false, false, readyToRunStripped, null, [.. diagnostics]); - } - - if (!string.Equals(strongName.PublicKeyToken, key.PublicKeyToken, StringComparison.Ordinal)) - { - diagnostics.Add(RewriteDiagnostic.Error( - RewriteDiagnosticIds.StrongNameReSignRequired, - $"'{asset.RelativePath}' has public-key token {strongName.PublicKeyToken}, but the configured signing key produces {key.PublicKeyToken}. An identity-preserving key is required.")); - return new AssemblyInstrumentationResult(asset.RelativePath, false, false, false, readyToRunStripped, null, [.. diagnostics]); - } - - willReSign = true; + diagnostics.Add(RewriteDiagnostic.Info( + RewriteDiagnosticIds.StrongNameStripped, + $"'{asset.RelativePath}' is strong-named ({strongName.Status}, token {strongName.PublicKeyToken}); its rewritten test identity and closure references are stripped automatically.")); } RewriteResult rewrite = RewriteEngine.Rewrite( @@ -362,40 +338,9 @@ private static AssemblyInstrumentationResult ProcessAssembly( asset.RelativePath, rewrite.WasWritten, rewrite.WasNoOp, false, readyToRunStripped, rewrite.Manifest, [.. diagnostics]); } - bool wasReSigned = false; - if (willReSign && key is not null && File.Exists(engineOutput)) - { - try - { - StrongNameSigner.ReSign(engineOutput, key); - StrongNameInfo outputStrongName = StrongNameInspector.Inspect(engineOutput); - if (outputStrongName.Status != StrongNameStatus.StrongNameSigned - || !string.Equals( - key.PublicKeyToken, - outputStrongName.PublicKeyToken, - StringComparison.Ordinal)) - { - throw new SigningException( - $"Re-signing failed to restore public-key token '{key.PublicKeyToken}' " + - $"(output token '{outputStrongName.PublicKeyToken ?? ""}')."); - } - - wasReSigned = true; - diagnostics.Add(RewriteDiagnostic.Info( - RewriteDiagnosticIds.StrongNameReSigned, - $"'{asset.RelativePath}' was re-signed and retained public-key token {key.PublicKeyToken}.")); - } - catch (SigningException ex) - { - diagnostics.Add(RewriteDiagnostic.Error( - RewriteDiagnosticIds.StrongNameReSignRequired, - $"Failed to re-sign '{asset.RelativePath}': {ex.Message}")); - } - } - CopyReadyToRunOutputIntoStaging(engineOutput, outputPath, temporaryDirectory); return new AssemblyInstrumentationResult( - asset.RelativePath, rewrite.WasWritten, rewrite.WasNoOp, wasReSigned, readyToRunStripped, rewrite.Manifest, [.. diagnostics]); + asset.RelativePath, rewrite.WasWritten, rewrite.WasNoOp, false, readyToRunStripped, rewrite.Manifest, [.. diagnostics]); } finally { @@ -430,6 +375,65 @@ private static ImmutableArray ResolveReplacementAssemblies( return [.. paths]; } + private static ImmutableArray DiscoverRewrittenStrongNameAssemblyNames( + ClosurePlan plan, + HashSet replacementNames) + { + var names = new SortedSet(StringComparer.Ordinal); + foreach (ClosureAsset asset in plan.AssembliesToRewrite) + { + string fileName = Path.GetFileNameWithoutExtension(asset.RelativePath); + if (replacementNames.Contains(fileName)) + { + continue; + } + + using AssemblyDefinition definition = AssemblyDefinition.ReadAssembly( + asset.SourcePath, + new ReaderParameters { ReadSymbols = false, InMemory = true }); + if (definition.Name.HasPublicKey) + { + names.Add(definition.Name.Name); + } + } + + return [.. names]; + } + + private static HashSet DiscoverCopiedStrongNameReferences( + ClosurePlan plan, + HashSet replacementNames, + HashSet strippedAssemblyNames) + { + var protectedNames = new HashSet(StringComparer.Ordinal); + foreach (ClosureAsset asset in plan.Assets) + { + bool copiedManagedAssembly = + asset.Kind == AssetKind.ManagedAssembly && + (!asset.Rewrite || replacementNames.Contains(Path.GetFileNameWithoutExtension(asset.RelativePath))); + if (!copiedManagedAssembly) + { + continue; + } + + using AssemblyDefinition definition = AssemblyDefinition.ReadAssembly( + asset.SourcePath, + new ReaderParameters { ReadSymbols = false, InMemory = true }); + foreach (AssemblyNameReference reference in definition.MainModule.AssemblyReferences) + { + if (!strippedAssemblyNames.Contains(reference.Name) || + reference.PublicKeyToken is not { Length: > 0 }) + { + continue; + } + + protectedNames.Add(reference.Name); + } + } + + return protectedNames; + } + private static HashSet ResolveReplacementClosureNames( string sourceDirectory, ImmutableArray replacementPaths) @@ -748,8 +752,7 @@ or IOException private static string ComputeIncrementalKey( ClosurePlan plan, InstrumentationConfiguration configuration, - Rules.RewriteRuleSet ruleSet, - StrongNameKey? key) + Rules.RewriteRuleSet ruleSet) { var canonical = new CanonicalEncoding("InstrumentationIncrementalKey"); canonical.AddString("EngineVersion", RewriteEngine.EngineVersion); @@ -772,7 +775,6 @@ configuration.SourcePath is null source.AddString("Sha256", HashRequiredSourceFile(path, "Rule-set source")); return source.ToString(); })); - canonical.AddString("StrongNameKeySha256", key is null ? null : HashBytes(key.Blob)); if (configuration.Mode == InstrumentationMode.RaceExploration) { canonical.AddString( @@ -904,11 +906,6 @@ private static InstrumentationConfiguration NormalizeConfigurationPaths( $"Instrumentation request Configuration.RuleSetPaths[{index}]")); } - string? strongNameKeyPath = configuration.StrongNameKeyPath is null - ? null - : InstrumentationPath.GetFullPath( - configuration.StrongNameKeyPath, - "Instrumentation request Configuration.StrongNameKeyPath"); string? configurationSourcePath = configuration.SourcePath is null ? null : InstrumentationPath.GetFullPath( @@ -917,7 +914,6 @@ private static InstrumentationConfiguration NormalizeConfigurationPaths( return configuration with { RuleSetPaths = ruleSetPaths.ToImmutable(), - StrongNameKeyPath = strongNameKeyPath, SourcePath = configurationSourcePath, }; } @@ -1023,11 +1019,6 @@ private static void ValidateProtectedInputLocations( inputs.Add(($"Configuration.RuleSetPaths[{index}]", configuration.RuleSetPaths[index])); } - if (configuration.StrongNameKeyPath is { } strongNameKeyPath) - { - inputs.Add(("Configuration.StrongNameKeyPath", strongNameKeyPath)); - } - foreach ((string inputName, string inputPath) in inputs) { if (PathsHaveHierarchyCollision(inputPath, stagingDirectory)) @@ -1178,14 +1169,6 @@ private static void ValidateRequestPathIsolation( terminalMustBeDirectory: false); } - if (configuration.StrongNameKeyPath is { } strongNameKeyPath) - { - ValidateExistingPathComponents( - strongNameKeyPath, - "Configuration.StrongNameKeyPath", - terminalMustBeDirectory: false); - } - ValidateDirectoryTreeHasNoReparsePoints( sourceDirectory, nameof(InstrumentationRequest.SourceDirectory)); diff --git a/src/Clockwork.Instrumentation/Rewriting/AssemblyRewriteContext.cs b/src/Clockwork.Instrumentation/Rewriting/AssemblyRewriteContext.cs index 41a93c5..642dc6b 100644 --- a/src/Clockwork.Instrumentation/Rewriting/AssemblyRewriteContext.cs +++ b/src/Clockwork.Instrumentation/Rewriting/AssemblyRewriteContext.cs @@ -21,8 +21,8 @@ namespace Clockwork.Instrumentation.Rewriting; /// /// Loads a single assembly for rewriting, exposes its Mono.Cecil , -/// detects and preserves its debug-symbol form, reads and applies the idempotence signature marker -/// (), and writes the rewritten result. Instances +/// detects and preserves its debug-symbol form, reads and applies the idempotence signature marker, +/// and writes the rewritten result. Instances /// own an assembly resolver and the underlying and must be disposed. /// internal sealed class AssemblyRewriteContext : IDisposable @@ -32,8 +32,14 @@ internal sealed class AssemblyRewriteContext : IDisposable private static readonly string SignatureAttributeName = nameof(ClockworkRewriteSignatureAttribute); + private static readonly string AssemblyMetadataAttributeFullName = + typeof(System.Reflection.AssemblyMetadataAttribute).FullName!; + private static readonly string DoNotRewriteAttributeFullName = typeof(DoNotRewriteAttribute).FullName!; + private const string InternalsVisibleToAttributeFullName = + "System.Runtime.CompilerServices.InternalsVisibleToAttribute"; + private readonly DefaultAssemblyResolver _resolver; private readonly List _loadDiagnostics = []; private bool _disposed; @@ -186,6 +192,25 @@ public static AssemblyRewriteContext Load( /// public bool TryGetRewriteSignature(out ClockworkRewriteSignatureValues values) { + CustomAttribute? metadata = FindSignatureMetadata(); + if (metadata is not null && + RewriteSignatureMetadata.TryDecode( + metadata.ConstructorArguments[1].Value as string, + out string engineVersion, + out string ruleSetId, + out string ruleSetVersion, + out string signature, + out string optionsFingerprint)) + { + values = new ClockworkRewriteSignatureValues( + engineVersion, + ruleSetId, + ruleSetVersion, + signature, + optionsFingerprint); + return true; + } + CustomAttribute? attribute = FindSignatureAttribute(); if (attribute is not null && attribute.ConstructorArguments.Count >= 4) { @@ -212,36 +237,77 @@ public void ApplyRewriteSignature(ClockworkRewriteSignatureValues values) { ModuleDefinition module = Definition.MainModule; TypeReference stringType = module.TypeSystem.String; - CustomAttributeArgument[] args = - [ - new(stringType, values.EngineVersion), - new(stringType, values.RuleSetId), - new(stringType, values.RuleSetVersion), - new(stringType, values.Signature), - new(stringType, values.OptionsFingerprint), - ]; - - CustomAttribute? existing = FindSignatureAttribute(); + string encoded = RewriteSignatureMetadata.Encode( + values.EngineVersion, + values.RuleSetId, + values.RuleSetVersion, + values.Signature, + values.OptionsFingerprint); + CustomAttribute? existing = FindSignatureMetadata(); if (existing is not null) { - for (int i = 0; i < args.Length; i++) - { - existing.ConstructorArguments[i] = args[i]; - } - + existing.ConstructorArguments[1] = new CustomAttributeArgument(stringType, encoded); return; } MethodReference ctor = module.ImportReference( - typeof(ClockworkRewriteSignatureAttribute).GetConstructor( - [typeof(string), typeof(string), typeof(string), typeof(string), typeof(string)])!); + typeof(System.Reflection.AssemblyMetadataAttribute).GetConstructor([typeof(string), typeof(string)])!); var attribute = new CustomAttribute(ctor); - foreach (CustomAttributeArgument arg in args) + attribute.ConstructorArguments.Add(new CustomAttributeArgument(stringType, RewriteSignatureMetadata.Key)); + attribute.ConstructorArguments.Add(new CustomAttributeArgument(stringType, encoded)); + Definition.CustomAttributes.Add(attribute); + } + + /// + /// Removes strong-name identity from this assembly when selected, and removes public-key tokens + /// from references to every selected closure assembly. + /// + public void StripStrongNames(IReadOnlySet assemblyNames) + { + if (assemblyNames.Contains(Definition.Name.Name)) { - attribute.ConstructorArguments.Add(arg); + Definition.Name.PublicKey = []; + Definition.Name.PublicKeyToken = []; + Definition.Name.HasPublicKey = false; + Definition.Name.Attributes &= ~AssemblyAttributes.PublicKey; + foreach (ModuleDefinition module in Definition.Modules) + { + module.Attributes &= ~ModuleAttributes.StrongNameSigned; + } } - Definition.CustomAttributes.Add(attribute); + foreach (ModuleDefinition module in Definition.Modules) + { + foreach (AssemblyNameReference reference in module.AssemblyReferences) + { + if (!assemblyNames.Contains(reference.Name)) + { + continue; + } + + reference.PublicKey = []; + reference.PublicKeyToken = []; + reference.HasPublicKey = false; + reference.Attributes &= ~AssemblyAttributes.PublicKey; + } + } + + foreach (CustomAttribute attribute in Definition.CustomAttributes) + { + if (attribute.AttributeType.FullName != InternalsVisibleToAttributeFullName || + attribute.ConstructorArguments.Count != 1 || + attribute.ConstructorArguments[0].Value is not string friendIdentity) + { + continue; + } + + string friendName = friendIdentity.Split(',', 2)[0].Trim(); + if (assemblyNames.Contains(friendName)) + { + attribute.ConstructorArguments[0] = + new CustomAttributeArgument(MainModule.TypeSystem.String, friendName); + } + } } /// @@ -313,6 +379,15 @@ public void Write(string outputPath, byte[]? strongNameKeyBlob) a.AttributeType.Namespace == SignatureAttributeNamespace && a.AttributeType.Name == SignatureAttributeName); + private CustomAttribute? FindSignatureMetadata() => + Definition.CustomAttributes.FirstOrDefault(attribute => + attribute.AttributeType.FullName == AssemblyMetadataAttributeFullName && + attribute.ConstructorArguments.Count == 2 && + string.Equals( + attribute.ConstructorArguments[0].Value as string, + RewriteSignatureMetadata.Key, + StringComparison.Ordinal)); + /// public void Dispose() { @@ -328,7 +403,7 @@ public void Dispose() } /// -/// The five string values stored in a . +/// The five string values stored in Clockwork rewrite-signature metadata. /// /// The engine version that performed the rewrite. /// The identity of the applied rule set. diff --git a/src/Clockwork.Instrumentation/Rewriting/RewriteEngine.cs b/src/Clockwork.Instrumentation/Rewriting/RewriteEngine.cs index ad31424..6a7cb3b 100644 --- a/src/Clockwork.Instrumentation/Rewriting/RewriteEngine.cs +++ b/src/Clockwork.Instrumentation/Rewriting/RewriteEngine.cs @@ -149,6 +149,7 @@ .. options.DetectUncontrolledTasks return Failed(request, engineVersion, signature, inputIdentity, diagnostics, exclusions, transformations, [.. unresolved]); } + context.StripStrongNames(options.StrongNameAssemblyNames.ToHashSet(StringComparer.Ordinal)); context.ApplyRewriteSignature(new ClockworkRewriteSignatureValues( engineVersion, request.RuleSet.Id, request.RuleSet.Version, signature, optionsFingerprint)); @@ -284,7 +285,16 @@ private static void CollectExclusions( foreach (TypeDefinition type in module.GetTypes()) { string fullName = type.FullName; - if (!skip.Contains(fullName) && excludedByOption.Contains(fullName)) + if (!skip.Contains(fullName) && IsGeneratedTestHostBootstrap(fullName)) + { + skip.Add(fullName); + const string reason = "Generated test-host bootstrap executes before a simulation is active."; + exclusions.Add(new ManifestExclusion(fullName, reason)); + diagnostics.Add(RewriteDiagnostic.Info( + RewriteDiagnosticIds.TypeExcluded, + $"Type '{fullName}' was excluded from rewriting: {reason}")); + } + else if (!skip.Contains(fullName) && excludedByOption.Contains(fullName)) { skip.Add(fullName); exclusions.Add(new ManifestExclusion(fullName, "Excluded by options.")); @@ -303,6 +313,11 @@ private static void CollectExclusions( } } + private static bool IsGeneratedTestHostBootstrap(string fullName) => + fullName.Contains("XunitAutoGeneratedEntryPoint", StringComparison.Ordinal) || + fullName.EndsWith(".SelfRegisteredExtensions", StringComparison.Ordinal) || + fullName.Contains("TestingPlatformEntryPoint", StringComparison.Ordinal); + private static RewriteResult Failed( RewriteRequest request, string engineVersion, diff --git a/src/Clockwork.Instrumentation/Rewriting/RewriteOptions.cs b/src/Clockwork.Instrumentation/Rewriting/RewriteOptions.cs index 566dea8..8852775 100644 --- a/src/Clockwork.Instrumentation/Rewriting/RewriteOptions.cs +++ b/src/Clockwork.Instrumentation/Rewriting/RewriteOptions.cs @@ -81,6 +81,8 @@ public sealed record RewriteOptions /// public bool InstrumentRaceExploration { get; init; } + internal ImmutableArray StrongNameAssemblyNames { get; init; } = []; + /// /// Computes a canonical fingerprint of every option which can affect rewritten output, /// diagnostics, or manifest content. Set-like exclusions are sorted so equivalent orderings @@ -98,6 +100,7 @@ public string ComputeSemanticFingerprint() canonical.AddBoolean(nameof(HardenExceptionHandlers), HardenExceptionHandlers); canonical.AddBoolean(nameof(DetectUncontrolledTasks), DetectUncontrolledTasks); canonical.AddBoolean(nameof(InstrumentRaceExploration), InstrumentRaceExploration); + AppendPaths(canonical, nameof(StrongNameAssemblyNames), StrongNameAssemblyNames, sort: true); return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()))); } diff --git a/src/Clockwork.Instrumentation/Rules/RewriteRuleSet.cs b/src/Clockwork.Instrumentation/Rules/RewriteRuleSet.cs index 71e9c4e..c11f093 100644 --- a/src/Clockwork.Instrumentation/Rules/RewriteRuleSet.cs +++ b/src/Clockwork.Instrumentation/Rules/RewriteRuleSet.cs @@ -10,7 +10,7 @@ namespace Clockwork.Instrumentation.Rules; /// engine. A rule set has a stable and , and can compute a /// deterministic content used for the engine's idempotence marker: /// re-running with the same signature is a verified no-op, while a different signature is detected -/// as an incompatible rewrite (see ). +/// as an incompatible rewrite. /// public sealed class RewriteRuleSet { diff --git a/src/Clockwork.Tool/ConfigurationFactory.cs b/src/Clockwork.Tool/ConfigurationFactory.cs index 040e931..cade157 100644 --- a/src/Clockwork.Tool/ConfigurationFactory.cs +++ b/src/Clockwork.Tool/ConfigurationFactory.cs @@ -15,8 +15,7 @@ internal static class ConfigurationFactory /// The value options this factory understands, contributed to a command's option set. public static readonly ImmutableArray ValueOptions = [ - "config", "rule-set", "include", "exclude", "mode", - "strong-name-key", "target-runtime", + "config", "rule-set", "include", "exclude", "mode", "target-runtime", "builtin", "builtin-include", "builtin-exclude", ]; @@ -38,7 +37,6 @@ public static InstrumentationConfiguration Build(ArgumentReader reader) reader.GetString("mode"), InstrumentationMode.Controlled), TargetRuntime = ParseVersion(reader.GetString("target-runtime")), - StrongNameKeyPath = reader.GetString("strong-name-key"), }; IReadOnlyList extraRuleSets = reader.GetMany("rule-set"); diff --git a/src/Clockwork.Tool/InstrumentCommand.cs b/src/Clockwork.Tool/InstrumentCommand.cs index 142d571..32dd685 100644 --- a/src/Clockwork.Tool/InstrumentCommand.cs +++ b/src/Clockwork.Tool/InstrumentCommand.cs @@ -91,17 +91,12 @@ private static ExitCode DryRun( TextWriter output) { ClosurePlan plan = ClosureDiscovery.Discover(source, configuration, string.IsNullOrWhiteSpace(entry) ? null : entry); - StrongNameKeyLoadResult keyLoad = - StrongNameKeyLoader.LoadConfigured(configuration.StrongNameKeyPath); - StrongNameKey? key = keyLoad.Key; - ImmutableArray diagnostics = keyLoad.Diagnostic is { } keyDiagnostic - ? [keyDiagnostic] - : []; + ImmutableArray diagnostics = []; var rows = new List(); foreach (ClosureAsset asset in plan.AssembliesToRewrite) { - rows.Add(PlanFor(asset, key)); + rows.Add(PlanFor(asset)); } bool anyBlocking = diagnostics.Any(static diagnostic => diagnostic.IsError) @@ -175,7 +170,7 @@ private static ExitCode DryRun( return anyBlocking ? ExitCode.InstrumentationError : ExitCode.Success; } - private static PlannedAction PlanFor(ClosureAsset asset, StrongNameKey? key) + private static PlannedAction PlanFor(ClosureAsset asset) { AssemblyImageInfo image; try @@ -207,29 +202,11 @@ private static PlannedAction PlanFor(ClosureAsset asset, StrongNameKey? key) StrongNameInfo strongName = StrongNameInspector.Inspect(asset.SourcePath); if (strongName.Status != StrongNameStatus.None) { - if (key is null || !key.CanSign) - { - string prefix = stripReadyToRun ? "strip ReadyToRun to IL, then " : string.Empty; - return new PlannedAction( - asset.RelativePath, - $"{prefix}fail (strong-named; re-signing requires a usable identity-preserving key)", - IsBlocking: true); - } - - if (!string.Equals(strongName.PublicKeyToken, key.PublicKeyToken, StringComparison.Ordinal)) - { - string prefix = stripReadyToRun ? "strip ReadyToRun to IL, then " : string.Empty; - return new PlannedAction( - asset.RelativePath, - $"{prefix}fail (strong-named; signing key changes public-key token)", - IsBlocking: true); - } - return new PlannedAction( asset.RelativePath, stripReadyToRun - ? "strip ReadyToRun to IL, instrument, and re-sign" - : "instrument and re-sign", + ? "strip ReadyToRun and strong-name identity, then instrument" + : "instrument and strip strong-name identity", IsBlocking: false); } diff --git a/src/Clockwork.Tool/Program.cs b/src/Clockwork.Tool/Program.cs index c361a94..41bc74c 100644 --- a/src/Clockwork.Tool/Program.cs +++ b/src/Clockwork.Tool/Program.cs @@ -115,7 +115,6 @@ private static void WriteUsage(TextWriter output) output.WriteLine(" --entry entry assembly simple name (else auto-detected)"); output.WriteLine(" --manifest manifest output path (else a sibling of --output)"); output.WriteLine(" --mode instrumentation granularity (default Controlled)"); - output.WriteLine(" --strong-name-key strong-name key used to re-sign signed inputs"); output.WriteLine(" --target-runtime runtime version rules are evaluated against"); output.WriteLine(" --builtin built-in rule set (repeatable)"); output.WriteLine(" --builtin-include include built-in family (repeatable)"); @@ -133,7 +132,6 @@ private static void WriteUsage(TextWriter output) output.WriteLine(" --include configuration include pattern (repeatable)"); output.WriteLine(" --exclude configuration exclude pattern (repeatable)"); output.WriteLine(" --mode configuration instrumentation granularity"); - output.WriteLine(" --strong-name-key configuration strong-name key"); output.WriteLine(" --target-runtime configuration target runtime"); output.WriteLine(" --json emit JSON instead of text"); output.WriteLine(); diff --git a/src/Clockwork/Nodes/SimulationNodeContext.cs b/src/Clockwork/Nodes/SimulationNodeContext.cs index 8abd1c3..8e137d3 100644 --- a/src/Clockwork/Nodes/SimulationNodeContext.cs +++ b/src/Clockwork/Nodes/SimulationNodeContext.cs @@ -109,6 +109,11 @@ internal SimulationNodeContext( /// public SimulationTaskScheduler TaskScheduler { get; } + /// + /// Gets the scheduler lane which owns this node's queued work. + /// + public SimulationSchedulerLane TaskQueue => SchedulerLane; + /// /// Gets the synchronization context for this node. /// Used for async/await continuations on this node's lane. @@ -221,6 +226,10 @@ public void Resume() /// /// How long to suspend the node (in simulated time). /// Thrown if no external scheduler lane was provided. + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The scheduled item transfers to the scheduler lane and shared-item registry; the failure path disposes it.")] public void SuspendFor(TimeSpan duration) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(duration, TimeSpan.Zero); diff --git a/src/Clockwork/Runtime/Shims/System/Threading/ControlledCancellationTokenSource.cs b/src/Clockwork/Runtime/Shims/System/Threading/ControlledCancellationTokenSource.cs index f3b3604..4c8769f 100644 --- a/src/Clockwork/Runtime/Shims/System/Threading/ControlledCancellationTokenSource.cs +++ b/src/Clockwork/Runtime/Shims/System/Threading/ControlledCancellationTokenSource.cs @@ -129,6 +129,10 @@ public static void Dispose(CancellationTokenSource source) source.Dispose(); } + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The registration is owned by the conditional weak table until the cancellation source is disposed.")] private static CancellationTokenSource CreateCore( SimulationExecutionSnapshot snapshot, TimeSpan delay) diff --git a/tests/Clockwork.Instrumentation.Tests/Cli/CliCommandTests.cs b/tests/Clockwork.Instrumentation.Tests/Cli/CliCommandTests.cs index cd13e1c..920627d 100644 --- a/tests/Clockwork.Instrumentation.Tests/Cli/CliCommandTests.cs +++ b/tests/Clockwork.Instrumentation.Tests/Cli/CliCommandTests.cs @@ -79,7 +79,7 @@ public void HelpListsAcceptedInspectConfigurationOptions() foreach (string option in new[] { "--config", "--rule-set", "--builtin", "--builtin-include", "--builtin-exclude", - "--include", "--exclude", "--strong-name-key", "--target-runtime", "--mode", "--json", + "--include", "--exclude", "--target-runtime", "--mode", "--json", }) { Assert.Contains(option, output); @@ -226,7 +226,7 @@ public void InstrumentBuiltInStagesSimulationOnlyExecutable() } [Fact] - public void InstrumentDryRunFlagsStrongNamedInputAsBlocking() + public void InstrumentDryRunReportsAutomaticStrongNameStripping() { string keyPath = Path.Combine(_root, "test.snk"); File.WriteAllBytes(keyPath, StrongNameKeys.CreatePrivateKeyBlob()); @@ -239,96 +239,8 @@ public void InstrumentDryRunFlagsStrongNamedInputAsBlocking() (ExitCode code, string output, _) = Invoke( "instrument", "--source", _source, "--rule-set", ruleSet, "--dry-run"); - Assert.Equal(ExitCode.InstrumentationError, code); - Assert.Contains("strong-named", output); - } - - [Fact] - public void InstrumentDryRunInfersReSigningFromKeyMaterial() - { - string keyPath = Path.Combine(_root, "test.snk"); - File.WriteAllBytes(keyPath, StrongNameKeys.CreatePrivateKeyBlob()); - FixtureCompiler.Compile( - "app", "namespace App { public static class A { public static int Go() => 1; } }", - _source, FixtureSymbols.PortableFile, optimize: false, strongNameKeyFile: keyPath); - File.WriteAllText(Path.Combine(_source, "app.runtimeconfig.json"), "{}"); - string ruleSet = WriteEmptyRuleSet(); - - (ExitCode code, string output, _) = Invoke( - "instrument", "--source", _source, "--rule-set", ruleSet, - "--strong-name-key", keyPath, "--dry-run"); - Assert.Equal(ExitCode.Success, code); - Assert.Contains("instrument and re-sign", output); - } - - [Fact] - public void InstrumentDryRunRejectsSigningKeyWhichChangesIdentity() - { - string inputKeyPath = Path.Combine(_root, "input.snk"); - File.WriteAllBytes(inputKeyPath, StrongNameKeys.CreatePrivateKeyBlob()); - FixtureCompiler.Compile( - "app", "namespace App { public static class A { public static int Go() => 1; } }", - _source, FixtureSymbols.PortableFile, optimize: false, strongNameKeyFile: inputKeyPath); - File.WriteAllText(Path.Combine(_source, "app.runtimeconfig.json"), "{}"); - string otherKeyPath = Path.Combine(_root, "other.snk"); - File.WriteAllBytes(otherKeyPath, StrongNameKeys.CreatePrivateKeyBlob()); - string ruleSet = WriteEmptyRuleSet(); - - (ExitCode code, string output, _) = Invoke( - "instrument", "--source", _source, "--rule-set", ruleSet, - "--strong-name-key", otherKeyPath, "--dry-run"); - - Assert.Equal(ExitCode.InstrumentationError, code); - Assert.Contains("changes public-key token", output); - } - - [Theory] - [InlineData("missing")] - [InlineData("invalid")] - [InlineData("empty")] - public void InstrumentDryRunRejectsConfiguredUnusableSigningKey(string keyKind) - { - BuildMinimalClosure(); - string keyPath = Path.Combine(_root, "configured.snk"); - if (keyKind == "invalid") - { - File.WriteAllText(keyPath, "not a strong-name key"); - } - - string configuredPath = keyKind == "empty" ? string.Empty : keyPath; - string ruleSet = WriteEmptyRuleSet(); - (ExitCode code, string output, _) = Invoke( - "instrument", "--source", _source, "--rule-set", ruleSet, - "--strong-name-key", configuredPath, "--dry-run"); - - Assert.Equal(ExitCode.InstrumentationError, code); - Assert.Contains(RewriteDiagnosticIds.StrongNameReSignRequired, output, StringComparison.Ordinal); - Assert.Contains($"Failed to load strong-name key '{configuredPath}'", output, StringComparison.Ordinal); - } - - [Fact] - public void InstrumentDryRunAndRealRunBothRejectTruncatedPrivateKey() - { - BuildMinimalClosure(); - string keyPath = Path.Combine(_root, "truncated.snk"); - byte[] privateKey = StrongNameKeys.CreatePrivateKeyBlob(); - File.WriteAllBytes(keyPath, privateKey[..^1]); - string ruleSet = WriteEmptyRuleSet(); - - (ExitCode dryRunCode, string dryRunOutput, _) = Invoke( - "instrument", "--source", _source, "--rule-set", ruleSet, - "--strong-name-key", keyPath, "--dry-run"); - (ExitCode realRunCode, string realRunOutput, _) = Invoke( - "instrument", "--source", _source, "--rule-set", ruleSet, - "--strong-name-key", keyPath, "--output", _staging); - - Assert.Equal(ExitCode.InstrumentationError, dryRunCode); - Assert.Equal(ExitCode.InstrumentationError, realRunCode); - Assert.Contains(RewriteDiagnosticIds.StrongNameReSignRequired, dryRunOutput); - Assert.Contains(RewriteDiagnosticIds.StrongNameReSignRequired, realRunOutput); - Assert.Contains("truncated", dryRunOutput, StringComparison.Ordinal); - Assert.Contains("truncated", realRunOutput, StringComparison.Ordinal); + Assert.Contains("strip strong-name identity", output); } [Fact] diff --git a/tests/Clockwork.Instrumentation.Tests/Configuration/InstrumentationConfigurationTests.cs b/tests/Clockwork.Instrumentation.Tests/Configuration/InstrumentationConfigurationTests.cs index ffe8ba2..feb82f2 100644 --- a/tests/Clockwork.Instrumentation.Tests/Configuration/InstrumentationConfigurationTests.cs +++ b/tests/Clockwork.Instrumentation.Tests/Configuration/InstrumentationConfigurationTests.cs @@ -19,7 +19,6 @@ public void AppliesDefaultsForMinimalDocument() Assert.Equal(InstrumentationMode.Controlled, config.Mode); Assert.Null(config.TargetRuntime); - Assert.Null(config.StrongNameKeyPath); Assert.Single(config.RuleSetPaths); } @@ -36,8 +35,7 @@ public void ParsesAllFields() "builtInExcludeFamilies": ["Crypto"], "include": ["App*.dll"], "exclude": ["*.Tests.dll"], - "targetRuntime": "10.0", - "strongNameKeyPath": "app.snk" + "targetRuntime": "10.0" } """; @@ -51,7 +49,6 @@ public void ParsesAllFields() Assert.Equal(["App*.dll"], config.IncludePatterns); Assert.Equal(["*.Tests.dll"], config.ExcludePatterns); Assert.Equal(new Version(10, 0), config.TargetRuntime); - Assert.Equal("app.snk", config.StrongNameKeyPath); } [Fact] @@ -87,11 +84,10 @@ public void ResolvesRelativePathsAgainstBaseDirectory() { string baseDir = OperatingSystem.IsWindows() ? @"C:\proj\cfg" : "/proj/cfg"; InstrumentationConfiguration config = InstrumentationConfigurationLoader.Parse( - """{ "schemaVersion": 2, "ruleSets": ["rules/clock.json"], "strongNameKeyPath": "keys/app.snk" }""", + """{ "schemaVersion": 2, "ruleSets": ["rules/clock.json"] }""", baseDir); Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "rules/clock.json")), config.RuleSetPaths[0]); - Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "keys/app.snk")), config.StrongNameKeyPath); } [Theory] @@ -133,6 +129,7 @@ public void RejectsSchemaVersionOne() [InlineData("instrumentDependencies")] [InlineData("readyToRunPolicy")] [InlineData("strongNamePolicy")] + [InlineData("strongNameKeyPath")] public void RejectsRemovedRootProperties(string propertyName) { string json = $$"""{ "schemaVersion": 2, "{{propertyName}}": true }"""; @@ -224,7 +221,6 @@ public void EveryEffectiveJsonPropertyChangesTheSignature() ("""{ "schemaVersion": 2 }""", """{ "schemaVersion": 2, "include": ["App*.dll"] }"""), ("""{ "schemaVersion": 2 }""", """{ "schemaVersion": 2, "exclude": ["*.Tests.dll"] }"""), ("""{ "schemaVersion": 2 }""", """{ "schemaVersion": 2, "targetRuntime": "10.0" }"""), - ("""{ "schemaVersion": 2 }""", """{ "schemaVersion": 2, "strongNameKeyPath": "app.snk" }"""), ]; foreach ((string baselineDocument, string changedDocument) in cases) diff --git a/tests/Clockwork.Instrumentation.Tests/Execution/ProcessExecutionTests.cs b/tests/Clockwork.Instrumentation.Tests/Execution/ProcessExecutionTests.cs index 856ea61..1603a1c 100644 --- a/tests/Clockwork.Instrumentation.Tests/Execution/ProcessExecutionTests.cs +++ b/tests/Clockwork.Instrumentation.Tests/Execution/ProcessExecutionTests.cs @@ -181,18 +181,14 @@ public void IncrementalRebuildLeavesRunnableClosure() } [Fact] - public void SignedClosureExecutesAfterReSigning() + public void SignedClosureExecutesAfterIdentityStripping() { using var fixture = ExecutionClosureFixture.Create(strongName: true); - var configuration = new InstrumentationConfiguration - { - StrongNameKeyPath = fixture.StrongNameKeyPath, - }; - InstrumentationResult result = fixture.Instrument(configuration); + InstrumentationResult result = fixture.Instrument(); Assert.True(result.Succeeded, string.Join("\n", result.Errors)); - // A re-signed strong-named closure with consistent public-key tokens loads and runs. + Assert.All(result.Assemblies, assembly => Assert.False(assembly.WasReSigned)); AppRunResult staged = fixture.RunStaged(); Assert.Equal(0, staged.ExitCode); Assert.Contains("app.ticks=999", staged.Output); diff --git a/tests/Clockwork.Instrumentation.Tests/Infrastructure/CecilInspect.cs b/tests/Clockwork.Instrumentation.Tests/Infrastructure/CecilInspect.cs index e370658..d54e143 100644 --- a/tests/Clockwork.Instrumentation.Tests/Infrastructure/CecilInspect.cs +++ b/tests/Clockwork.Instrumentation.Tests/Infrastructure/CecilInspect.cs @@ -115,5 +115,12 @@ public static bool AnyMethodCallsContaining(ModuleDefinition module, string frag /// Returns if the assembly carries the idempotence signature marker. public static bool HasRewriteSignature(ModuleDefinition module) => - module.Assembly.CustomAttributes.Any(a => a.AttributeType.Name == "ClockworkRewriteSignatureAttribute"); + module.Assembly.CustomAttributes.Any(attribute => + attribute.AttributeType.Name == "ClockworkRewriteSignatureAttribute" || + attribute.AttributeType.FullName == "System.Reflection.AssemblyMetadataAttribute" && + attribute.ConstructorArguments.Count == 2 && + string.Equals( + attribute.ConstructorArguments[0].Value as string, + "Clockwork.RewriteSignature", + StringComparison.Ordinal)); } diff --git a/tests/Clockwork.Instrumentation.Tests/Infrastructure/ExecutionClosureFixture.cs b/tests/Clockwork.Instrumentation.Tests/Infrastructure/ExecutionClosureFixture.cs index 0f691ce..696f4c2 100644 --- a/tests/Clockwork.Instrumentation.Tests/Infrastructure/ExecutionClosureFixture.cs +++ b/tests/Clockwork.Instrumentation.Tests/Infrastructure/ExecutionClosureFixture.cs @@ -93,9 +93,6 @@ private ExecutionClosureFixture(string root, string sourceDirectory, string stag /// Gets the staging directory the instrumented closure is written to. public string StagingDirectory { get; } - /// Gets the path of the strong-name key when the closure is strong-named; otherwise . - public string? StrongNameKeyPath { get; private set; } - /// Gets the path of the original (uninstrumented) application assembly. public string SourceAppPath => Path.Combine(SourceDirectory, AppAssemblyName + ".dll"); @@ -121,7 +118,6 @@ public static ExecutionClosureFixture Create( { keyPath = Path.Combine(root, "closure.snk"); File.WriteAllBytes(keyPath, StrongNameKeys.CreatePrivateKeyBlob()); - fixture.StrongNameKeyPath = keyPath; } string api = FixtureCompiler.Compile( diff --git a/tests/Clockwork.Instrumentation.Tests/Orchestration/InstrumentationRunnerTests.cs b/tests/Clockwork.Instrumentation.Tests/Orchestration/InstrumentationRunnerTests.cs index 6efb836..ee2a398 100644 --- a/tests/Clockwork.Instrumentation.Tests/Orchestration/InstrumentationRunnerTests.cs +++ b/tests/Clockwork.Instrumentation.Tests/Orchestration/InstrumentationRunnerTests.cs @@ -21,8 +21,8 @@ namespace Clockwork.Instrumentation.Tests.Orchestration; /// Verifies the end-to-end instrumentation orchestrator: it stages a runnable closure (rewriting /// managed assemblies and copying every other asset verbatim), never mutates the source, is /// incrementally cached and cache-invalidated on input changes, emits a deterministic closure -/// manifest, strips ReadyToRun inputs, and re-signs a strong-named closure only with a key that -/// preserves its public-key token. +/// manifest, strips ReadyToRun inputs, and strips strong-name identities consistently across the +/// rewritten closure. /// public sealed class InstrumentationRunnerTests : IDisposable { @@ -116,7 +116,6 @@ public void RejectsWindowsDeviceRequestPathBeforeFilesystemMutation( "manifest" => request.ManifestPath, "cache" => request.CachePath, "rules" => Path.Combine(_root, "rules", "instrumentation.rules.json"), - "signing-key" => Path.Combine(_root, "keys", "instrumentation.snk"), _ => throw new InvalidOperationException($"Unknown request path kind '{pathKind}'."), }; string devicePath = ToWindowsDevicePath(ordinaryPath, devicePathKind); @@ -130,10 +129,6 @@ public void RejectsWindowsDeviceRequestPathBeforeFilesystemMutation( { Configuration = request.Configuration with { RuleSetPaths = [devicePath] }, }, - "signing-key" => request with - { - Configuration = request.Configuration with { StrongNameKeyPath = devicePath }, - }, _ => throw new InvalidOperationException($"Unknown request path kind '{pathKind}'."), }; Dictionary before = SnapshotRootFiles(); @@ -150,7 +145,6 @@ public void RejectsWindowsDeviceRequestPathBeforeFilesystemMutation( "manifest" => nameof(InstrumentationRequest.ManifestPath), "cache" => nameof(InstrumentationRequest.CachePath), "rules" => nameof(InstrumentationConfiguration.RuleSetPaths), - "signing-key" => nameof(InstrumentationConfiguration.StrongNameKeyPath), _ => throw new InvalidOperationException($"Unknown request path kind '{pathKind}'."), }, closureException.Message, @@ -267,7 +261,7 @@ public static TheoryData WindowsDeviceRequestPathCases { get { - string[] pathKinds = ["source", "staging", "manifest", "cache", "rules", "signing-key"]; + string[] pathKinds = ["source", "staging", "manifest", "cache", "rules"]; string[] devicePathKinds = [ "extended", @@ -619,7 +613,6 @@ public void HardLinkedPredictableTemporaryFilesRemainUntouchedOnWindows() [Theory] [InlineData("configuration")] [InlineData("rules")] - [InlineData("key")] public void RejectsMetadataWhichWouldOverwriteProtectedConfigurationInput(string inputKind) { BuildMinimalApp(); @@ -630,7 +623,6 @@ public void RejectsMetadataWhichWouldOverwriteProtectedConfigurationInput(string { "configuration" => configuration with { SourcePath = inputPath }, "rules" => configuration with { RuleSetPaths = [inputPath] }, - "key" => configuration with { StrongNameKeyPath = inputPath }, _ => throw new InvalidOperationException($"Unknown input kind '{inputKind}'."), }; @@ -1069,45 +1061,6 @@ public void InaccessiblePlannedAssetFailsInsteadOfUsingSharedCacheSentinel() Assert.False(File.Exists(CachePath())); } - [Fact] - public void MissingConfiguredKeyFailsAndRemovesStaleCacheWithoutComputingAKey() - { - BuildMinimalApp(); - string missingKeyPath = Path.Combine(_root, "missing.snk"); - File.WriteAllText(CachePath(), "stale"); - - InstrumentationResult result = Run( - new InstrumentationConfiguration { StrongNameKeyPath = missingKeyPath }, - EmptyRuleSet()); - - Assert.False(result.Succeeded); - Assert.False(result.WasIncrementalHit); - Assert.Contains( - result.Errors, - diagnostic => diagnostic.Message.Contains("was not found", StringComparison.Ordinal)); - Assert.False(File.Exists(CachePath())); - Assert.False(Directory.Exists(_staging)); - } - - [Fact] - public void DistinctKeyContentsProduceDistinctIncrementalKeys() - { - BuildMinimalApp(); - string keyPath = WriteKey(); - var configuration = new InstrumentationConfiguration { StrongNameKeyPath = keyPath }; - InstrumentationResult first = Run(configuration, EmptyRuleSet()); - string firstKey = ReadIncrementalKey(); - File.WriteAllBytes(keyPath, StrongNameKeys.CreatePrivateKeyBlob()); - - InstrumentationResult second = Run(configuration, EmptyRuleSet()); - string secondKey = ReadIncrementalKey(); - - Assert.True(first.Succeeded, string.Join("\n", first.Errors)); - Assert.True(second.Succeeded, string.Join("\n", second.Errors)); - Assert.False(second.WasIncrementalHit); - Assert.NotEqual(firstKey, secondKey); - } - [Fact] public void ChangedInstrumentationModeInvalidatesIncrementalCache() { @@ -1307,7 +1260,7 @@ public void ManifestIsDeterministic() } [Fact] - public void ReadyToRunInputIsStrippedBeforeStrongNameValidation() + public void ReadyToRunInputHasNativeAndStrongNameIdentityStripped() { string? r2r = FindReadyToRunAssembly(); Assert.SkipWhen(r2r is null, "No ReadyToRun image found in the shared framework."); @@ -1317,17 +1270,17 @@ public void ReadyToRunInputIsStrippedBeforeStrongNameValidation() InstrumentationResult result = Run(new InstrumentationConfiguration(), EmptyRuleSet()); - Assert.False(result.Succeeded); + Assert.True(result.Succeeded, string.Join("\n", result.Errors)); AssemblyInstrumentationResult stripped = result.Assemblies.Single(a => a.RelativePath == "r2rdep.dll"); Assert.True(stripped.ReadyToRunStripped); Assert.Contains(stripped.Diagnostics, d => d.Id == RewriteDiagnosticIds.ReadyToRunStripped); - Assert.Contains(stripped.Errors, d => d.Id == RewriteDiagnosticIds.StrongNameReSignRequired); - // A failed run must not leave a stale cache that would skip the next build. - Assert.False(File.Exists(CachePath())); + Assert.Contains(stripped.Diagnostics, d => d.Id == RewriteDiagnosticIds.StrongNameStripped); + Assert.Equal(StrongNameStatus.None, StrongNameInspector.Inspect(Path.Combine(_staging, "r2rdep.dll")).Status); + Assert.True(File.Exists(CachePath())); } [Fact] - public void ReadyToRunFailurePreservesOldTempNamedAssetAndCleansWorkspace() + public void ReadyToRunRewritePreservesOldTempNamedAssetAndCleansWorkspace() { string? r2r = FindReadyToRunAssembly(); Assert.SkipWhen(r2r is null, "No ReadyToRun image found in the shared framework."); @@ -1346,7 +1299,7 @@ public void ReadyToRunFailurePreservesOldTempNamedAssetAndCleansWorkspace() InstrumentationResult result = Run(new InstrumentationConfiguration(), EmptyRuleSet()); - Assert.False(result.Succeeded); + Assert.True(result.Succeeded, string.Join("\n", result.Errors)); Assert.Contains(oldTemporaryName, result.CopiedAssets); Assert.Equal( legitimateAsset, @@ -1373,20 +1326,7 @@ public void ReadyToRunStripperProducesManagedILOnlyOutput() } [Fact] - public void FailsOnStrongNamedInputWithoutKey() - { - string keyPath = WriteKey(); - Compile("app", "namespace App { public static class A { public static int Go() => 1; } }", keyPath); - File.WriteAllText(Path.Combine(_source, "app.runtimeconfig.json"), "{}"); - - InstrumentationResult result = Run(new InstrumentationConfiguration(), EmptyRuleSet()); - - Assert.False(result.Succeeded); - Assert.Contains(result.Errors, d => d.Id == RewriteDiagnosticIds.StrongNameReSignRequired); - } - - [Fact] - public void ReSignsStrongNamedClosureConsistently() + public void StripsStrongNamedClosureConsistently() { string keyPath = WriteKey(); string third = Compile( @@ -1398,50 +1338,40 @@ public void ReSignsStrongNamedClosureConsistently() references: [third]); File.WriteAllText(Path.Combine(_source, "app.runtimeconfig.json"), "{}"); - var config = new InstrumentationConfiguration - { - StrongNameKeyPath = keyPath, - }; - InstrumentationResult result = Run(config, EmptyRuleSet()); - - Assert.True(result.Succeeded, string.Join("\n", result.Errors)); - Assert.All(result.Assemblies, a => Assert.True(a.WasReSigned)); - - // Every re-signed assembly carries the same public-key token, and the app's reference to the - // dependency still matches the dependency's token: the closure is signing-consistent. - string appToken = TokenOf(Path.Combine(_staging, "app.dll")); - string depToken = TokenOf(Path.Combine(_staging, "thirdparty.dll")); - Assert.Equal(depToken, appToken); - Assert.Equal(depToken, ReferenceTokenOf(Path.Combine(_staging, "app.dll"), "thirdparty")); - } - - [Fact] - public void UnsignedInputRemainsUnsignedWhenKeyIsConfigured() - { - BuildMinimalApp(); - InstrumentationResult result = Run( - new InstrumentationConfiguration { StrongNameKeyPath = WriteKey() }, EmptyRuleSet()); + InstrumentationResult result = Run(new InstrumentationConfiguration(), EmptyRuleSet()); Assert.True(result.Succeeded, string.Join("\n", result.Errors)); Assert.All(result.Assemblies, assembly => Assert.False(assembly.WasReSigned)); Assert.Equal(StrongNameStatus.None, StrongNameInspector.Inspect(Path.Combine(_staging, "app.dll")).Status); + Assert.Equal(StrongNameStatus.None, StrongNameInspector.Inspect(Path.Combine(_staging, "thirdparty.dll")).Status); + Assert.Null(ReferenceTokenOf(Path.Combine(_staging, "app.dll"), "thirdparty")); } [Fact] - public void FailsWhenSigningKeyChangesPublicKeyToken() + public void PreservesStrongIdentityReferencedByCopiedAssembly() { - string originalKeyPath = WriteKey("original.snk"); - Compile("app", "namespace App { public static class A { public static int Go() => 1; } }", originalKeyPath); + string keyPath = WriteKey(); + string dependency = Compile( + "dependency", + "namespace Dependency { public static class Value { public static int Get() => 1; } }", + keyPath); + Compile( + "app", + "namespace App { public static class Entry { public static int Get() => Dependency.Value.Get(); } }", + keyPath, + references: [dependency]); File.WriteAllText(Path.Combine(_source, "app.runtimeconfig.json"), "{}"); InstrumentationResult result = Run( - new InstrumentationConfiguration { StrongNameKeyPath = WriteKey("other.snk") }, EmptyRuleSet()); + new InstrumentationConfiguration { IncludePatterns = ["dependency.dll"] }, + EmptyRuleSet()); - Assert.False(result.Succeeded); - Assert.Contains( - result.Errors, - diagnostic => diagnostic.Id == RewriteDiagnosticIds.StrongNameReSignRequired - && diagnostic.Message.Contains("public-key token", StringComparison.Ordinal)); + Assert.True(result.Succeeded, string.Join("\n", result.Errors)); + string stagedDependency = Path.Combine(_staging, "dependency.dll"); + Assert.Equal(StrongNameStatus.StrongNameSigned, StrongNameInspector.Inspect(stagedDependency).Status); + Assert.Equal( + StrongNameInspector.Inspect(stagedDependency).PublicKeyToken, + ReferenceTokenOf(Path.Combine(_staging, "app.dll"), "dependency")); } private InstrumentationResult Run( @@ -1694,11 +1624,7 @@ private static void RemoveMappedDrive(string drive) return null; } - private static string TokenOf(string assemblyPath) => - StrongNameInspector.Inspect(assemblyPath).PublicKeyToken - ?? throw new InvalidOperationException($"'{assemblyPath}' is not strong-named."); - - private static string ReferenceTokenOf(string assemblyPath, string referenceName) + private static string? ReferenceTokenOf(string assemblyPath, string referenceName) { using AssemblyDefinition definition = AssemblyDefinition.ReadAssembly( assemblyPath, new ReaderParameters { ReadSymbols = false, InMemory = true }); diff --git a/tests/Clockwork.Instrumentation.Tests/Packaging/PackageSmokeTests.cs b/tests/Clockwork.Instrumentation.Tests/Packaging/PackageSmokeTests.cs index 32d746a..6b2b103 100644 --- a/tests/Clockwork.Instrumentation.Tests/Packaging/PackageSmokeTests.cs +++ b/tests/Clockwork.Instrumentation.Tests/Packaging/PackageSmokeTests.cs @@ -97,6 +97,99 @@ public void BuildPackageDoesNothingWhenDisabled() Assert.Contains("ticks=100", normal.Output); } + [Fact] + public void BuildPackageRunsInstrumentedTestProjectsFromStagedClosure() + { + Assert.SkipUnless(SmokeEnabled, "Set CLOCKWORK_SMOKE_TESTS=1 to run package smoke tests."); + ConsumerProject consumer = Artifacts.Value.ScaffoldConsumer( + "InstrumentedTestApp", + instrumentationEnabled: false, + instrumentedTestProject: true); + + AppRunResult build = consumer.Build(); + Assert.True(build.ExitCode == 0, $"Build failed:\n{build.StandardOutput}\n{build.StandardError}"); + + AppRunResult pristine = ProcessAppRunner.Run(consumer.UninstrumentedAppPath); + Assert.Equal(0, pristine.ExitCode); + Assert.Contains("instrumented=False", pristine.Output); + + AppRunResult deployed = ProcessAppRunner.Run(consumer.OutputAppPath); + Assert.Equal(0, deployed.ExitCode); + Assert.Contains("instrumented=True", deployed.Output); + + AppRunResult testRun = consumer.Run("forwarded"); + Assert.True( + testRun.ExitCode == 0, + $"Instrumented test run failed ({testRun.ExitCode}):\n{testRun.StandardOutput}\n{testRun.StandardError}"); + Assert.Contains("instrumented=True", testRun.Output); + Assert.Contains("argument=forwarded", testRun.Output); + } + + [Fact] + public void BuildPackageRunsDotnetTestNoBuildFromStagedClosure() + { + Assert.SkipUnless(SmokeEnabled, "Set CLOCKWORK_SMOKE_TESTS=1 to run package smoke tests."); + ConsumerProject consumer = Artifacts.Value.ScaffoldConsumer( + "InstrumentedMtpTestApp", + instrumentationEnabled: false, + instrumentedTestProject: true, + testingPlatformProject: true); + + AppRunResult build = consumer.Build(); + Assert.True(build.ExitCode == 0, $"Build failed:\n{build.StandardOutput}\n{build.StandardError}"); + + using (Mono.Cecil.ModuleDefinition ordinary = Mono.Cecil.ModuleDefinition.ReadModule(consumer.UninstrumentedAppPath)) + { + Assert.False(CecilInspect.HasRewriteSignature(ordinary)); + } + using (Mono.Cecil.ModuleDefinition deployed = Mono.Cecil.ModuleDefinition.ReadModule(consumer.OutputAppPath)) + { + Assert.False(CecilInspect.HasRewriteSignature(deployed)); + } + using (Mono.Cecil.ModuleDefinition subject = Mono.Cecil.ModuleDefinition.ReadModule(consumer.StagedSubjectPath)) + { + Assert.True(CecilInspect.HasRewriteSignature(subject)); + } + + AppRunResult testRun = consumer.TestNoBuild(); + Assert.True( + testRun.ExitCode == 0, + $"Instrumented dotnet test --no-build failed ({testRun.ExitCode}):\n{testRun.StandardOutput}\n{testRun.StandardError}"); + Assert.Contains("succeeded: 1", testRun.StandardOutput, StringComparison.OrdinalIgnoreCase); + + consumer.AddPassingTest(); + AppRunResult incrementalBuild = consumer.Build(); + Assert.True( + incrementalBuild.ExitCode == 0, + $"Incremental build failed:\n{incrementalBuild.StandardOutput}\n{incrementalBuild.StandardError}"); + AppRunResult incrementalTestRun = consumer.TestNoBuild(); + Assert.True( + incrementalTestRun.ExitCode == 0, + $"Instrumented test after incremental build failed ({incrementalTestRun.ExitCode}):\n{incrementalTestRun.StandardOutput}\n{incrementalTestRun.StandardError}"); + Assert.Contains("succeeded: 2", incrementalTestRun.StandardOutput, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildPackageBuiltInRulesKeepTestingPlatformRunnable() + { + Assert.SkipUnless(SmokeEnabled, "Set CLOCKWORK_SMOKE_TESTS=1 to run package smoke tests."); + ConsumerProject consumer = Artifacts.Value.ScaffoldConsumer( + "InstrumentedBuiltInMtpTestApp", + instrumentationEnabled: false, + useBuiltInRules: true, + instrumentedTestProject: true, + testingPlatformProject: true, + useConfigurationFile: true); + + AppRunResult build = consumer.Build(); + Assert.True(build.ExitCode == 0, $"Build failed:\n{build.StandardOutput}\n{build.StandardError}"); + + AppRunResult testRun = consumer.TestNoBuild(); + Assert.True( + testRun.ExitCode == 0, + $"Built-in instrumented test failed ({testRun.ExitCode}):\n{testRun.StandardOutput}\n{testRun.StandardError}"); + } + [Fact] public void BuildPackageSupportsExplicitRaceExplorationMode() { @@ -138,20 +231,23 @@ public void BuildPackageReinstrumentsWhenModeTogglesBackAndForth() } [Fact] - public void BuildPackageRetriesUnchangedFailedInstrumentation() + public void BuildPackageStripsStrongNameWithoutMutatingProductionOutput() { Assert.SkipUnless(SmokeEnabled, "Set CLOCKWORK_SMOKE_TESTS=1 to run package smoke tests."); ConsumerProject consumer = Artifacts.Value.ScaffoldConsumer( - "FailedApp", instrumentationEnabled: true, signEntryAssembly: true); + "SignedApp", instrumentationEnabled: true, signEntryAssembly: true); - AppRunResult first = consumer.Build(); - AppRunResult second = consumer.Build(); + AppRunResult build = consumer.Build(); - Assert.NotEqual(0, first.ExitCode); - Assert.NotEqual(0, second.ExitCode); - Assert.Contains("Clockwork: instrumenting", first.StandardOutput); - Assert.Contains("Clockwork: instrumenting", second.StandardOutput); - Assert.False(File.Exists(consumer.SuccessPath)); + Assert.True(build.ExitCode == 0, $"Build failed:\n{build.StandardOutput}\n{build.StandardError}"); + Assert.Equal( + Clockwork.Instrumentation.Signing.StrongNameStatus.StrongNameSigned, + Clockwork.Instrumentation.Signing.StrongNameInspector.Inspect(consumer.OutputAppPath).Status); + Assert.Equal( + Clockwork.Instrumentation.Signing.StrongNameStatus.None, + Clockwork.Instrumentation.Signing.StrongNameInspector.Inspect(consumer.StagedAppPath).Status); + Assert.Contains("ticks=999", ProcessAppRunner.Run(consumer.StagedAppPath).Output); + Assert.True(File.Exists(consumer.SuccessPath)); } [Fact] @@ -367,13 +463,18 @@ public ConsumerProject ScaffoldConsumer( bool instrumentationEnabled, bool signEntryAssembly = false, bool useBuiltInRules = false, + bool instrumentedTestProject = false, + bool testingPlatformProject = false, + bool useConfigurationFile = false, InstrumentationMode mode = InstrumentationMode.Controlled) { string rootDir = Path.Combine(Root, name); string appDir = Path.Combine(rootDir, "app"); string libDir = Path.Combine(rootDir, "lib"); + string subjectDir = Path.Combine(rootDir, "subject"); Directory.CreateDirectory(appDir); Directory.CreateDirectory(libDir); + Directory.CreateDirectory(subjectDir); // A single nuget.config at the solution root is discovered by both projects via the // standard walk-up, wiring in the freshly packed local feed. @@ -387,6 +488,20 @@ public ConsumerProject ScaffoldConsumer( """); + if (testingPlatformProject) + { + File.WriteAllText(Path.Combine(rootDir, "global.json"), """ + { + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } + } + """); + } // The controlled API and the shim live in one dependency assembly, in its own directory // so the executable project's default source glob does not also compile it. The redirect @@ -416,13 +531,73 @@ public static class Shim """); + File.WriteAllText(Path.Combine(subjectDir, "SmokeSubject.cs"), """ + using SmokeApi; + + namespace SmokeSubject; + + public static class Subject + { + public static long Capture() => RealClock.UtcNowTicks(); + } + """); + File.WriteAllText(Path.Combine(subjectDir, "SmokeSubject.csproj"), """ + + + net10.0 + enable + enable + SmokeSubject + + + + + + """); - string programSource = useBuiltInRules + string programSource = testingPlatformProject && useBuiltInRules + ? """ + using Xunit; + + public sealed class InstrumentedTests + { + [Fact] + public void TestingPlatformStartsOutsideSimulation() => Assert.True(true); + } + """ + : testingPlatformProject + ? """ + using SmokeSubject; + using System.Reflection; + using Xunit; + + public sealed class InstrumentedTests + { + [Fact] + public void UsesRewrittenDependency() => + Assert.True( + typeof(Subject).Assembly + .GetCustomAttributes() + .Any(attribute => attribute.Key == "Clockwork.RewriteSignature")); + } + """ + : useBuiltInRules ? """ _ = System.DateTime.UtcNow; System.IO.File.WriteAllText("side-effect.txt", "unexpected"); System.Console.WriteLine("reached-end"); """ + : instrumentedTestProject + ? """ + using SmokeSubject; + using System.Reflection; + + bool instrumented = typeof(Subject).Assembly + .GetCustomAttributes() + .Any(attribute => attribute.Key == "Clockwork.RewriteSignature"); + System.Console.WriteLine("instrumented=" + instrumented); + System.Console.WriteLine("argument=" + args.SingleOrDefault()); + """ : """ using SmokeApi; @@ -431,7 +606,13 @@ public static class Shim File.WriteAllText(Path.Combine(appDir, "Program.cs"), programSource); string enabled = instrumentationEnabled ? "true" : "false"; - string builtIn = useBuiltInRules ? "true" : "false"; + string builtIn = useBuiltInRules && !useConfigurationFile ? "true" : "false"; + string testProject = instrumentedTestProject ? "true" : "false"; + string testingPackage = testingPlatformProject + ? """ + + """ + : string.Empty; string runtimeReference = useBuiltInRules ? $""" @@ -441,8 +622,14 @@ public static class Shim """ : string.Empty; string ruleSetItem = useBuiltInRules - ? string.Empty - : """ + ? testingPlatformProject && !useConfigurationFile + ? """ + + """ + : string.Empty + : useConfigurationFile + ? string.Empty + : """ """; string signingProperties = string.Empty; @@ -464,16 +651,18 @@ public static class Shim enable SmokeApp {enabled} + {testProject} {mode} {builtIn} {signingProperties} + {testingPackage} {runtimeReference} - + {ruleSetItem} @@ -491,6 +680,26 @@ public static class Shim RewriteReplacement.Method("SmokeApi", "SmokeApi.Shim", "UtcNowTicks")), ]); File.WriteAllText(Path.Combine(appDir, "clockwork.rules.json"), RuleSetJson.Write(ruleSet)); + if (useConfigurationFile) + { + string configuration = useBuiltInRules + ? """ + { + "schemaVersion": 2, + "builtInRuleSets": [ + "clockwork.bcl.deterministic", + "clockwork.tasks.controlled" + ] + } + """ + : """ + { + "schemaVersion": 2, + "ruleSets": ["clockwork.rules.json"] + } + """; + File.WriteAllText(Path.Combine(appDir, "clockwork.config.json"), configuration); + } return new ConsumerProject(appDir, _packagesDirectory, mode); } @@ -575,10 +784,15 @@ public ConsumerProject( public string OutputAppPath => Path.Combine(ProjectDirectory, OutputRelative, "SmokeApp.dll"); + public string UninstrumentedAppPath => + Path.Combine(ProjectDirectory, "obj/Release/net10.0/clockwork/source", "SmokeApp.dll"); + public string StagingDirectory => Path.Combine(ProjectDirectory, StagingRelative); public string StagedAppPath => Path.Combine(StagingDirectory, "SmokeApp.dll"); + public string StagedSubjectPath => Path.Combine(StagingDirectory, "SmokeSubject.dll"); + public string ManifestPath => Path.Combine(ProjectDirectory, ManifestRelative); public string SuccessPath => Path.Combine( @@ -595,6 +809,28 @@ public void ReplaceShimTicks(long ticks) File.WriteAllText(sourcePath, source.Replace("999L", ticks + "L", StringComparison.Ordinal)); } + public void AddPassingTest() + { + string sourcePath = Path.Combine(ProjectDirectory, "Program.cs"); + string source = File.ReadAllText(sourcePath); + const string existing = + "public void UsesRewrittenDependency() =>"; + int methodStart = source.IndexOf(existing, StringComparison.Ordinal); + int classEnd = source.LastIndexOf('}'); + if (methodStart < 0 || classEnd < 0) + { + throw new InvalidOperationException("Could not locate generated test source."); + } + + File.WriteAllText( + sourcePath, + source.Insert( + classEnd, + Environment.NewLine + + " [Fact]" + Environment.NewLine + + " public void RebuiltSourceIsUsed() => Assert.True(true);" + Environment.NewLine)); + } + public void SetInstrumentationMode(InstrumentationMode mode) { string projectPath = Path.Combine(ProjectDirectory, "SmokeApp.csproj"); @@ -622,5 +858,54 @@ public AppRunResult Build() => ProcessAppRunner.Execute( ["DOTNET_NOLOGO"] = "1", }, TimeSpan.FromSeconds(300)); + + public AppRunResult Run(params string[] arguments) + { + var command = new List + { + "run", + "--project", + "SmokeApp.csproj", + "--configuration", + "Release", + "--no-build", + "--", + }; + command.AddRange(arguments); + return ProcessAppRunner.Execute( + "dotnet", + command, + ProjectDirectory, + new Dictionary + { + ["NUGET_PACKAGES"] = _packagesDirectory, + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", + ["DOTNET_NOLOGO"] = "1", + }, + TimeSpan.FromSeconds(300)); + } + + public AppRunResult TestNoBuild() => ProcessAppRunner.Execute( + "dotnet", + [ + "test", + "--project", + "SmokeApp.csproj", + "--configuration", + "Release", + "--no-build", + "--no-ansi", + "--no-progress", + ], + ProjectDirectory, + new Dictionary + { + ["NUGET_PACKAGES"] = _packagesDirectory, + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", + ["DOTNET_NOLOGO"] = "1", + }, + TimeSpan.FromSeconds(300)); } } diff --git a/tests/Clockwork.Instrumentation.Tests/Rules/CanonicalSignatureTests.cs b/tests/Clockwork.Instrumentation.Tests/Rules/CanonicalSignatureTests.cs index 2e59101..6919679 100644 --- a/tests/Clockwork.Instrumentation.Tests/Rules/CanonicalSignatureTests.cs +++ b/tests/Clockwork.Instrumentation.Tests/Rules/CanonicalSignatureTests.cs @@ -109,8 +109,6 @@ public void ConfigurationAndRewriteOptionListsCannotCollide() { IncludePatterns = ["A%2CB"], }; - var noKey = new InstrumentationConfiguration(); - var emptyKeyPath = new InstrumentationConfiguration { StrongNameKeyPath = string.Empty }; var onePath = new RewriteOptions { ReplacementAssemblyPaths = ["A,B"], @@ -121,7 +119,6 @@ public void ConfigurationAndRewriteOptionListsCannotCollide() }; Assert.NotEqual(escapedComma.ComputeSignature(), literalEscapeText.ComputeSignature()); - Assert.NotEqual(noKey.ComputeSignature(), emptyKeyPath.ComputeSignature()); Assert.NotEqual(onePath.ComputeSemanticFingerprint(), twoPaths.ComputeSemanticFingerprint()); }