diff --git a/.editorconfig b/.editorconfig index f744b2481..8c9209e56 100644 --- a/.editorconfig +++ b/.editorconfig @@ -39,6 +39,18 @@ dotnet_style_collection_initializer = true:suggestion dotnet_style_object_initializer = true:suggestion dotnet_style_prefer_collection_expression = true:suggestion +[SharpProof.BuildTasks/InvalidatePublishedResult.cs] +dotnet_diagnostic.CA1515.severity = none + +[SharpProof.BuildTasks/ResetPublishedVerification.cs] +dotnet_diagnostic.CA1515.severity = none + +[SharpProof.BuildTasks/RunVerifier.cs] +dotnet_diagnostic.CA1515.severity = none + +[SharpProof.BuildTasks/ValidatePublishedVerificationResult.cs] +dotnet_diagnostic.CA1515.severity = none + [SharpProof.Dataflow/IntervalDomain.cs] dotnet_diagnostic.CA1822.severity = none diff --git a/Directory.Build.props b/Directory.Build.props index f443e37ae..4d000e954 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -36,6 +36,7 @@ '$(MSBuildProjectName)' == 'SharpProof.Dataflow' Or '$(MSBuildProjectName)' == 'SharpProof.Effects' Or '$(MSBuildProjectName)' == 'SharpProof.Frontend' Or + '$(MSBuildProjectName)' == 'SharpProof.Fuzz' Or '$(MSBuildProjectName)' == 'SharpProof.Gates' Or '$(MSBuildProjectName)' == 'SharpProof.Host' Or '$(MSBuildProjectName)' == 'SharpProof.Ir' Or diff --git a/README.md b/README.md index f903950b3..2f16b93eb 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ both implemented feature groups: verification. `SharpProofFeatures` values are `effects`, `contracts`, and `all` (the -default). The effective selection is sealed into the schema-14 compiler +default). The effective selection is sealed into the schema-15 compiler artifact and filters its manifest: `contracts` excludes effect-only annotations, `effects` excludes postcondition claims and contract assumptions, and `all` selects both surfaces. Every effective effect contract has one typed @@ -425,7 +425,7 @@ Each manifest claim receives: Effect claims use canonical compiler-produced evidence. They are `Proven` only when a complete effect summary establishes the selected contract. Compiler -artifact schema 14 retains schema 10's independently replayable, +artifact schema 15 retains schema 10's independently replayable, unconditional direct event for a definite managed object or array allocation. The worker validates the event's order, source-tree hash and span, semantic identity, selected constraint, and compiler witness, then derives the @@ -524,7 +524,7 @@ unsupported opcodes, recursive dependencies, and exhausted budgets abstain as typed `Unknown`; they are never treated as implementation proof authority. Every composed call seals its origin, evidence digest, optional pack identity, and complete transitive dependency-evidence closure into compiler artifact -schema 14. Relational-summary schema version 2 and specification-pack schema +schema 15. Relational-summary schema version 2 and specification-pack schema version 1 govern those evidence records. Specification packs are off by default. The preview ships one data-driven @@ -720,7 +720,7 @@ host shadowing and arbitrary relative overrides are rejected before any push. ## Closed compiler artifact and remaining release gaps -The build-only collector now emits compiler artifact schema version 14 from the +The build-only collector now emits compiler artifact schema version 15 from the final post-generator Roslyn `Compilation`. It seals the feature-selected claim manifest and, for each selected callable, either a typed lowering failure or portable whole-body CFG/IR with bound contract clauses, canonical variables, diff --git a/SEMANTICS.md b/SEMANTICS.md index 8403f164d..4a89aa8c2 100644 --- a/SEMANTICS.md +++ b/SEMANTICS.md @@ -60,7 +60,7 @@ manifest claims while sharing the effective combined constraint and evidence. Each effect claim is `Proven` only when a complete compiler-produced effect summary establishes its contract. The compiler can record a structured `DefiniteViolation` candidate for a simple unconditional direct operation. -Compiler artifact schema 14 carries an independently replayable event only for +Compiler artifact schema 15 carries an independently replayable event only for a definite managed object or array allocation whose operands are already known to complete and whose allocation is not static-initialization-sensitive. The worker derives `Allocates` from that event rather than trusting the @@ -209,7 +209,7 @@ identity-mismatched pack never contributes a fact. Every summary call seals its origin, SHA-256 evidence, pack identity when applicable, and the canonical transitive provenance of every composed -dependency. Compiler artifact schema 14, relational-summary schema version 2, +dependency. Compiler artifact schema 15, relational-summary schema version 2, and specification-pack schema version 1 validate that closure before backend creation. Unsupported or incomplete calls remain `Unknown`; neither a convenient method @@ -376,7 +376,7 @@ its outcome is not combined with the containing callable. Unavailable captured facts remain unknown. An expression-tree lambda is quoted code and is not treated as an executing call site. -The packaged verifier consumes compiler artifact schema version 14 produced +The packaged verifier consumes compiler artifact schema version 15 produced from the final post-generator compilation. The artifact contains the sealed feature-selected manifest and, for every selected callable, either a typed lowering failure or portable whole-body CFG/IR with bound clauses, canonical diff --git a/SharpProof.Analyzer.Core/AnalyzerFeaturePipeline.cs b/SharpProof.Analyzer.Core/AnalyzerFeaturePipeline.cs index a60173eae..766a42375 100644 --- a/SharpProof.Analyzer.Core/AnalyzerFeaturePipeline.cs +++ b/SharpProof.Analyzer.Core/AnalyzerFeaturePipeline.cs @@ -417,9 +417,7 @@ internal static void AnalyzeMemberInitializer( { context.CancellationToken.ThrowIfCancellationRequested(); if (context.Node is not EqualsValueClauseSyntax initializer || - initializer.Parent is not VariableDeclaratorSyntax and not PropertyDeclarationSyntax || - AnalyzerGeneratedCodePolicy.IsGenerated( - initializer.SyntaxTree, context.Compilation, context.CancellationToken)) + initializer.Parent is not VariableDeclaratorSyntax and not PropertyDeclarationSyntax) { return; } @@ -431,31 +429,79 @@ initializer.Parent is not VariableDeclaratorSyntax and not PropertyDeclarationSy property, context.CancellationToken), _ => null }; - if (symbol is not IFieldSymbol and not IPropertySymbol || + if (symbol is not IFieldSymbol and + not IPropertySymbol and + not IEventSymbol || symbol.ContainingType is not { } type) { return; } var isStatic = symbol.IsStatic; - var constructor = (isStatic + var constructors = (isStatic ? type.StaticConstructors : type.InstanceConstructors) .OrderBy(static candidate => candidate.DeclaringSyntaxReferences + .FirstOrDefault()?.SyntaxTree.FilePath, StringComparer.Ordinal) + .ThenBy(static candidate => candidate.DeclaringSyntaxReferences .FirstOrDefault()?.Span.Start ?? int.MaxValue) - .FirstOrDefault(); + .Where(candidate => + !AnalyzerGeneratedCodePolicy.IsGenerated( + candidate, + candidate.DeclaringSyntaxReferences.FirstOrDefault()?.SyntaxTree ?? + initializer.SyntaxTree, + context.Compilation, + context.CancellationToken)) + .ToArray(); var root = context.SemanticModel.GetOperation( initializer.Value, context.CancellationToken); - if (constructor == null || root == null) + if (constructors.Length == 0 || root == null || + AnalyzerGeneratedCodePolicy.IsGenerated( + symbol, + initializer.SyntaxTree, + context.Compilation, + context.CancellationToken)) { return; } - var outcome = AnalyzerSemanticOutcome.NotApplicable; - foreach (var operation in root.DescendantsAndSelf()) + IMethodSymbol? constructor = null; + foreach (var candidate in constructors) { - if (operation is not IInvocationOperation and not IObjectCreationOperation) + if (SharpProofControlAttributePolicy.ValidateAndShouldSuppress( + candidate, + session, + context.ReportDiagnostic, + context.CancellationToken)) { + session.RecordSemanticOutcome( + candidate, + AnalyzerSemanticOutcome.Suppressed); continue; } + constructor = candidate; + break; + } + if (constructor == null) + { + return; + } + var outcome = AnalyzerSemanticOutcome.NotApplicable; + var operationFacts = new DefiniteOperationFacts( + context.Compilation, + context.CancellationToken); + if (!CanReachMemberInitializer( + initializer, + isStatic, + context.SemanticModel, + operationFacts, + context.CancellationToken)) + { + return; + } + foreach (var operation in RequiresCallSiteDiscovery + .ExecutableUnflowedDescendantsAndSelf( + root, + operationFacts)) + { outcome = AnalyzerSemanticOutcomes.Combine( outcome, RequiresCallSiteAnalyzer.AnalyzeInitializerCall( @@ -466,6 +512,89 @@ initializer.Parent is not VariableDeclaratorSyntax and not PropertyDeclarationSy session.RecordSemanticOutcome(constructor, outcome); } + private static bool CanReachMemberInitializer( + EqualsValueClauseSyntax target, + bool isStatic, + SemanticModel semanticModel, + DefiniteOperationFacts operationFacts, + CancellationToken cancellationToken) + { + var containingType = target.FirstAncestorOrSelf(); + var targetMember = target.FirstAncestorOrSelf(); + if (containingType == null || targetMember == null) + { + return true; + } + + foreach (var member in containingType.Members) + { + foreach (var initializer in GetMemberInitializers(member)) + { + if (initializer.SyntaxTree == target.SyntaxTree && + initializer.Span == target.Span) + { + return true; + } + if (!HasMatchingInitializationKind( + initializer, + isStatic, + semanticModel, + cancellationToken)) + { + continue; + } + var operation = semanticModel.GetOperation( + initializer.Value, + cancellationToken); + if (operation != null && + !operationFacts.MayCompleteNormally(operation)) + { + return false; + } + } + if (member.SyntaxTree == targetMember.SyntaxTree && + member.Span == targetMember.Span) + { + return true; + } + } + return true; + } + + private static IEnumerable GetMemberInitializers( + MemberDeclarationSyntax member) + { + return member switch + { + BaseFieldDeclarationSyntax field => field.Declaration.Variables + .Select(static variable => variable.Initializer) + .OfType(), + PropertyDeclarationSyntax { Initializer: { } initializer } => + [initializer], + _ => [] + }; + } + + private static bool HasMatchingInitializationKind( + EqualsValueClauseSyntax initializer, + bool isStatic, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + var symbol = initializer.Parent switch + { + VariableDeclaratorSyntax variable => semanticModel.GetDeclaredSymbol( + variable, + cancellationToken), + PropertyDeclarationSyntax property => semanticModel.GetDeclaredSymbol( + property, + cancellationToken), + _ => null + }; + return symbol is IFieldSymbol or IPropertySymbol or IEventSymbol && + symbol.IsStatic == isStatic; + } + private static bool ValidateContractClauses( IMethodSymbol method, AnalyzerSession session, diff --git a/SharpProof.Analyzer.Core/AnalyzerGeneratedCodePolicy.cs b/SharpProof.Analyzer.Core/AnalyzerGeneratedCodePolicy.cs index 930621b44..f0ca5a829 100644 --- a/SharpProof.Analyzer.Core/AnalyzerGeneratedCodePolicy.cs +++ b/SharpProof.Analyzer.Core/AnalyzerGeneratedCodePolicy.cs @@ -18,6 +18,19 @@ internal static bool IsGenerated( SyntaxTree tree, Compilation compilation, CancellationToken cancellationToken) + { + return IsGenerated( + (ISymbol)method, + tree, + compilation, + cancellationToken); + } + + internal static bool IsGenerated( + ISymbol symbol, + SyntaxTree tree, + Compilation compilation, + CancellationToken cancellationToken) { if (IsGenerated(tree, compilation, cancellationToken)) { @@ -27,7 +40,7 @@ internal static bool IsGenerated( var generated = compilation.Options.SyntaxTreeOptionsProvider? .IsGenerated(tree, cancellationToken) ?? GeneratedKind.Unknown; return generated != GeneratedKind.NotGenerated && - HasGeneratedCodeAttribute(method, compilation); + HasGeneratedCodeAttribute(symbol, compilation); } internal static bool IsGenerated( @@ -94,7 +107,7 @@ private static bool IsExactGeneratedHeader(string comment) } private static bool HasGeneratedCodeAttribute( - IMethodSymbol method, + ISymbol symbol, Compilation compilation) { var generatedCode = compilation.GetTypeByMetadataName( @@ -104,21 +117,21 @@ private static bool HasGeneratedCodeAttribute( return false; } - var scopes = new List { method }; - if (method.AssociatedSymbol != null) + var scopes = new List { symbol }; + if (symbol is IMethodSymbol { AssociatedSymbol: { } associated }) { - scopes.Add(method.AssociatedSymbol); + scopes.Add(associated); } - for (var type = method.ContainingType; + for (var type = symbol.ContainingType; type != null; type = type.ContainingType) { scopes.Add(type); } - foreach (var symbol in scopes) + foreach (var scope in scopes) { - if (symbol.GetAttributes().Any(attribute => + if (scope.GetAttributes().Any(attribute => SymbolEqualityComparer.Default.Equals( attribute.AttributeClass?.OriginalDefinition, generatedCode.OriginalDefinition))) diff --git a/SharpProof.Analyzer.Core/Configuration/AnalyzerConfiguration.cs b/SharpProof.Analyzer.Core/Configuration/AnalyzerConfiguration.cs index c416b3d9c..a77a32aac 100644 --- a/SharpProof.Analyzer.Core/Configuration/AnalyzerConfiguration.cs +++ b/SharpProof.Analyzer.Core/Configuration/AnalyzerConfiguration.cs @@ -73,6 +73,14 @@ private static ImmutableArray var builder = ImmutableArray.CreateBuilder(); foreach (var option in AnalyzerConfigurationOptionRegistry.All) { + if (TryGetConflictingAliases(options, option, out var conflict)) + { + builder.Add(new( + option.Key, + conflict, + "configuration aliases disagree; use one effective value")); + continue; + } if (!TryGet(options, option, out var value) || AnalyzerConfigurationOptionRegistry.IsAcceptedValue(option, value)) { @@ -98,6 +106,31 @@ private static ImmutableArray return builder.ToImmutable(); } + private static bool TryGetConflictingAliases( + AnalyzerConfigOptions options, + AnalyzerConfigurationOption option, + out string conflict) + { + var values = new List(); + foreach (var key in new[] { + option.Key, + "build_property." + option.Key, + "build_property." + option.BuildPropertyName + }) + { + if (options.TryGetValue(key, out var value) && + !string.IsNullOrWhiteSpace(value)) + { + values.Add(value.Trim()); + } + } + + var distinct = values.Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + conflict = string.Join(" / ", distinct); + return distinct.Length > 1; + } + internal static InvalidAnalyzerConfigurationValue ProviderFailure( Exception exception) { @@ -114,6 +147,14 @@ internal static ImmutableArray GetInvalidTree var builder = ImmutableArray.CreateBuilder(); foreach (var option in AnalyzerConfigurationOptionRegistry.All) { + if (TryGetConflictingAliases(options, option, out var conflict)) + { + builder.Add(new InvalidAnalyzerConfigurationValue( + option.Key, + conflict, + "configuration aliases disagree; use one effective value")); + continue; + } if (!TryGet(options, option, out var value)) { continue; diff --git a/SharpProof.Analyzer.Core/Configuration/AnalyzerConfigurationOptionRegistry.cs b/SharpProof.Analyzer.Core/Configuration/AnalyzerConfigurationOptionRegistry.cs index fa135b4fa..afe0e53fd 100644 --- a/SharpProof.Analyzer.Core/Configuration/AnalyzerConfigurationOptionRegistry.cs +++ b/SharpProof.Analyzer.Core/Configuration/AnalyzerConfigurationOptionRegistry.cs @@ -1,4 +1,5 @@ namespace SharpProof.Analyzer.Configuration; + internal static class AnalyzerConfigurationOptionRegistry { internal static AnalyzerConfigurationOption Profile { get; } = diff --git a/SharpProof.Analyzer.Core/InvalidContractArgumentDiagnostics.cs b/SharpProof.Analyzer.Core/InvalidContractArgumentDiagnostics.cs index 0a378b437..b434cf9fc 100644 --- a/SharpProof.Analyzer.Core/InvalidContractArgumentDiagnostics.cs +++ b/SharpProof.Analyzer.Core/InvalidContractArgumentDiagnostics.cs @@ -1,4 +1,5 @@ namespace SharpProof.Analyzer; + internal static class InvalidContractArgumentDiagnostics { internal static Diagnostic Create(string attributeName, string argument, string reason, Location location) diff --git a/SharpProof.Analyzer.Core/RequiresCallSiteAnalyzer.cs b/SharpProof.Analyzer.Core/RequiresCallSiteAnalyzer.cs index 2db76eb8f..9e080ff38 100644 --- a/SharpProof.Analyzer.Core/RequiresCallSiteAnalyzer.cs +++ b/SharpProof.Analyzer.Core/RequiresCallSiteAnalyzer.cs @@ -58,23 +58,30 @@ internal static AnalyzerSemanticOutcome AnalyzePrimaryConstructorInitializer( return AnalyzerSemanticOutcome.NotApplicable; } - var target = semanticModel.GetSymbolInfo( - initializer, - cancellationToken) - .Symbol as IMethodSymbol; - var arguments = initializer.ArgumentList.Arguments - .Select(argument => semanticModel.GetOperation( - argument, - cancellationToken) as IArgumentOperation) - .ToImmutableArray(); - if (target == null || arguments.IsDefaultOrEmpty || + var initializerOperation = semanticModel.GetOperation( + initializer, + cancellationToken); + var target = initializerOperation is IInvocationOperation invocation + ? invocation.TargetMethod + : semanticModel.GetSymbolInfo(initializer, cancellationToken) + .Symbol as IMethodSymbol; + var arguments = initializerOperation is IInvocationOperation baseCallOperation + ? baseCallOperation.Arguments.Cast().ToImmutableArray() + : initializer.ArgumentList.Arguments + .Select(argument => semanticModel.GetOperation( + argument, + cancellationToken) as IArgumentOperation) + .ToImmutableArray(); + var origin = initializerOperation ?? + (arguments.IsDefaultOrEmpty ? null : arguments[0]); + if (target == null || origin == null || arguments.Any(static argument => argument == null)) { return AnalyzerSemanticOutcome.Unknown; } - var call = new RequiresCallSiteCandidate( - arguments[0]!, + var baseCall = new RequiresCallSiteCandidate( + origin, target, Instance: null, arguments.OfType().ToImmutableArray(), @@ -83,16 +90,65 @@ internal static AnalyzerSemanticOutcome AnalyzePrimaryConstructorInitializer( Flow: null, ManagedFlowStatus.BudgetExceeded); - return new Analysis( + var analysis = new Analysis( constructor, declaration, semanticModel, session, reportDiagnostic, graph: null, - operationRoot: null, - cancellationToken) - .AnalyzeCallSite(call); + operationRoot: null, + cancellationToken); + var outcome = AnalyzerSemanticOutcome.NotApplicable; + var nestedCalls = new List(); + var operationFacts = new DefiniteOperationFacts( + semanticModel.Compilation, + cancellationToken); + var argumentsMayComplete = true; + foreach (var argument in arguments.OfType()) + { + foreach (var operation in RequiresCallSiteDiscovery + .ExecutableUnflowedDescendantsAndSelf( + argument, + operationFacts)) + { + foreach (var call in RequiresCallSiteDiscovery + .CreateUnflowedCandidates(operation)) + { + if (!nestedCalls.Any(existing => + existing.Operation.Syntax.SyntaxTree == + call.Operation.Syntax.SyntaxTree && + existing.Operation.Syntax.Span == + call.Operation.Syntax.Span && + SymbolEqualityComparer.Default.Equals( + existing.TargetMethod, + call.TargetMethod))) + { + nestedCalls.Add(call); + } + } + } + if (!operationFacts.MayCompleteNormally(argument.Value)) + { + argumentsMayComplete = false; + break; + } + } + foreach (var call in nestedCalls) + { + outcome = AnalyzerSemanticOutcomes.Combine( + outcome, + analysis.AnalyzeCallSite( + call, + requireCallerOwnership: false)); + } + return argumentsMayComplete + ? AnalyzerSemanticOutcomes.Combine( + outcome, + analysis.AnalyzeCallSite( + baseCall, + requireCallerOwnership: false)) + : outcome; } internal static AnalyzerSemanticOutcome AnalyzeInitializerCall( @@ -104,32 +160,26 @@ internal static AnalyzerSemanticOutcome AnalyzeInitializerCall( Action reportDiagnostic, CancellationToken cancellationToken) { - var target = operation switch - { - IInvocationOperation invocation => invocation.TargetMethod, - IObjectCreationOperation creation => creation.Constructor, - _ => null - }; - if (target == null) + var calls = RequiresCallSiteDiscovery + .CreateUnflowedCandidates(operation); + if (calls.IsDefaultOrEmpty) { return AnalyzerSemanticOutcome.NotApplicable; } - var instance = (operation as IInvocationOperation)?.Instance; - var arguments = operation switch - { - IInvocationOperation invocation => invocation.Arguments, - IObjectCreationOperation creation => creation.Arguments, - _ => default - }; - var call = new RequiresCallSiteCandidate( - operation, target, instance, arguments, - ImmutableDictionary.Empty, CanReplay: true, - Flow: null, ManagedFlowStatus.BudgetExceeded); - return new Analysis( + var analysis = new Analysis( constructor, initializer, semanticModel, session, reportDiagnostic, graph: null, operationRoot: operation, - cancellationToken) - .AnalyzeCallSite(call, requireCallerOwnership: false); + cancellationToken); + var outcome = AnalyzerSemanticOutcome.NotApplicable; + foreach (var call in calls) + { + outcome = AnalyzerSemanticOutcomes.Combine( + outcome, + analysis.AnalyzeCallSite( + call, + requireCallerOwnership: false)); + } + return outcome; } private sealed class Analysis( @@ -258,6 +308,9 @@ private AnalyzerSemanticOutcome AnalyzeAbstractCallSite( if (candidate.Instance != null && candidate.Instance is not IInstanceReferenceOperation && !operationFacts.CompletesNormally(candidate.Instance) || + !candidate.TargetMethod.IsStatic && + candidate.Instance != null && + DefiniteOperationFacts.IsDefinitelyNull(candidate.Instance) || candidate.Arguments.Any(argument => !operationFacts.CompletesNormally(argument.Value))) { @@ -266,7 +319,9 @@ candidate.Instance is not IInstanceReferenceOperation && var variables = new Dictionary(); var definitelyStrings = new HashSet(); - foreach (var variable in GetInputVariables(contracts)) + foreach (var variable in GetInputVariablesUsedBy( + contracts, + requires)) { cancellationToken.ThrowIfCancellationRequested(); var actual = GetActual(candidate, variable); @@ -334,12 +389,27 @@ candidate.Instance is not IInstanceReferenceOperation && return null; } + var operationFacts = new DefiniteOperationFacts( + semanticModel.Compilation, cancellationToken); + if (callSite.Instance != null && + !operationFacts.MayCompleteNormally(callSite.Instance) || + !callSite.TargetMethod.IsStatic && + callSite.Instance != null && + DefiniteOperationFacts.IsDefinitelyNull(callSite.Instance) || + callSite.Arguments.Any(argument => + !operationFacts.MayCompleteNormally(argument.Value))) + { + return null; + } + var lowerer = RoslynOperationLowerer.CreateForConcreteReplay( _factory, session.IsKnownPure); var interpreter = new IrInterpreter(_factory); var substitutions = new Dictionary(); - foreach (var variable in GetInputVariables(contracts)) + foreach (var variable in GetInputVariablesUsedBy( + contracts, + requires)) { cancellationToken.ThrowIfCancellationRequested(); var actual = GetActual(callSite, variable); @@ -427,6 +497,18 @@ BoundContractVariableRole.Result or BoundContractVariableRole.PreState)); } + private static IEnumerable GetInputVariablesUsedBy( + BoundMethodContracts contracts, + ImmutableArray clauses) + { + var used = clauses + .SelectMany(static clause => + IrTermAnalysis.CollectVariables(clause.Condition)) + .ToImmutableHashSet(); + return GetInputVariables(contracts).Where(variable => + used.Contains(variable.Variable)); + } + private static IOperation? GetActual( RequiresCallSiteCandidate callSite, BoundContractVariable variable) diff --git a/SharpProof.Analyzer.Core/RequiresCallSiteDiscovery.cs b/SharpProof.Analyzer.Core/RequiresCallSiteDiscovery.cs index d3ff69d63..26a498335 100644 --- a/SharpProof.Analyzer.Core/RequiresCallSiteDiscovery.cs +++ b/SharpProof.Analyzer.Core/RequiresCallSiteDiscovery.cs @@ -38,11 +38,18 @@ internal ImmutableHashSet? var owners = ImmutableHashSet.CreateBuilder< IMethodSymbol>( SymbolEqualityComparer.Default); + var operationFacts = new DefiniteOperationFacts( + semanticModel.Compilation, + cancellationToken); foreach (var operation in ExecutableDescendantsAndSelf(operationRoot)) { cancellationToken.ThrowIfCancellationRequested(); - var calls = GetCalls(operation); + var calls = GetCalls( + operation, + operationFacts, + semanticModel.Compilation, + cancellationToken); if (calls.IsDefaultOrEmpty) { continue; @@ -99,6 +106,8 @@ internal ImmutableHashSet? cancellationToken); var flowResult = flowAnalysis.Result; var callSites = new List(); + var reachableOperationSites = new HashSet<( + SyntaxTree Tree, int Start, int Length)>(); var initializer = (operationRoot as IConstructorBodyOperation)?.Initializer; if (TryGetImplicitParameterlessBaseConstructor(out var baseConstructor)) { @@ -137,7 +146,15 @@ internal ImmutableHashSet? foreach (var operation in roots.SelectMany( ExecutableDescendantsAndSelf)) { - var calls = GetCalls(operation); + reachableOperationSites.Add(( + operation.Syntax.SyntaxTree, + operation.Syntax.SpanStart, + operation.Syntax.Span.Length)); + var calls = GetCalls( + operation, + operationFacts, + semanticModel.Compilation, + cancellationToken); if (calls.IsDefaultOrEmpty || !SymbolEqualityComparer.Default.Equals( semanticModel.GetEnclosingSymbol( @@ -152,13 +169,22 @@ internal ImmutableHashSet? flowResult?.TryGetState(operation, out _) == true; if (flowAnalysis.IsComplete && !hasFlowState && - !IsInsideExceptionHandler(operation)) + !IsInsideExceptionHandler(operation) && + operation is not IListPatternOperation) { continue; } foreach (var call in calls) { + if (call.TargetMethod.MethodKind == MethodKind.PropertySet && + operation is IPropertyReferenceOperation property && + property.Parent is ICoalesceAssignmentOperation coalesce && + ReferenceEquals(coalesce.Target, property) && + !CanCoalesceGetterComplete(property, operationFacts)) + { + continue; + } var candidate = new RequiresCallSiteCandidate( operation, call.TargetMethod, @@ -166,14 +192,15 @@ internal ImmutableHashSet? call.Arguments, call.ExplicitArguments, call.CanReplay && - (hasFlowState || !flowAnalysis.IsComplete) && - (IsAccessorCall(call.TargetMethod) + (IsAccessorCall(call.TargetMethod) || + operation is IListPatternOperation ? HasReplayableAccessorEvaluation( call, operationFacts) - : HasReplayablePrefix( - operation, - operationFacts)), + : (hasFlowState || !flowAnalysis.IsComplete) && + HasReplayablePrefix( + operation, + operationFacts)), hasFlowState ? flowResult : null, flowAnalysis.Status); var existingIndex = callSites.FindIndex(existing => @@ -197,6 +224,50 @@ internal ImmutableHashSet? } } + foreach (var property in ExecutableDescendantsAndSelf(operationRoot!) + .OfType() + .Where(static property => + property.Parent is ICoalesceAssignmentOperation coalesce && + ReferenceEquals(coalesce.Target, property)) + .Where(property => reachableOperationSites.Contains(( + property.Syntax.SyntaxTree, + property.Syntax.SpanStart, + property.Syntax.Span.Length))) + .Where(property => + SymbolEqualityComparer.Default.Equals( + semanticModel.GetEnclosingSymbol( + property.Syntax.SpanStart, + cancellationToken), + caller)) + .Where(property => + CanCoalesceGetterComplete(property, operationFacts))) + { + foreach (var call in GetPropertyCalls(property).Where(static call => + call.TargetMethod.MethodKind == MethodKind.PropertySet)) + { + if (callSites.Any(existing => + existing.Operation.Syntax.SyntaxTree == + property.Syntax.SyntaxTree && + existing.Operation.Syntax.Span == property.Syntax.Span && + SymbolEqualityComparer.Default.Equals( + existing.TargetMethod, + call.TargetMethod))) + { + continue; + } + + callSites.Add(new RequiresCallSiteCandidate( + property, + call.TargetMethod, + call.Instance, + call.Arguments, + call.ExplicitArguments, + CanReplay: false, + Flow: null, + ManagedFlowStatus.BudgetExceeded)); + } + } + return [ .. callSites.OrderBy( static candidate => candidate.Operation.Syntax.SpanStart) @@ -418,6 +489,20 @@ private static bool HasReplayableAccessorEvaluation( operationFacts.CompletesNormally); } + private static bool CanCoalesceGetterComplete( + IPropertyReferenceOperation property, + DefiniteOperationFacts operationFacts) + { + return (property.Instance == null || + operationFacts.MayCompleteNormally(property.Instance)) && + property.Arguments.All(argument => + operationFacts.MayCompleteNormally(argument.Value)) && + property.Parent is ICoalesceAssignmentOperation coalesce && + operationFacts.MayCompleteNormally(coalesce.Value) && + property.Property.GetMethod is { } getter && + operationFacts.MethodCanCompleteNormally(getter); + } + private bool IsDirectReplayableStatement( StatementSyntax statement, IOperation callSite, @@ -472,7 +557,10 @@ private static bool IsOwnedCallSiteExpression( } private static ImmutableArray GetCalls( - IOperation operation) + IOperation operation, + DefiniteOperationFacts? operationFacts = null, + Compilation? compilation = null, + CancellationToken cancellationToken = default) { return operation switch { @@ -495,10 +583,932 @@ private static ImmutableArray GetCalls( GetPropertyCalls(property), IEventReferenceOperation eventReference => GetEventCalls(eventReference), + IListPatternOperation listPattern => GetListPatternCalls( + listPattern, + operationFacts, + compilation, + cancellationToken), _ => [] }; } + private static ImmutableArray GetListPatternCalls( + IListPatternOperation pattern, + DefiniteOperationFacts? operationFacts, + Compilation? compilation, + CancellationToken cancellationToken) + { + var instance = SwitchExpressionFacts.GetGoverningValue(pattern); + if (instance != null && + DefiniteOperationFacts.IsDefinitelyNull(instance)) + { + return []; + } + + var calls = ImmutableArray.CreateBuilder(); + var length = SwitchExpressionFacts.GetCallableListPatternMember( + pattern.LengthSymbol); + if (length != null) + { + calls.Add(CreateImplicitListPatternCall(length, instance)); + if (operationFacts != null && + !operationFacts.MethodCanCompleteNormally(length)) + { + return calls.ToImmutable(); + } + } + + var requiredLength = pattern.Patterns.Count( + static item => item is not ISlicePatternOperation); + var hasSlice = pattern.Patterns.Any( + static item => item is ISlicePatternOperation); + if (compilation != null && + TryGetKnownListLength( + pattern, + instance, + compilation, + cancellationToken, + out var knownLength) && + (hasSlice + ? knownLength < requiredLength + : knownLength != requiredLength)) + { + return calls.ToImmutable(); + } + + foreach (var item in pattern.Patterns) + { + var member = item is ISlicePatternOperation slice + ? slice.Pattern == null + ? null + : SwitchExpressionFacts.GetCallableListPatternMember( + slice.SliceSymbol) + : SwitchExpressionFacts.GetCallableListPatternMember( + pattern.IndexerSymbol); + if (member == null) + { + continue; + } + calls.Add(CreateImplicitListPatternCall(member, instance)); + if (operationFacts != null && + !operationFacts.MethodCanCompleteNormally(member)) + { + break; + } + } + return calls.ToImmutable(); + } + + private static RequiresCallTarget CreateImplicitListPatternCall( + IMethodSymbol method, + IOperation? instance) + { + return new RequiresCallTarget( + method, + instance, + [], + ImmutableDictionary.Empty, + true); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1508:Avoid dead conditional code", + Justification = "The analyzer misreads the multi-branch nullable " + + "assignment above the null check as unreachable.")] + private static bool TryGetKnownListLength( + IListPatternOperation pattern, + IOperation? instance, + Compilation compilation, + CancellationToken cancellationToken, + out long length) + { + instance = instance == null + ? null + : DefiniteOperationFacts.UnwrapHarmlessValue(instance); + if (instance is IArrayCreationOperation + { DimensionSizes.Length: 1 } arrayCreation && + arrayCreation.DimensionSizes[0].ConstantValue is + { HasValue: true, Value: int arrayLength }) + { + length = arrayLength; + return true; + } + if (pattern.LengthSymbol is not IPropertySymbol + { GetMethod: { } getter } || + getter.IsVirtual && !getter.IsSealed || + getter.DeclaringSyntaxReferences.Length != 1) + { + length = 0; + return false; + } + + var declaration = getter.DeclaringSyntaxReferences[0] + .GetSyntax(cancellationToken); + var expression = declaration switch + { + PropertyDeclarationSyntax + { ExpressionBody.Expression: { } body } => body, + AccessorDeclarationSyntax + { ExpressionBody.Expression: { } body } => body, + ArrowExpressionClauseSyntax + { Expression: { } body } => body, + AccessorDeclarationSyntax + { Body.Statements.Count: 1 } accessor + when accessor.Body!.Statements[0] is ReturnStatementSyntax + { Expression: { } body } => body, + _ => null + }; + if (expression == null) + { + length = 0; + return false; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, expression.SyntaxTree); + var constant = model.GetConstantValue(expression, cancellationToken); + if (!constant.HasValue || constant.Value == null) + { + length = 0; + return false; + } + try + { + length = Convert.ToInt64( + constant.Value, + System.Globalization.CultureInfo.InvariantCulture); + return length >= 0; + } + catch (Exception exception) when (exception is + FormatException or InvalidCastException or OverflowException) + { + length = 0; + return false; + } + } + + internal static ImmutableArray + CreateUnflowedCandidates(IOperation operation) + { + return [.. GetCalls(operation).Select(call => + new RequiresCallSiteCandidate( + operation, + call.TargetMethod, + call.Instance, + call.Arguments, + call.ExplicitArguments, + call.CanReplay, + Flow: null, + ManagedFlowStatus.BudgetExceeded))]; + } + + internal static IEnumerable + ExecutableUnflowedDescendantsAndSelf(IOperation operation) + { + return ExecutableUnflowedDescendantsAndSelfCore( + operation, + operationFacts: null); + } + + internal static IEnumerable + ExecutableUnflowedDescendantsAndSelf( + IOperation operation, + DefiniteOperationFacts operationFacts) + { + return ExecutableUnflowedDescendantsAndSelfCore( + operation, + operationFacts); + } + + private static IEnumerable + ExecutableUnflowedDescendantsAndSelfCore( + IOperation operation, + DefiniteOperationFacts? operationFacts) + { + if (operation is IAnonymousFunctionOperation or ILocalFunctionOperation) + { + yield break; + } + + if (operationFacts != null && operation is IInvocationOperation invocation) + { + if (invocation.Instance is { } instance) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + instance, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(instance)) + { + yield break; + } + } + foreach (var argument in invocation.Arguments) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + argument.Value, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(argument.Value)) + { + yield break; + } + } + yield return invocation; + yield break; + } + + if (operationFacts != null && + operation is IObjectCreationOperation creation) + { + foreach (var argument in creation.Arguments) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + argument.Value, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(argument.Value)) + { + yield break; + } + } + yield return creation; + if (creation.Constructor is { } constructor && + !operationFacts.MethodCanCompleteNormally(constructor)) + { + yield break; + } + if (creation.Initializer != null) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + creation.Initializer, + operationFacts)) + { + yield return descendant; + } + } + yield break; + } + + if (operationFacts != null && + operation is IObjectOrCollectionInitializerOperation initializer) + { + yield return initializer; + foreach (var item in initializer.Initializers) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + item, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(item)) + { + yield break; + } + } + yield break; + } + + if (operationFacts != null && + operation is ISimpleAssignmentOperation + { + Target: IPropertyReferenceOperation property + } assignment) + { + yield return assignment; + if (property.Instance is { } propertyInstance) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + propertyInstance, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(propertyInstance)) + { + yield break; + } + } + foreach (var argument in property.Arguments) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + argument.Value, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(argument.Value)) + { + yield break; + } + } + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + assignment.Value, + operationFacts)) + { + yield return descendant; + } + if (operationFacts.MayCompleteNormally(assignment.Value)) + { + yield return property; + } + yield break; + } + + if (operationFacts != null && + operation is IPropertyReferenceOperation propertyReference) + { + if (propertyReference.Instance is { } propertyInstance) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + propertyInstance, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(propertyInstance)) + { + yield break; + } + } + foreach (var argument in propertyReference.Arguments) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + argument.Value, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(argument.Value)) + { + yield break; + } + } + yield return propertyReference; + yield break; + } + + if (operationFacts != null && + operation is IConditionalOperation factConditional) + { + yield return factConditional; + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + factConditional.Condition, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally( + factConditional.Condition)) + { + yield break; + } + if (factConditional.Condition.ConstantValue is + { HasValue: true, Value: bool factCondition }) + { + var branch = factCondition + ? factConditional.WhenTrue + : factConditional.WhenFalse; + if (branch != null) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + branch, + operationFacts)) + { + yield return descendant; + } + } + yield break; + } + foreach (var branch in new[] + { + factConditional.WhenTrue, + factConditional.WhenFalse + }) + { + if (branch == null) + { + continue; + } + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + branch, + operationFacts)) + { + yield return descendant; + } + } + yield break; + } + + if (operationFacts != null && operation is IBinaryOperation + { + OperatorKind: BinaryOperatorKind.ConditionalAnd or + BinaryOperatorKind.ConditionalOr + } factBinary) + { + yield return factBinary; + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + factBinary.LeftOperand, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(factBinary.LeftOperand)) + { + yield break; + } + var skipRight = factBinary.LeftOperand.ConstantValue is + { HasValue: true, Value: bool leftValue } && + leftValue == (factBinary.OperatorKind == + BinaryOperatorKind.ConditionalOr); + if (!skipRight) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + factBinary.RightOperand, + operationFacts)) + { + yield return descendant; + } + } + yield break; + } + + if (operationFacts != null && operation is ICoalesceOperation factCoalesce) + { + yield return factCoalesce; + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + factCoalesce.Value, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(factCoalesce.Value)) + { + yield break; + } + if (!factCoalesce.Value.ConstantValue.HasValue || + factCoalesce.Value.ConstantValue.Value == null) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + factCoalesce.WhenNull, + operationFacts)) + { + yield return descendant; + } + } + yield break; + } + + if (operationFacts != null && + operation is IConditionalAccessOperation factAccess) + { + yield return factAccess; + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + factAccess.Operation, + operationFacts)) + { + yield return descendant; + } + if (!operationFacts.MayCompleteNormally(factAccess.Operation) || + factAccess.Operation.ConstantValue is + { HasValue: true, Value: null }) + { + yield break; + } + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + factAccess.WhenNotNull, + operationFacts)) + { + yield return descendant; + } + yield break; + } + + yield return operation; + + if (operation is IConditionalOperation conditional && + conditional.Condition.ConstantValue is + { HasValue: true, Value: bool condition }) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + conditional.Condition, + operationFacts)) + { + yield return descendant; + } + var branch = condition + ? conditional.WhenTrue + : conditional.WhenFalse; + if (branch != null) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + branch, + operationFacts)) + { + yield return descendant; + } + } + yield break; + } + + if (operation is IBinaryOperation binary && + (binary.OperatorKind is + BinaryOperatorKind.ConditionalAnd or + BinaryOperatorKind.ConditionalOr) && + binary.LeftOperand.ConstantValue is + { HasValue: true, Value: bool left }) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + binary.LeftOperand, + operationFacts)) + { + yield return descendant; + } + if (left != (binary.OperatorKind == + BinaryOperatorKind.ConditionalOr)) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + binary.RightOperand, + operationFacts)) + { + yield return descendant; + } + } + yield break; + } + + if (operation is ICoalesceOperation coalesce && + coalesce.Value.ConstantValue.HasValue) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + coalesce.Value, + operationFacts)) + { + yield return descendant; + } + if (coalesce.Value.ConstantValue.Value == null) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + coalesce.WhenNull, + operationFacts)) + { + yield return descendant; + } + } + yield break; + } + + if (operation is IConditionalAccessOperation access && + access.Operation.ConstantValue is + { HasValue: true, Value: null }) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + access.Operation, + operationFacts)) + { + yield return descendant; + } + yield break; + } + + if (operation is ISwitchExpressionOperation switchExpression && + switchExpression.Value.ConstantValue.HasValue) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + switchExpression.Value, + operationFacts)) + { + yield return descendant; + } + if (operationFacts != null && + !operationFacts.MayCompleteNormally(switchExpression.Value)) + { + yield break; + } + var input = switchExpression.Value.ConstantValue.Value; + var switchCompilation = switchExpression.SemanticModel?.Compilation; + foreach (var arm in switchExpression.Arms) + { + var match = switchCompilation == null + ? ConstantPatternMatch.Unknown + : GetConstantPatternMatch( + switchCompilation, + arm.Pattern, + input, + switchExpression.Value.Type); + if (match == ConstantPatternMatch.No) + { + continue; + } + if (arm.Guard != null) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + arm.Guard, + operationFacts)) + { + yield return descendant; + } + if (operationFacts != null && + !operationFacts.MayCompleteNormally(arm.Guard)) + { + if (match == ConstantPatternMatch.Yes) + { + yield break; + } + continue; + } + if (arm.Guard.ConstantValue is + { HasValue: true, Value: false }) + { + continue; + } + } + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + arm.Value, + operationFacts)) + { + yield return descendant; + } + var guardIsTrue = arm.Guard == null || + arm.Guard.ConstantValue is { HasValue: true, Value: true }; + if (match == ConstantPatternMatch.Yes && guardIsTrue) + { + break; + } + } + yield break; + } + + foreach (var child in operation.ChildOperations) + { + foreach (var descendant in + ExecutableUnflowedDescendantsAndSelfCore( + child, + operationFacts)) + { + yield return descendant; + } + if (operationFacts != null && + !operationFacts.MayCompleteNormally(child)) + { + yield break; + } + } + } + + private static ConstantPatternMatch GetConstantPatternMatch( + Compilation compilation, + IPatternOperation pattern, + object? input, + ITypeSymbol? inputType) + { + return pattern switch + { + IDiscardPatternOperation => ConstantPatternMatch.Yes, + ITypePatternOperation typePattern => + MatchTypePattern( + compilation, + typePattern.MatchedType, + input, + inputType, + matchesNull: false), + IDeclarationPatternOperation + { MatchedType: { } declarationMatchedType } declarationPattern => + MatchTypePattern( + compilation, + declarationMatchedType, + input, + inputType, + declarationPattern.MatchesNull), + IDeclarationPatternOperation => ConstantPatternMatch.Unknown, + IConstantPatternOperation + { + Value.ConstantValue: { HasValue: true } constant + } => Equals(constant.Value, input) + ? ConstantPatternMatch.Yes + : ConstantPatternMatch.No, + IRelationalPatternOperation relational => + MatchRelationalPattern(relational, input), + INegatedPatternOperation negated => + Negate(GetConstantPatternMatch( + compilation, + negated.Pattern, + input, + inputType)), + IBinaryPatternOperation binary + when binary.OperatorKind == BinaryOperatorKind.And => + And( + GetConstantPatternMatch( + compilation, + binary.LeftPattern, + input, + inputType), + GetConstantPatternMatch( + compilation, + binary.RightPattern, + input, + inputType)), + IBinaryPatternOperation binary + when binary.OperatorKind == BinaryOperatorKind.Or => + Or( + GetConstantPatternMatch( + compilation, + binary.LeftPattern, + input, + inputType), + GetConstantPatternMatch( + compilation, + binary.RightPattern, + input, + inputType)), + _ => ConstantPatternMatch.Unknown + }; + } + + private static ConstantPatternMatch MatchTypePattern( + Compilation compilation, + ITypeSymbol matchedType, + object? input, + ITypeSymbol? inputType, + bool matchesNull) + { + if (input == null) + { + return matchesNull + ? ConstantPatternMatch.Yes + : ConstantPatternMatch.No; + } + var actualType = inputType?.TypeKind == TypeKind.Enum + ? inputType + : input switch + { + bool => compilation.GetSpecialType( + SpecialType.System_Boolean), + byte => compilation.GetSpecialType( + SpecialType.System_Byte), + sbyte => compilation.GetSpecialType( + SpecialType.System_SByte), + short => compilation.GetSpecialType( + SpecialType.System_Int16), + ushort => compilation.GetSpecialType( + SpecialType.System_UInt16), + int => compilation.GetSpecialType( + SpecialType.System_Int32), + uint => compilation.GetSpecialType( + SpecialType.System_UInt32), + long => compilation.GetSpecialType( + SpecialType.System_Int64), + ulong => compilation.GetSpecialType( + SpecialType.System_UInt64), + char => compilation.GetSpecialType( + SpecialType.System_Char), + float => compilation.GetSpecialType( + SpecialType.System_Single), + double => compilation.GetSpecialType( + SpecialType.System_Double), + decimal => compilation.GetSpecialType( + SpecialType.System_Decimal), + string => compilation.GetSpecialType( + SpecialType.System_String), + _ => null + }; + if (actualType == null || actualType.TypeKind == TypeKind.Error) + { + return ConstantPatternMatch.Unknown; + } + return compilation + .ClassifyCommonConversion(actualType, matchedType) + .IsImplicit + ? ConstantPatternMatch.Yes + : ConstantPatternMatch.No; + } + + private static ConstantPatternMatch MatchRelationalPattern( + IRelationalPatternOperation pattern, + object? input) + { + var constantValue = pattern.Value.ConstantValue; + if (input is not IComparable comparable || + !constantValue.HasValue || + constantValue.Value == null) + { + return ConstantPatternMatch.No; + } + var constant = constantValue.Value; + if (input is double inputDouble && double.IsNaN(inputDouble) || + constant is double constantDouble && double.IsNaN(constantDouble) || + input is float inputFloat && float.IsNaN(inputFloat) || + constant is float constantFloat && float.IsNaN(constantFloat)) + { + return ConstantPatternMatch.No; + } + + int comparison; + try + { + comparison = comparable.CompareTo(constant); + } + catch (ArgumentException) + { + return ConstantPatternMatch.Unknown; + } + + var matches = pattern.OperatorKind switch + { + BinaryOperatorKind.LessThan => comparison < 0, + BinaryOperatorKind.LessThanOrEqual => comparison <= 0, + BinaryOperatorKind.GreaterThan => comparison > 0, + BinaryOperatorKind.GreaterThanOrEqual => comparison >= 0, + _ => false + }; + return matches + ? ConstantPatternMatch.Yes + : ConstantPatternMatch.No; + } + + private static ConstantPatternMatch Negate(ConstantPatternMatch value) + { + return value switch + { + ConstantPatternMatch.Yes => ConstantPatternMatch.No, + ConstantPatternMatch.No => ConstantPatternMatch.Yes, + _ => ConstantPatternMatch.Unknown + }; + } + + private static ConstantPatternMatch And( + ConstantPatternMatch left, + ConstantPatternMatch right) + { + if (left == ConstantPatternMatch.No || + right == ConstantPatternMatch.No) + { + return ConstantPatternMatch.No; + } + return left == ConstantPatternMatch.Yes && + right == ConstantPatternMatch.Yes + ? ConstantPatternMatch.Yes + : ConstantPatternMatch.Unknown; + } + + private static ConstantPatternMatch Or( + ConstantPatternMatch left, + ConstantPatternMatch right) + { + if (left == ConstantPatternMatch.Yes || + right == ConstantPatternMatch.Yes) + { + return ConstantPatternMatch.Yes; + } + return left == ConstantPatternMatch.No && + right == ConstantPatternMatch.No + ? ConstantPatternMatch.No + : ConstantPatternMatch.Unknown; + } + + private enum ConstantPatternMatch + { + No, + Yes, + Unknown + } + private static ImmutableArray GetPropertyCalls( IPropertyReferenceOperation property) { @@ -511,6 +1521,24 @@ private static ImmutableArray GetPropertyCalls( ? [] : [CreateSetterCall(property, setter, assignment.Value, true)]; } + if (property.Parent is ICoalesceAssignmentOperation coalesce && + ReferenceEquals(coalesce.Target, property)) + { + var calls = ImmutableArray.CreateBuilder(2); + if (getter != null) + { + calls.Add(CreateGetterCall(property, getter)); + } + if (setter != null) + { + calls.Add(CreateSetterCall( + property, + setter, + coalesce.Value, + canReplay: false)); + } + return calls.ToImmutable(); + } if (property.Parent is ICompoundAssignmentOperation compound && ReferenceEquals(compound.Target, property) || property.Parent is IIncrementOrDecrementOperation increment && diff --git a/SharpProof.Analyzer.Core/RequiresCallSiteTreeAnalyzer.cs b/SharpProof.Analyzer.Core/RequiresCallSiteTreeAnalyzer.cs index 4a94e5027..39f605713 100644 --- a/SharpProof.Analyzer.Core/RequiresCallSiteTreeAnalyzer.cs +++ b/SharpProof.Analyzer.Core/RequiresCallSiteTreeAnalyzer.cs @@ -153,6 +153,11 @@ private void AnalyzeGraph( cancellationToken.ThrowIfCancellationRequested(); caller = ContractClauseInventoryBuilder .NormalizeCallable(caller); + if (!isRoot && IsGenerated(caller, declaration)) + { + RecordGeneratedSubtree(declaration); + return; + } if (potentialOwners.Contains(caller)) { _visitedPotentialOwners.Add(caller); @@ -195,6 +200,11 @@ private void AnalyzeGraph( { cancellationToken .ThrowIfCancellationRequested(); + if (IsGenerated(nested.Method, nested.Declaration)) + { + RecordGeneratedSubtree(nested.Declaration); + continue; + } if (nested.IsExpressionTree) { RecordUnsupportedNested( @@ -234,6 +244,38 @@ exception is ArgumentException or } } + private bool IsGenerated( + IMethodSymbol method, + SyntaxNode declaration) + { + return AnalyzerGeneratedCodePolicy.IsGenerated( + method, + declaration.SyntaxTree, + semanticModel.Compilation, + cancellationToken); + } + + private void RecordGeneratedSubtree(SyntaxNode declaration) + { + foreach (var owner in potentialOwners) + { + var belongsToGeneratedSubtree = owner + .DeclaringSyntaxReferences.Any(reference => + ReferenceEquals( + reference.SyntaxTree, + declaration.SyntaxTree) && + declaration.FullSpan.Contains(reference.Span)); + if (belongsToGeneratedSubtree && + _visitedPotentialOwners.Add(owner) && + session.TryBeginRequiresCallSiteAnalysis(owner)) + { + session.RecordSemanticOutcome( + owner, + AnalyzerSemanticOutcome.NotApplicable); + } + } + } + private void RecordUnsupportedNested( IMethodSymbol method) { @@ -300,7 +342,7 @@ private ImmutableArray if (!expressionTree && !IsAnonymousExecutableOrEscaped( graph, - anonymous.Syntax)) + anonymous)) { _visitedPotentialOwners.Add(method); continue; @@ -401,7 +443,11 @@ private bool TryCollectLocalReferences( IInvocationOperation invocation => invocation.TargetMethod, IMethodReferenceOperation methodReference => - methodReference.Method, + IsAnonymousExecutableOrEscaped( + graph, + methodReference) + ? methodReference.Method + : null, _ => null }; if (referenced != null) @@ -419,7 +465,7 @@ private bool TryCollectLocalReferences( if (IsExpressionTree(anonymous.Syntax) || !IsAnonymousExecutableOrEscaped( graph, - anonymous.Syntax)) + anonymous)) { continue; } @@ -453,104 +499,802 @@ exception is ArgumentException or private bool IsAnonymousExecutableOrEscaped( ControlFlowGraph graph, - SyntaxNode declaration) + IOperation value) { if (!TryGetLocalDestination( - declaration, - out var initialLocal)) + value.Syntax, + out var initialLocal, + out var definition)) { return true; } + var block = FindContainingBlock(graph, value.Syntax); + if (block == null) + { + return true; + } + return CanReachConsumption( + graph, + initialLocal, + block.Ordinal, + definition.Span.End, + new HashSet { definition }, + GetTuplePath(value.Syntax, definition)); + } - var pending = new Queue(); - var tracked = new HashSet( - SymbolEqualityComparer.Default); - pending.Enqueue(initialLocal); + private bool TryGetLocalDestination( + SyntaxNode value, + out ILocalSymbol local, + out SyntaxNode definition) + { + foreach (var ancestor in value.Ancestors()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ancestor is EqualsValueClauseSyntax equalsValue && + equalsValue.Value.Span.Contains(value.Span) && + equalsValue.Parent is VariableDeclaratorSyntax variable && + semanticModel.GetDeclaredSymbol( + variable, + cancellationToken) is ILocalSymbol declared) + { + local = declared; + definition = variable; + return true; + } + + if (ancestor is AssignmentExpressionSyntax assignment && + assignment.Right.Span.Contains(value.Span) && + semanticModel.GetSymbolInfo( + assignment.Left, + cancellationToken).Symbol is ILocalSymbol assigned) + { + local = assigned; + definition = assignment; + return true; + } + + if (ancestor is StatementSyntax or ArrowExpressionClauseSyntax) + { + break; + } + } + + local = null!; + definition = null!; + return false; + } + + private bool CanReachConsumption( + ControlFlowGraph graph, + ILocalSymbol local, + int definitionBlock, + int definitionEnd, + HashSet activeDefinitions, + IReadOnlyList? tuplePath = null) + { + var pending = new Queue<(int Ordinal, int After)>(); + var visited = new HashSet<(int Ordinal, bool FromStart)>(); + pending.Enqueue((definitionBlock, definitionEnd)); while (pending.Count != 0) { cancellationToken.ThrowIfCancellationRequested(); - var local = pending.Dequeue(); - if (!tracked.Add(local)) + var (ordinal, after) = pending.Dequeue(); + if (!visited.Add((ordinal, after < 0))) { continue; } - - foreach (var reference in ReachableOperations(graph) + var block = graph.Blocks[ordinal]; + var killed = false; + var exceptionalStateSurvivesKill = false; + foreach (var reference in BlockOperations(block) + .SelectMany(static operation => + operation.DescendantsAndSelf()) .OfType() .Where(reference => SymbolEqualityComparer.Default.Equals( reference.Local, - local))) + local)) + .OrderBy(GetReferenceOrder)) { cancellationToken.ThrowIfCancellationRequested(); - if (reference.IsDeclaration || - IsAssignmentTarget(reference.Syntax)) + var order = GetReferenceOrder(reference); + if (order <= after || reference.IsDeclaration) { continue; } - + var accessedTuplePath = GetAccessedTuplePath(reference); + if (IsAssignmentTarget(reference.Syntax)) + { + // A target-shaped reference that has no enclosing + // ISimpleAssignmentOperation isn't the real commit: + // a multi-block RHS (e.g. a ternary) can lower into + // a flow-capture that happens to share the target's + // syntax span in an earlier block. Only the + // reference embedded in the actual assignment + // operation represents the commit. + if (!HasEnclosingSimpleAssignment(reference)) + { + continue; + } + if (AssignmentKillsTrackedValue( + tuplePath, + accessedTuplePath)) + { + exceptionalStateSurvivesKill = + BlockMayThrowBeforeAssignmentCommit( + graph, + after, + reference); + killed = true; + break; + } + continue; + } + IReadOnlyList? propagatedTuplePath = tuplePath; + if (tuplePath != null && accessedTuplePath.Count != 0) + { + var shared = 0; + while (shared < tuplePath.Count && + shared < accessedTuplePath.Count && + string.Equals( + tuplePath[shared], + accessedTuplePath[shared], + StringComparison.Ordinal)) + { + shared++; + } + if (shared < accessedTuplePath.Count && + shared < tuplePath.Count) + { + continue; + } + propagatedTuplePath = shared < tuplePath.Count + ? tuplePath.Skip(shared).ToArray() + : null; + } if (TryGetLocalDestination( reference.Syntax, - out var alias)) + out var alias, + out var aliasDefinition) && + (accessedTuplePath.Count != 0 || + IsDirectDelegatePropagation( + reference.Syntax, + aliasDefinition))) + { + if (activeDefinitions.Add(aliasDefinition)) + { + try + { + if (CanReachConsumption( + graph, + alias, + ordinal, + aliasDefinition.Span.End, + activeDefinitions, + propagatedTuplePath)) + { + return true; + } + } + finally + { + activeDefinitions.Remove(aliasDefinition); + } + } + continue; + } + if (TryGetDeconstructionDestination( + reference.Syntax, + propagatedTuplePath, + out var deconstructionAlias, + out var deconstructionDefinition, + out var remainingTuplePath)) + { + if (deconstructionAlias != null && + activeDefinitions.Add(deconstructionDefinition)) + { + try + { + if (CanReachConsumption( + graph, + deconstructionAlias, + ordinal, + deconstructionDefinition.Span.End, + activeDefinitions, + remainingTuplePath)) + { + return true; + } + } + finally + { + activeDefinitions.Remove( + deconstructionDefinition); + } + } + continue; + } + var patternDestinations = GetPatternDestinations( + reference.Syntax); + if (patternDestinations.Count != 0) + { + foreach (var patternAlias in patternDestinations) + { + if (!activeDefinitions.Add( + patternAlias.Definition)) + { + continue; + } + try + { + if (CanReachConsumption( + graph, + patternAlias.Local, + ordinal, + patternAlias.Definition.Span.End, + activeDefinitions, + propagatedTuplePath)) + { + return true; + } + } + finally + { + activeDefinitions.Remove( + patternAlias.Definition); + } + } + continue; + } + if (IsNonExecutingObservation(reference)) { - pending.Enqueue(alias); continue; } - return true; } + if (killed) + { + if (exceptionalStateSurvivesKill) + { + foreach (var successor in ExceptionalSuccessors( + graph, + block)) + { + pending.Enqueue((successor.Ordinal, -1)); + } + } + continue; + } + foreach (var successor in RegularSuccessors(block)) + { + pending.Enqueue((successor.Ordinal, -1)); + } + if (BlockMayThrow(block, after)) + { + foreach (var successor in ExceptionalSuccessors( + graph, + block)) + { + pending.Enqueue((successor.Ordinal, -1)); + } + } } - return false; } - private bool TryGetLocalDestination( + private string[]? GetTuplePath( SyntaxNode value, - out ILocalSymbol local) + SyntaxNode definition) { - foreach (var ancestor in value.Ancestors()) + var components = value.Ancestors() + .OfType() + .Where(candidate => + candidate.Parent is TupleExpressionSyntax tuple && + definition.Span.Contains(tuple.Span)) + .Select(argument => + { + var owner = (TupleExpressionSyntax)argument.Parent!; + var index = owner.Arguments.IndexOf(argument); + return GetConvertedTupleElementName(owner, index) ?? + argument.NameColon?.Name.Identifier.ValueText ?? + $"Item{index + 1}"; + }) + .Reverse() + .ToArray(); + return components.Length == 0 ? null : components; + } + + private string? GetConvertedTupleElementName( + TupleExpressionSyntax owner, + int index) + { + var convertedType = semanticModel.GetTypeInfo( + owner, + cancellationToken) + .ConvertedType as INamedTypeSymbol; + return convertedType is { IsTupleType: true } && + index < convertedType.TupleElements.Length + ? convertedType.TupleElements[index].Name + : null; + } + + private static List GetAccessedTuplePath( + ILocalReferenceOperation reference) + { + var components = new List(); + for (var operation = reference.Parent; + operation != null; + operation = operation.Parent) { - cancellationToken.ThrowIfCancellationRequested(); - if (ancestor is EqualsValueClauseSyntax equalsValue && - equalsValue.Value.Span.Contains(value.Span) && - equalsValue.Parent is VariableDeclaratorSyntax variable && + if (operation is IConversionOperation or + IParenthesizedOperation) + { + continue; + } + if (operation is IFieldReferenceOperation field && + field.Field.ContainingType?.IsTupleType == true) + { + components.Add(field.Field.Name); + continue; + } + break; + } + return components; + } + + private bool TryGetDeconstructionDestination( + SyntaxNode reference, + IReadOnlyList? tuplePath, + out ILocalSymbol? local, + out SyntaxNode definition, + out IReadOnlyList? remainingTuplePath) + { + local = null; + definition = null!; + remainingTuplePath = null; + if (tuplePath == null || tuplePath.Count == 0) + { + return false; + } + + var assignment = reference.AncestorsAndSelf() + .OfType() + .FirstOrDefault(candidate => + candidate.IsKind(SyntaxKind.SimpleAssignmentExpression) && + candidate.Right.Span.Contains(reference.Span)); + if (assignment == null) + { + return false; + } + + SyntaxNode target = assignment.Left; + var sourceType = semanticModel.GetTypeInfo( + assignment.Right, + cancellationToken).Type as INamedTypeSymbol; + var consumed = 0; + while (sourceType?.IsTupleType == true && + consumed < tuplePath.Count) + { + var component = tuplePath[consumed]; + var index = -1; + for (var candidate = 0; + candidate < sourceType.TupleElements.Length; + candidate++) + { + var element = sourceType.TupleElements[candidate]; + if (string.Equals( + element.Name, + component, + StringComparison.Ordinal) || + string.Equals( + element.CorrespondingTupleField?.Name, + component, + StringComparison.Ordinal)) + { + index = candidate; + break; + } + } + var elements = GetDeconstructionElements(target); + if (index < 0 || index >= elements.Length) + { + return false; + } + target = elements[index]; + sourceType = sourceType.TupleElements[index].Type as + INamedTypeSymbol; + consumed++; + if (consumed < tuplePath.Count && + GetDeconstructionElements(target).Length == 0) + { + break; + } + } + + if (consumed == 0) + { + return false; + } + definition = assignment; + remainingTuplePath = consumed < tuplePath.Count + ? tuplePath.Skip(consumed).ToArray() + : null; + local = target switch + { + SingleVariableDesignationSyntax designation => semanticModel.GetDeclaredSymbol( - variable, - cancellationToken) is ILocalSymbol declared) + designation, + cancellationToken) as ILocalSymbol, + DeclarationExpressionSyntax + { Designation: SingleVariableDesignationSyntax designation } => + semanticModel.GetDeclaredSymbol( + designation, + cancellationToken) as ILocalSymbol, + IdentifierNameSyntax identifier => semanticModel.GetSymbolInfo( + identifier, + cancellationToken).Symbol as ILocalSymbol, + _ => null + }; + return local != null || IsDiscardDeconstructionTarget(target); + } + + private static SyntaxNode[] GetDeconstructionElements( + SyntaxNode target) + { + return target switch + { + TupleExpressionSyntax tuple => tuple.Arguments + .Select(static argument => (SyntaxNode)argument.Expression) + .ToArray(), + DeclarationExpressionSyntax + { Designation: ParenthesizedVariableDesignationSyntax tuple } => + tuple.Variables.Cast().ToArray(), + ParenthesizedVariableDesignationSyntax tuple => + tuple.Variables.Cast().ToArray(), + _ => [] + }; + } + + private static bool IsDiscardDeconstructionTarget(SyntaxNode target) + { + return target is DiscardDesignationSyntax or DiscardPatternSyntax || + target is IdentifierNameSyntax identifier && + identifier.Identifier.ValueText == "_"; + } + + private static BasicBlock? FindContainingBlock( + ControlFlowGraph graph, + SyntaxNode syntax) + { + return graph.Blocks.FirstOrDefault(block => + BlockOperations(block).Any(operation => + operation.DescendantsAndSelf().Any(descendant => + descendant.Syntax.SyntaxTree == syntax.SyntaxTree && + descendant.Syntax.Span.Contains(syntax.Span)))); + } + + private static IEnumerable BlockOperations( + BasicBlock block) + { + return block.Operations.Concat( + block.BranchValue == null + ? [] + : [block.BranchValue]); + } + + private static IEnumerable RegularSuccessors( + BasicBlock block) + { + if (block.FallThroughSuccessor is + { + Semantics: ControlFlowBranchSemantics.Regular or + ControlFlowBranchSemantics.StructuredExceptionHandling, + Destination: not null + } fallThrough) + { + yield return fallThrough.Destination!; + } + if (block.ConditionalSuccessor is + { + Semantics: ControlFlowBranchSemantics.Regular or + ControlFlowBranchSemantics.StructuredExceptionHandling, + Destination: not null + } conditional && + conditional.Destination.Ordinal != + block.FallThroughSuccessor?.Destination?.Ordinal) + { + yield return conditional.Destination!; + } + } + + private static bool BlockMayThrow(BasicBlock block, int after) + { + return BlockOperations(block) + .Where(operation => operation.Syntax.Span.End > after) + .SelectMany(static operation => + operation.DescendantsAndSelf()) + .Any(static operation => OperationMayThrow(operation)); + } + + private static bool HasEnclosingSimpleAssignment( + ILocalReferenceOperation reference) + { + for (var operation = reference.Parent; + operation != null; + operation = operation.Parent) + { + if (operation is ISimpleAssignmentOperation) { - local = declared; return true; } + } + return false; + } - if (ancestor is AssignmentExpressionSyntax assignment && - assignment.Right.Span.Contains(value.Span) && - semanticModel.GetSymbolInfo( - assignment.Left, - cancellationToken).Symbol is ILocalSymbol assigned) + private static bool BlockMayThrowBeforeAssignmentCommit( + ControlFlowGraph graph, + int after, + ILocalReferenceOperation reference) + { + for (var operation = reference.Parent; + operation != null; + operation = operation.Parent) + { + if (operation is ISimpleAssignmentOperation assignment) { - local = assigned; - return true; + var commitEnd = assignment.Syntax.Span.End; + + // The assignment's RHS may be lowered across several + // basic blocks (e.g. a ternary or coalesce), so the + // throwing sub-expression is not necessarily in the + // same block as the commit. Scan every block, bounded + // by the assignment's own syntax span. + return graph.Blocks + .SelectMany(BlockOperations) + .Where(candidate => + candidate.Syntax.Span.End > after && + candidate.Syntax.SpanStart < commitEnd) + .SelectMany(static candidate => + candidate.DescendantsAndSelf()) + .Any(static candidate => + OperationMayThrow(candidate)); } + } + return false; + } - if (ancestor is StatementSyntax or ArrowExpressionClauseSyntax) + private static bool OperationMayThrow(IOperation operation) + { + if (operation is IConversionOperation conversion) + { + return conversion.IsChecked || + (!conversion.IsTryCast && !conversion.IsImplicit && + (conversion.Conversion.IsReference || + conversion.Operand.Type?.IsReferenceType == true && + conversion.Type?.IsValueType == true)); + } + return operation is + IThrowOperation or + IInvocationOperation or + IDynamicInvocationOperation or + IDynamicObjectCreationOperation or + IDynamicIndexerAccessOperation or + IFunctionPointerInvocationOperation or + IObjectCreationOperation or + IArrayCreationOperation or + IArrayElementReferenceOperation or + IDynamicMemberReferenceOperation or + IFieldReferenceOperation { Instance: not null } or + IPropertyReferenceOperation or + IEventAssignmentOperation or + ILockOperation or + IAwaitOperation or + ICompoundAssignmentOperation + { IsChecked: true } or + ICompoundAssignmentOperation { - break; + OperatorKind: BinaryOperatorKind.Divide or + BinaryOperatorKind.Remainder + } or + IBinaryOperation { IsChecked: true } or + IBinaryOperation + { + OperatorKind: BinaryOperatorKind.Divide or + BinaryOperatorKind.Remainder + } or + IUnaryOperation { IsChecked: true } or + IIncrementOrDecrementOperation { IsChecked: true }; + } + + private static IEnumerable ExceptionalSuccessors( + ControlFlowGraph graph, + BasicBlock block) + { + var yielded = new HashSet(); + for (var region = block.EnclosingRegion; + region != null; + region = region.EnclosingRegion) + { + if (region.Kind != ControlFlowRegionKind.Try || + region.EnclosingRegion is not { } owner) + { + continue; + } + foreach (var handler in owner.NestedRegions.Where(candidate => + candidate.Kind is ControlFlowRegionKind.Filter or + ControlFlowRegionKind.Catch or + ControlFlowRegionKind.FilterAndHandler or + ControlFlowRegionKind.Finally)) + { + if (yielded.Add(handler.FirstBlockOrdinal)) + { + yield return graph.Blocks[handler.FirstBlockOrdinal]; + } } } + } - local = null!; + private static int GetReferenceOrder( + ILocalReferenceOperation reference) + { + var assignment = reference.Syntax.AncestorsAndSelf() + .OfType() + .FirstOrDefault(candidate => + candidate.Left.Span.Contains(reference.Syntax.Span)); + return assignment?.Span.End ?? reference.Syntax.SpanStart; + } + + private static bool IsDirectDelegatePropagation( + SyntaxNode reference, + SyntaxNode definition) + { + for (var current = reference.Parent; + current != null && current != definition; + current = current.Parent) + { + if (current is InvocationExpressionSyntax or + ObjectCreationExpressionSyntax or + ElementAccessExpressionSyntax or + MemberAccessExpressionSyntax) + { + return false; + } + } + return true; + } + + private static bool IsNonExecutingObservation( + ILocalReferenceOperation reference) + { + if (reference.Syntax.Ancestors() + .OfType() + .Any(assignment => + assignment.IsKind( + SyntaxKind.CoalesceAssignmentExpression) && + assignment.Left.Span.Contains(reference.Syntax.Span))) + { + return true; + } + for (var operation = reference.Parent; + operation != null; + operation = operation.Parent) + { + if (operation is IConversionOperation or + IParenthesizedOperation || + operation is IFieldReferenceOperation tupleField && + tupleField.Field.ContainingType?.IsTupleType == true) + { + continue; + } + return operation is IBinaryOperation + { + OperatorMethod: null, + OperatorKind: BinaryOperatorKind.Equals or + BinaryOperatorKind.NotEquals + } or IIsPatternOperation; + } return false; } + private List<(ILocalSymbol Local, SyntaxNode Definition)> + GetPatternDestinations(SyntaxNode reference) + { + var pattern = reference.Ancestors() + .OfType() + .FirstOrDefault(); + if (pattern == null) + { + return []; + } + var result = new List<( + ILocalSymbol Local, + SyntaxNode Definition)>(); + foreach (var designation in WholeInputDesignations( + pattern.Pattern)) + { + if (designation is SingleVariableDesignationSyntax single && + semanticModel.GetDeclaredSymbol( + single, + cancellationToken) is ILocalSymbol declared && + !result.Any(candidate => + SymbolEqualityComparer.Default.Equals( + candidate.Local, + declared))) + { + result.Add((declared, pattern)); + } + } + return result; + } + + private static IEnumerable + WholeInputDesignations(PatternSyntax pattern) + { + switch (pattern) + { + case DeclarationPatternSyntax declaration: + yield return declaration.Designation; + yield break; + case VarPatternSyntax varPattern: + yield return varPattern.Designation; + yield break; + case RecursivePatternSyntax + { Designation: { } designation }: + yield return designation; + yield break; + case ParenthesizedPatternSyntax parenthesized: + foreach (var nested in WholeInputDesignations( + parenthesized.Pattern)) + { + yield return nested; + } + yield break; + case BinaryPatternSyntax binary: + foreach (var nested in WholeInputDesignations( + binary.Left)) + { + yield return nested; + } + foreach (var nested in WholeInputDesignations( + binary.Right)) + { + yield return nested; + } + yield break; + } + } + private static bool IsAssignmentTarget( SyntaxNode reference) { return reference.Ancestors() .OfType() .Any(assignment => + assignment.IsKind( + SyntaxKind.SimpleAssignmentExpression) && assignment.Left.Span.Contains(reference.Span)); } + private static bool AssignmentKillsTrackedValue( + IReadOnlyList? trackedPath, + List assignedPath) + { + if (trackedPath == null || assignedPath.Count == 0) + { + return true; + } + if (assignedPath.Count > trackedPath.Count) + { + return false; + } + return assignedPath + .Select((component, index) => (component, index)) + .All(pair => string.Equals( + pair.component, + trackedPath[pair.index], + StringComparison.Ordinal)); + } + private static IEnumerable ReachableOperations( ControlFlowGraph graph) { diff --git a/SharpProof.Analyzer.Test/AnalyzerConfigurationUnitTests.cs b/SharpProof.Analyzer.Test/AnalyzerConfigurationUnitTests.cs index 63d8ef2c5..94955f976 100644 --- a/SharpProof.Analyzer.Test/AnalyzerConfigurationUnitTests.cs +++ b/SharpProof.Analyzer.Test/AnalyzerConfigurationUnitTests.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using NUnit.Framework; using SharpProof.Analyzer.Configuration; @@ -41,6 +42,38 @@ public void TreeConfigurationDistinguishesRedundantAndLocalValues() } } + [Test] + public void ConflictingGlobalAliasesFailClosed() + { + var configuration = AnalyzerConfiguration.FromOptions( + new DictionaryProvider(new DictionaryOptions( + ("sharpproof_profile", "strict"), + ("build_property.SharpProofProfile", "advisory")))); + + Assert.That(configuration.Profile, Is.EqualTo(SharpProofProfile.Off)); + Assert.That(configuration.InvalidConfigurationValues, Has.Length.EqualTo(1)); + Assert.That( + configuration.InvalidConfigurationValues[0].Reason, + Does.Contain("aliases disagree")); + } + + [Test] + public void ConflictingTreeAliasesCannotHideBehindMatchingGlobalValue() + { + var tree = new DictionaryOptions( + ("sharpproof_profile", "advisory"), + ("build_property.SharpProofProfile", "strict")); + var global = new DictionaryOptions( + ("sharpproof_profile", "advisory")); + + var invalid = AnalyzerConfiguration.GetInvalidTreeConfigurationValues( + tree, + global); + + Assert.That(invalid, Has.Length.EqualTo(1)); + Assert.That(invalid[0].Reason, Does.Contain("aliases disagree")); + } + [Test] public void SemanticOutcomeOrderingIsExhaustiveAndValidated() { @@ -73,4 +106,21 @@ public override bool TryGetValue( return _values.TryGetValue(key, out value!); } } + + private sealed class DictionaryProvider(AnalyzerConfigOptions globalOptions) + : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = globalOptions; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + return GlobalOptions; + } + + public override AnalyzerConfigOptions GetOptions( + AdditionalText textFile) + { + return GlobalOptions; + } + } } diff --git a/SharpProof.Analyzer.Test/AnalyzerModeAndEffectTests.cs b/SharpProof.Analyzer.Test/AnalyzerModeAndEffectTests.cs index 248cccfc0..b7bc3e833 100644 --- a/SharpProof.Analyzer.Test/AnalyzerModeAndEffectTests.cs +++ b/SharpProof.Analyzer.Test/AnalyzerModeAndEffectTests.cs @@ -1855,7 +1855,7 @@ public static void DynamicArrayLength(int length) { Assert.That( diagnostics.Select(static diagnostic => diagnostic.Id), - Is.EqualTo(Enumerable.Repeat("SP0016", 5))); + Is.EqualTo(Enumerable.Repeat("SP0016", 3))); using (Assert.EnterMultipleScope()) { Assert.That( @@ -1866,10 +1866,10 @@ public static void DynamicArrayLength(int length) { Is.EqualTo(AnalyzerSemanticOutcome.Refuted)); Assert.That( factory.Outcomes["ThrowingConstructor"], - Is.EqualTo(AnalyzerSemanticOutcome.Unknown)); + Is.EqualTo(AnalyzerSemanticOutcome.Proven)); Assert.That( factory.Outcomes["WrappedThrowingConstructor"], - Is.EqualTo(AnalyzerSemanticOutcome.Unknown)); + Is.EqualTo(AnalyzerSemanticOutcome.Proven)); Assert.That( factory.Outcomes["DynamicArrayLength"], Is.EqualTo(AnalyzerSemanticOutcome.Unknown)); diff --git a/SharpProof.Analyzer.Test/FinalCompilationCollectorTests.cs b/SharpProof.Analyzer.Test/FinalCompilationCollectorTests.cs index f3b18389d..8f4a4f28a 100644 --- a/SharpProof.Analyzer.Test/FinalCompilationCollectorTests.cs +++ b/SharpProof.Analyzer.Test/FinalCompilationCollectorTests.cs @@ -334,7 +334,7 @@ internal static int Identity(int value) { Assert.That(first.Take(3), Is.Not.EqualTo(new byte[] { 0xEF, 0xBB, 0xBF })); Assert.That(first, Does.Not.Contain((byte)'\r')); Assert.That(artifact.Schema, Is.EqualTo("SharpProof.CompilerManifest")); - Assert.That(artifact.SchemaVersion, Is.EqualTo(14)); + Assert.That(artifact.SchemaVersion, Is.EqualTo(15)); Assert.That(artifact.ProtocolVersion, Is.EqualTo("11")); Assert.That(artifact.Compilation.TargetFramework, Is.EqualTo("net9.0")); Assert.That(artifact.Features, Is.EqualTo(WorkerFeatureSet.All)); @@ -941,14 +941,15 @@ public async Task TreeConfigurationProviderFailureFailsArtifactEmission() } } - [TestCase("advisory", "off", "all", true)] + [TestCase("advisory", "off", "all", false)] [TestCase("off", "advisory", "all", false)] [TestCase("invalid", "advisory", "all", false)] [TestCase(" ", "off", "all", false)] - [TestCase(" AdViSoRy ", "strict", "contracts", true)] - [TestCase("advisory", "strict", "effects", true)] + [TestCase(" AdViSoRy ", "strict", "contracts", false)] + [TestCase("advisory", "strict", "effects", false)] [TestCase("advisory", "strict", "invalid", false)] - public async Task CollectorUsesAuthoritativeConfigurationAliasOrder( + [TestCase(" strict ", "strict", "all", true)] + public async Task CollectorRejectsConflictingConfigurationAliases( string rawProfile, string buildProfile, string features, diff --git a/SharpProof.Analyzer.Test/GeneratedContractForAnalyzerTests.cs b/SharpProof.Analyzer.Test/GeneratedContractForAnalyzerTests.cs index 3e909101d..bfa5ef7fe 100644 --- a/SharpProof.Analyzer.Test/GeneratedContractForAnalyzerTests.cs +++ b/SharpProof.Analyzer.Test/GeneratedContractForAnalyzerTests.cs @@ -211,7 +211,7 @@ public static class ServiceContracts } [Test] - public async Task GeneratedFinalValidationUsesAuthoritativeAliasOrder() + public async Task GeneratedFinalValidationRejectsConflictingAliases() { const string malformed = """ using SharpProof.Attributes; @@ -235,13 +235,21 @@ public static class ServiceContracts ["sharpproof_profile"] = "invalid", ["build_property.SharpProofProfile"] = "advisory" }); + var matching = await AnalyzeGeneratedAsync( + malformed, + globalOptions: new Dictionary(StringComparer.Ordinal) + { + ["sharpproof_profile"] = " advisory ", + ["build_property.SharpProofProfile"] = "ADVISORY" + }); using (Assert.EnterMultipleScope()) { + Assert.That(conflicting, Is.Empty); + Assert.That(invalid, Is.Empty); Assert.That( - conflicting.Select(static diagnostic => diagnostic.Id), + matching.Select(static diagnostic => diagnostic.Id), Is.EqualTo(["SPCF0004"])); - Assert.That(invalid, Is.Empty); } } diff --git a/SharpProof.Analyzer.Test/NestedRequiresCallSiteTests.cs b/SharpProof.Analyzer.Test/NestedRequiresCallSiteTests.cs index 1511d8923..3ae95a40f 100644 --- a/SharpProof.Analyzer.Test/NestedRequiresCallSiteTests.cs +++ b/SharpProof.Analyzer.Test/NestedRequiresCallSiteTests.cs @@ -397,11 +397,13 @@ private static int Positive(int value) { public static int Outer() { Expression> quoted = () => Dead(); Func explicitReference = Reachable; + Func unusedReference = Unused; return explicitReference() + Inferred(0); int Dead() => Positive(-1); int Reachable() => Positive(-2); int Inferred(T value) => Positive(-3); + int Unused() => Positive(-4); } } """; @@ -418,6 +420,609 @@ public static int Outer() { })); } + [Test] + public async Task OverwrittenMethodGroupsDoNotReachLocalFunctions() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Outer(bool condition) { + Func overwritten = Dead; + if (condition) overwritten = () => 1; + else overwritten = () => 2; + Func source = Reachable; + Func alias = source; + source = () => 3; + return overwritten() + alias(); + + int Dead() => Positive(-1); + int Reachable() => Positive(-2); + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 1); + Assert.That( + diagnostics[0].Location.SourceSpan.Start, + Is.EqualTo(source.IndexOf( + "Positive(-2)", StringComparison.Ordinal))); + } + + [Test] + public async Task ObservingMethodGroupsDoesNotReachLocalFunctions() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Outer() { + Func value = Dead; + return value == null ? 0 : 1; + + int Dead() => Positive(-1); + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 0); + } + + [Test] + public async Task CoalesceAssignmentOnlyReachesLaterConsumedMethodGroups() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Outer() { + Func? unused = Dead; + unused ??= () => 1; + Func? consumed = Reachable; + consumed ??= () => 2; + return consumed(); + + int Dead() => Positive(-1); + int Reachable() => Positive(-2); + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 1); + Assert.That( + diagnostics[0].Location.SourceSpan.Start, + Is.EqualTo(source.IndexOf( + "Positive(-2)", StringComparison.Ordinal))); + } + + [Test] + public async Task TupleMethodGroupsOnlyReachThroughTheirOwnComponent() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Outer() { + var unused = (Callback: (Func)Dead, Number: 1); + _ = unused.Number; + var consumed = + (Callback: (Func)Reachable, Number: 2); + return consumed.Callback(); + + int Dead() => Positive(-1); + int Reachable() => Positive(-2); + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 1); + Assert.That( + diagnostics[0].Location.SourceSpan.Start, + Is.EqualTo(source.IndexOf( + "Positive(-2)", StringComparison.Ordinal))); + } + + [Test] + public async Task NestedTupleMethodGroupsTrackTheirFullProjectionPath() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Outer() { + var unused = (Inner: ( + Callback: (Func)Dead, + Number: 1), Other: 0); + _ = unused.Inner.Number; + var consumed = (Inner: ( + Callback: (Func)Reachable, + Number: 2), Other: 0); + var inner = consumed.Inner; + return inner.Callback(); + + int Dead() => Positive(-1); + int Reachable() => Positive(-2); + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 1); + Assert.That( + diagnostics[0].Location.SourceSpan.Start, + Is.EqualTo(source.IndexOf( + "Positive(-2)", StringComparison.Ordinal))); + } + + [Test] + public async Task NestedTupleProjectionAliasDoesNotConsumeSiblingDelegate() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Outer() { + var outer = (Inner: ( + Callback: (Func)Dead, + Number: 1), Other: 0); + var inner = outer.Inner; + return inner.Number; + + int Dead() => Positive(-1); + } + } + """; + + var diagnostics = await Analyze(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task TupleAssignmentsKillOnlyOverwrittenComponents() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int SiblingAssignment() { + var pair = ( + Callback: (Func)Reachable, + Number: 1); + pair.Number = 2; + return pair.Callback(); + + int Reachable() => Positive(-1); + } + + public static int CallbackAssignment() { + var pair = ( + Callback: (Func)Dead, + Number: 1); + pair.Callback = Safe; + return pair.Callback(); + + int Dead() => Positive(-2); + int Safe() => 0; + } + + public static int OuterAssignment() { + var outer = (Inner: ( + Callback: (Func)Dead, + Number: 1), Other: 0); + outer.Inner = (Safe, 2); + return outer.Inner.Callback(); + + int Dead() => Positive(-3); + int Safe() => 0; + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 1); + Assert.That( + diagnostics[0].Location.SourceSpan.Start, + Is.EqualTo(source.IndexOf( + "Positive(-1)", StringComparison.Ordinal))); + } + + [Test] + public async Task TupleComponentNullObservationsDoNotConsumeDelegates() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Equality() { + var pair = ( + Callback: (Func)Dead, + Number: 1); + return pair.Callback == null ? 0 : 1; + + int Dead() => Positive(-1); + } + + public static int Pattern() { + var outer = (Inner: ( + Callback: (Func)Dead, + Number: 1), Other: 0); + return outer.Inner.Callback is null ? 0 : 1; + + int Dead() => Positive(-2); + } + } + """; + + var diagnostics = await Analyze(source); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task TupleDeconstructionTracksOnlyTheDelegateDestination() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Unused() { + var pair = (Callback: (Func)Dead, Number: 1); + var (callback, number) = pair; + return number; + + int Dead() => Positive(-1); + } + + public static int Invoked() { + var pair = (Callback: (Func)Reachable, Number: 1); + var (callback, number) = pair; + return callback(); + + int Reachable() => Positive(-2); + } + + public static int Discarded() { + var pair = (Callback: (Func)Dead, Number: 1); + var (_, number) = pair; + return number; + + int Dead() => Positive(-3); + } + + public static int NestedUnused() { + var outer = (Inner: ( + Callback: (Func)Dead, + Number: 1), Other: 0); + var (inner, other) = outer; + return inner.Number; + + int Dead() => Positive(-4); + } + + public static int NestedInvoked() { + var outer = (Inner: ( + Callback: (Func)Reachable, + Number: 1), Other: 0); + var (inner, other) = outer; + return inner.Callback(); + + int Reachable() => Positive(-5); + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 2); + Assert.That( + diagnostics[0].Location.SourceSpan.Start, + Is.EqualTo(source.IndexOf( + "Positive(-2)", StringComparison.Ordinal))); + Assert.That( + diagnostics[1].Location.SourceSpan.Start, + Is.EqualTo(source.IndexOf( + "Positive(-5)", StringComparison.Ordinal))); + } + + [Test] + public async Task ExceptionHandlersCanConsumeTrackedDelegates() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private sealed class Holder { + public Func Callback = null!; + } + private sealed class Boom { + public Boom(int value) => + throw new InvalidOperationException(); + } + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + private static void Fail() => + throw new InvalidOperationException(); + private static Func FailDelegate() => + throw new InvalidOperationException(); + + public static int CatchUse() { + Func callback = Reachable; + try { Fail(); } + catch { return callback(); } + + int Reachable() => Positive(-1); + } + + public static int FinallyUse() { + Func callback = Reachable; + try { Fail(); } + finally { _ = callback(); } + + int Reachable() => Positive(-2); + } + + public static int OverwrittenBeforeThrow() { + Func callback = Dead; + callback = Safe; + try { Fail(); } + catch { return callback(); } + + int Dead() => Positive(-3); + int Safe() => 0; + } + + public static int ThrowingOverwrite() { + Func callback = Reachable; + try { callback = FailDelegate(); } + catch { return callback(); } + + int Reachable() => Positive(-4); + } + + public static int ThrowingTupleOverwrite() { + (Func callback, int other) pair = (Reachable, 0); + try { pair.callback = FailDelegate(); } + catch { return pair.callback(); } + + int Reachable() => Positive(-5); + } + + public static int ThrowingFieldOverwrite() { + Holder holder = null!; + Func callback = Reachable; + try { callback = holder.Callback; } + catch { return callback(); } + + int Reachable() => Positive(-6); + } + + public static int ThrowingTupleFieldOverwrite() { + Holder holder = null!; + (Func callback, int other) pair = (Reachable, 0); + try { pair.callback = holder.Callback; } + catch { return pair.callback(); } + + int Reachable() => Positive(-7); + } + + public static int ThrowingCastOverwrite(object source) { + Func callback = Reachable; + try { callback = (Func)source; } + catch { return callback(); } + + int Reachable() => Positive(-8); + } + + public static int ThrowingTupleCastOverwrite(object source) { + (Func callback, int other) pair = (Reachable, 0); + try { pair.callback = (Func)source; } + catch { return pair.callback(); } + + int Reachable() => Positive(-9); + } + + public static int ThrowingArrayLengthOverwrite() { + Func[] values = null!; + Func callback = Reachable; + try { callback = values.Length == 0 ? Safe : Safe; } + catch { return callback(); } + + int Reachable() => Positive(-10); + int Safe() => 0; + } + + public static int ThrowingTupleArrayLengthOverwrite() { + Func[] values = null!; + (Func callback, int other) pair = (Reachable, 0); + try { pair.callback = values.Length == 0 ? Safe : Safe; } + catch { return pair.callback(); } + + int Reachable() => Positive(-11); + int Safe() => 0; + } + + public static int ThrowingCheckedOverwrite(int value) { + Func callback = Reachable; + try { + callback = checked(int.MaxValue + value) > 0 + ? Safe + : Safe; + } + catch { return callback(); } + + int Reachable() => Positive(-12); + int Safe() => 0; + } + + public static int ThrowingTupleCheckedOverwrite(int value) { + (Func callback, int other) pair = (Reachable, 0); + try { + pair.callback = checked(int.MaxValue + value) > 0 + ? Safe + : Safe; + } + catch { return pair.callback(); } + + int Reachable() => Positive(-13); + int Safe() => 0; + } + + public static int ThrowingDynamicCreationOverwrite( + dynamic value) { + Func callback = Reachable; + try { + callback = new Boom(value) != null ? Safe : Safe; + } + catch { return callback(); } + + int Reachable() => Positive(-14); + int Safe() => 0; + } + + public static int ThrowingTupleDynamicCreationOverwrite( + dynamic value) { + (Func callback, int other) pair = (Reachable, 0); + try { + pair.callback = new Boom(value) != null ? Safe : Safe; + } + catch { return pair.callback(); } + + int Reachable() => Positive(-15); + int Safe() => 0; + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 14); + Assert.That( + diagnostics.Select(diagnostic => diagnostic.Location.SourceSpan.Start), + Is.EquivalentTo(new[] { + source.IndexOf("Positive(-1)", StringComparison.Ordinal), + source.IndexOf("Positive(-2)", StringComparison.Ordinal), + source.IndexOf("Positive(-4)", StringComparison.Ordinal), + source.IndexOf("Positive(-5)", StringComparison.Ordinal), + source.IndexOf("Positive(-6)", StringComparison.Ordinal), + source.IndexOf("Positive(-7)", StringComparison.Ordinal), + source.IndexOf("Positive(-8)", StringComparison.Ordinal), + source.IndexOf("Positive(-9)", StringComparison.Ordinal), + source.IndexOf("Positive(-10)", StringComparison.Ordinal), + source.IndexOf("Positive(-11)", StringComparison.Ordinal), + source.IndexOf("Positive(-12)", StringComparison.Ordinal), + source.IndexOf("Positive(-13)", StringComparison.Ordinal), + source.IndexOf("Positive(-14)", StringComparison.Ordinal), + source.IndexOf("Positive(-15)", StringComparison.Ordinal) + })); + } + + [Test] + public async Task PatternAliasesReachLocalFunctions() + { + const string source = + """ + using System; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Outer() { + Func value = Reachable; + if (value is (var alias)) return alias(); + return 0; + + int Reachable() => Positive(-1); + } + } + """; + + var diagnostics = await Analyze(source); + + AssertRequiresDiagnostics(diagnostics, 1); + } + [Test] public async Task NestedCallableSuppressionsAreValidatedAndRecorded() { @@ -607,6 +1212,55 @@ void Local() { } Assert.That(diagnostics, Is.Empty); } + [Test] + public async Task GeneratedNestedCallablesInHandwrittenCodeAreExcluded() + { + var factory = new RecordingSessionFactory(); + var diagnostics = await Analyze( + """ + using System; + using System.CodeDom.Compiler; + using System.Linq.Expressions; + using SharpProof.Attributes; + + public static class Fixture { + private static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + + public static int Outer() { + [GeneratedCode("test", "1")] + int Local() { + int Inner() => Positive(-1); + return Inner(); + } + Func lambda = + [GeneratedCode("test", "1")] + () => Positive(-2); + Expression> expression = + [GeneratedCode("test", "1")] + () => Positive(-3); + return Local() + lambda() + expression.Compile()(); + } + } + """, + factory); + + using (Assert.EnterMultipleScope()) + { + Assert.That(diagnostics, Is.Empty); + Assert.That( + factory.GetNamedOutcome("Inner"), + Is.EqualTo(AnalyzerSemanticOutcome.NotApplicable)); + Assert.That( + factory.GetOutcomes(MethodKind.AnonymousFunction), + Is.EqualTo(Enumerable.Repeat( + AnalyzerSemanticOutcome.NotApplicable, + 2))); + } + } + private static Task> Analyze( string source, IAnalyzerSessionFactory? sessionFactory = null, diff --git a/SharpProof.Analyzer.Test/RequiresAndControlTests.cs b/SharpProof.Analyzer.Test/RequiresAndControlTests.cs index 54f18e21c..dd373b224 100644 --- a/SharpProof.Analyzer.Test/RequiresAndControlTests.cs +++ b/SharpProof.Analyzer.Test/RequiresAndControlTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using NUnit.Framework; namespace SharpProof.Analyzer.Test; @@ -258,12 +259,21 @@ public async Task FieldAndAutoPropertyInitializersCheckRequiresExactlyOnce() var diagnostics = await AnalyzerTestHost.AnalyzeAsync( """ using SharpProof.Attributes; - public static class Guard { public static int Positive(int value) { Contract.Requires(value > 0); return value; } } + public static class Guard { + public static int Invalid { + get { Contract.Requires(false); return 0; } + } + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } public sealed class Subject { private int instanceField = Guard.Positive(-1); private static int staticField = Guard.Positive(-2); private int InstanceProperty { get; } = Guard.Positive(-3); private static int StaticProperty { get; } = Guard.Positive(-4); + private int accessor = Guard.Invalid; private int valid = Guard.Positive(1); public Subject() { } public Subject(int value) { } @@ -274,7 +284,65 @@ public Subject(int value) { } Assert.That( diagnostics.Select(static diagnostic => diagnostic.Id), - Is.EqualTo(Enumerable.Repeat("SP0027", 4))); + Is.EqualTo(Enumerable.Repeat("SP0027", 5))); + } + + [Test] + public async Task MemberInitializersStopAfterNonCompletingOperands() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System; + using SharpProof.Attributes; + public static class Guard { + public static int Fail() => + throw new InvalidOperationException(); + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public sealed class Subject { + private int field = Guard.Fail() + Guard.Positive(-1); + private int Property { get; } = + Guard.Fail() + Guard.Positive(-2); + } + """, + "contracts", + ["SP0027"]); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task MemberInitializerSequencesStopAfterNonCompletion() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System; + using SharpProof.Attributes; + public static class Guard { + public static int Fail() => + throw new InvalidOperationException(); + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public sealed class Subject { + private int first = Guard.Fail(), second = Guard.Positive(-1); + private int third = Guard.Positive(-2); + private int Fourth { get; } = Guard.Positive(-3); + + private static int staticFirst = Guard.Fail(); + private static int staticSecond = Guard.Positive(-4); + private static int StaticThird { get; } = Guard.Positive(-5); + } + """, + "contracts", + ["SP0027"]); + + Assert.That(diagnostics, Is.Empty); } [Test] @@ -316,6 +384,543 @@ public async Task PrimaryConstructorBaseInitializerChecksRequires( Is.EqualTo(["SP0027"])); } + [Test] + public async Task ZeroArgumentPrimaryConstructorBaseInitializerChecksRequires() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public class Base { + public Base() { Contract.Requires(false); } + } + public sealed class Derived() : Base() { } + """, + "contracts", + ["SP0027"]); + + Assert.That( + diagnostics.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(["SP0027"])); + } + + [Test] + public async Task PrimaryConstructorBaseInitializerChecksViolatingOptionalDefault() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public class Base { + public Base(int value = -1) { Contract.Requires(value > 0); } + } + public sealed class Derived() : Base() { } + """, + "contracts", + ["SP0027"]); + + Assert.That( + diagnostics.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(["SP0027"])); + } + + [Test] + public async Task PrimaryConstructorBaseInitializerAcceptsSatisfyingOptionalDefault() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public class Base { + public Base(int value = 1) { Contract.Requires(value > 0); } + } + public sealed class Derived() : Base() { } + """, + "contracts", + ["SP0027"]); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task PrimaryConstructorBaseArgumentsCheckNestedCalls() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { + public Base(int value) { } + } + public sealed class Derived(int marker) : + Base(Guard.Positive(-1)) { } + """, + "contracts", + ["SP0027"]); + + Assert.That( + diagnostics.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(["SP0027"])); + } + + [Test] + public async Task PrimaryConstructorSkipsUnreachableNestedCalls() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { public Base(int value) { } } + public sealed class Derived(int marker) : + Base(false ? Guard.Positive(-1) : 0) { } + """, + "contracts", + ["SP0027"]); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task PrimaryConstructorStopsAfterNonCompletingArgument() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System; + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { + public Base(string text, int value) { + Contract.Requires(false); + } + } + public sealed class Derived(int marker) : Base( + (string?)null ?? throw new InvalidOperationException(), + Guard.Positive(-1)) { } + """, + "contracts", + ["SP0027"]); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task PrimaryConstructorHonorsNestedEvaluationOrder() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System; + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + public static int Fail() => + throw new InvalidOperationException(); + public static bool FailBool() => + throw new InvalidOperationException(); + public static Box Wrap(int value) { + Contract.Requires(false); + return new Box(); + } + } + public sealed class Box { + public int A { get; set; } + public int B { get; set; } + } + public sealed class CheckedBox { + public CheckedBox(int value) { + Contract.Requires(false); + } + } + public class BoxBase { public BoxBase(Box value) { } } + public class CheckedBase { public CheckedBase(CheckedBox value) { } } + public sealed class InitializerDerived(int marker) : BoxBase( + new Box { + A = Guard.Fail(), + B = Guard.Positive(-1) + }) { } + public sealed class InvocationDerived(int marker) : BoxBase( + Guard.Wrap(Guard.Fail())) { } + public sealed class CreationDerived(int marker) : CheckedBase( + new CheckedBox(Guard.Fail())) { } + public sealed class ConditionalDerived(int marker) : CheckedBase( + Guard.FailBool() + ? new CheckedBox(Guard.Positive(-1)) + : new CheckedBox(0)) { } + """, + "contracts", + ["SP0027"]); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task PrimaryConstructorStopsAtNonCompletingSwitchGuard() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System; + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + public static bool FailBool() => + throw new InvalidOperationException(); + } + public class Base { public Base(int value) { } } + public sealed class Derived(int marker) : Base( + 0 switch { + _ when Guard.FailBool() => Guard.Positive(-1), + _ => 0 + }) { } + """, + "contracts", + ["SP0027"]); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task GeneratedCodeAttributeSuppressesMemberInitializerCalls() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System.CodeDom.Compiler; + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + [GeneratedCode("test", "1")] + public sealed class GeneratedSubject { + private int _value = Guard.Positive(-1); + } + """, + "contracts", + ["SP0027"]); + var propertyDiagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System.CodeDom.Compiler; + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public sealed class Subject { + [GeneratedCode("test", "1")] + private int Value { get; } = Guard.Positive(-1); + } + """, + "contracts", + ["SP0027"]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(diagnostics, Is.Empty); + Assert.That(propertyDiagnostics, Is.Empty); + } + } + + [Test] + public async Task ControlSuppressionAppliesToMemberInitializers() + { + var suppressed = await AnalyzerTestHost.AnalyzeAsync( + """ + using System; + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + public static Action Create(int value) { + Contract.Requires(value > 0); + return () => { }; + } + } + [SharpProofSuppress("reviewed initializers")] + public sealed class Subject { + private int _field = Guard.Positive(-1); + private int Property { get; } = Guard.Positive(-1); + private event Action Changed = Guard.Create(-1); + } + """, + "contracts", + ["SP0027"]); + var mixed = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public sealed class Subject { + private int _field = Guard.Positive(-1); + [SharpProofSuppress("reviewed constructor")] + public Subject() { } + public Subject(int value) { } + } + """, + "contracts", + ["SP0027"]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(suppressed, Is.Empty); + Assert.That( + mixed.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(["SP0027"])); + } + } + + [Test] + public async Task NonGeneratedConstructorRetainsMemberInitializerCalls() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System.CodeDom.Compiler; + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public sealed class Subject { + private int _value = Guard.Positive(-1); + + [GeneratedCode("test", "1")] + public Subject() { } + + public Subject(int value) { } + } + """, + "contracts", + ["SP0027"]); + + Assert.That( + diagnostics.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(["SP0027"])); + } + + [Test] + public async Task FieldLikeEventInitializersCheckRequires() + { + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + """ + using System; + using SharpProof.Attributes; + public static class Guard { + public static Action Create(int value) { + Contract.Requires(value > 0); + return () => { }; + } + } + public sealed class Subject { + private event Action Changed = Guard.Create(-1); + } + """, + "contracts", + ["SP0027"]); + + Assert.That( + diagnostics.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(["SP0027"])); + } + + [Test] + public async Task GeneratedPartialConstructorSuppressesMemberInitializerCalls() + { + var compilation = AnalyzerTestHost.CreateCompilation( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public sealed partial class Subject { + private int _value = Guard.Positive(-1); + } + """, + ["SP0027"], + filePath: "Subject.cs"); + compilation = compilation.AddSyntaxTrees( + CSharpSyntaxTree.ParseText( + """ + public sealed partial class Subject { + public Subject() { } + } + """, + (CSharpParseOptions)compilation.SyntaxTrees.Single().Options, + path: "Subject.g.cs")); + + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + compilation, + "contracts"); + + Assert.That(diagnostics, Is.Empty); + } + + [Test] + public async Task UnflowedCallDiscoverySkipsNonexecutedOperations() + { + var lambda = await AnalyzerTestHost.AnalyzeAsync( + """ + using System; + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { public Base(Func value) { } } + public sealed class Derived(int marker) : + Base(() => Guard.Positive(-1)) { } + """, + "contracts", + ["SP0027"]); + var switchArm = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { public Base(int value) { } } + public sealed class Derived(int marker) : Base( + 0 switch { 0 => 0, _ => Guard.Positive(-1) }) { } + """, + "contracts", + ["SP0027"]); + var relationalSwitchArm = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { public Base(int value) { } } + public sealed class Derived(int marker) : Base( + 0 switch { > 0 => Guard.Positive(-1), _ => 0 }) { } + """, + "contracts", + ["SP0027"]); + var typeSwitchArm = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { public Base(int value) { } } + public sealed class Derived(int marker) : Base( + "value" switch { + string => 0, + _ => Guard.Positive(-1) + }) { } + """, + "contracts", + ["SP0027"]); + var nanRelationalArm = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { public Base(int value) { } } + public sealed class Derived(int marker) : Base( + double.NaN switch { + < 0.0 => Guard.Positive(-1), + _ => 0 + }) { } + """, + "contracts", + ["SP0027"]); + var nanDefaultArm = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public class Base { public Base(int value) { } } + public sealed class Derived(int marker) : Base( + double.NaN switch { + < 0.0 => 0, + _ => Guard.Positive(-1) + }) { } + """, + "contracts", + ["SP0027"]); + var initializer = await AnalyzerTestHost.AnalyzeAsync( + """ + using SharpProof.Attributes; + public static class Guard { + public static int Positive(int value) { + Contract.Requires(value > 0); + return value; + } + } + public sealed class Subject { + private int _value = false ? Guard.Positive(-1) : 0; + } + """, + "contracts", + ["SP0027"]); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + lambda.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(["SP0027"]), + "The lambda body is analyzed as its own callable, but the " + + "primary-constructor traversal must not duplicate it."); + Assert.That(switchArm, Is.Empty); + Assert.That(relationalSwitchArm, Is.Empty); + Assert.That(typeSwitchArm, Is.Empty); + Assert.That(nanRelationalArm, Is.Empty); + Assert.That( + nanDefaultArm.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(["SP0027"])); + Assert.That(initializer, Is.Empty); + } + } + [Test] public async Task PrimaryConstructorControlsDoNotDuplicateOrAnalyzeGeneratedCode() { diff --git a/SharpProof.Analyzer.Test/RequiresCallSiteDiscoveryTests.cs b/SharpProof.Analyzer.Test/RequiresCallSiteDiscoveryTests.cs index ea2e7154f..2c89b1fc3 100644 --- a/SharpProof.Analyzer.Test/RequiresCallSiteDiscoveryTests.cs +++ b/SharpProof.Analyzer.Test/RequiresCallSiteDiscoveryTests.cs @@ -302,6 +302,258 @@ public event Action Changed { add { } remove { } } } } + [Test] + public void CoalesceAssignmentDiscoversConditionalPropertySetter() + { + bool[] expectedReplay = [true, false]; + var compilation = AnalyzerTestHost.CreateCompilation( + """ + #nullable enable + public sealed class Subject { + public string? Value { get; set; } + public void Call() { Value ??= null; } + } + """, + []); + var tree = compilation.SyntaxTrees.Single(); + var declaration = tree.GetRoot().DescendantNodes() + .OfType() + .Single(); + var semanticModel = compilation.GetSemanticModel(tree); + var caller = (IMethodSymbol)semanticModel.GetDeclaredSymbol(declaration)!; + var candidates = new RequiresCallSiteDiscovery( + caller, + declaration, + semanticModel, + CancellationToken.None) + .Get(callerContracts: null); + + Assert.That(candidates, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That( + candidates!.Value.Select(static candidate => + candidate.TargetMethod.MethodKind), + Is.EqualTo(new[] { + MethodKind.PropertyGet, + MethodKind.PropertySet + })); + Assert.That( + candidates.Value.Select(static candidate => + candidate.CanReplay), + Is.EqualTo(expectedReplay)); + } + } + + [Test] + public void CoalesceAssignmentSkipsSetterAfterNonreturningGetter() + { + var compilation = AnalyzerTestHost.CreateCompilation( + """ + #nullable enable + using System; + public sealed class Subject { + public string? Value { + get => throw new InvalidOperationException(); + set { } + } + public void Call() { Value ??= null; } + } + """, + []); + var tree = compilation.SyntaxTrees.Single(); + var declaration = tree.GetRoot().DescendantNodes() + .OfType() + .Single(static method => method.Identifier.ValueText == "Call"); + var semanticModel = compilation.GetSemanticModel(tree); + var caller = (IMethodSymbol)semanticModel.GetDeclaredSymbol(declaration)!; + var candidates = new RequiresCallSiteDiscovery( + caller, + declaration, + semanticModel, + CancellationToken.None) + .Get(callerContracts: null); + + Assert.That(candidates, Is.Not.Null); + Assert.That( + candidates!.Value.Select(static candidate => + candidate.TargetMethod.MethodKind), + Is.EqualTo([MethodKind.PropertyGet])); + } + + [Test] + public void CoalesceAssignmentSkipsSetterAfterNonreturningReceiver() + { + var compilation = AnalyzerTestHost.CreateCompilation( + """ + #nullable enable + using System; + public sealed class Box { public string? Value { get; set; } } + public static class Subject { + private static Box Fail() => throw new InvalidOperationException(); + public static void Call() { Fail().Value ??= null; } + } + """, + []); + var tree = compilation.SyntaxTrees.Single(); + var declaration = tree.GetRoot().DescendantNodes() + .OfType() + .Single(static method => method.Identifier.ValueText == "Call"); + var semanticModel = compilation.GetSemanticModel(tree); + var caller = (IMethodSymbol)semanticModel.GetDeclaredSymbol(declaration)!; + var candidates = new RequiresCallSiteDiscovery( + caller, + declaration, + semanticModel, + CancellationToken.None) + .Get(callerContracts: null); + + Assert.That(candidates, Is.Not.Null); + Assert.That( + candidates!.Value.Select(static candidate => + candidate.TargetMethod.MethodKind), + Is.EqualTo([MethodKind.PropertyGet, MethodKind.Ordinary])); + } + + [Test] + public void CoalesceAssignmentSkipsSetterAfterNonreturningIndex() + { + var compilation = AnalyzerTestHost.CreateCompilation( + """ + #nullable enable + using System; + public sealed class Box { + public string? this[int index] { get => null; set { } } + } + public static class Subject { + private static int Fail() => throw new InvalidOperationException(); + public static void Call(Box box) { box[Fail()] ??= null; } + } + """, + []); + var tree = compilation.SyntaxTrees.Single(); + var declaration = tree.GetRoot().DescendantNodes() + .OfType() + .Single(static method => method.Identifier.ValueText == "Call"); + var semanticModel = compilation.GetSemanticModel(tree); + var caller = (IMethodSymbol)semanticModel.GetDeclaredSymbol(declaration)!; + var candidates = new RequiresCallSiteDiscovery( + caller, + declaration, + semanticModel, + CancellationToken.None) + .Get(callerContracts: null); + + Assert.That(candidates, Is.Not.Null); + Assert.That( + candidates!.Value.Select(static candidate => + candidate.TargetMethod.MethodKind), + Is.EqualTo([MethodKind.PropertyGet, MethodKind.Ordinary])); + } + + [Test] + public void CoalesceAssignmentSkipsSetterAfterNonreturningValue() + { + var compilation = AnalyzerTestHost.CreateCompilation( + """ + #nullable enable + using System; + public sealed class Subject { + public string? Value { get; set; } + private static string Fail() => + throw new InvalidOperationException(); + public void Call() { Value ??= Fail(); } + } + """, + []); + var tree = compilation.SyntaxTrees.Single(); + var declaration = tree.GetRoot().DescendantNodes() + .OfType() + .Single(static method => method.Identifier.ValueText == "Call"); + var semanticModel = compilation.GetSemanticModel(tree); + var caller = (IMethodSymbol)semanticModel.GetDeclaredSymbol(declaration)!; + var candidates = new RequiresCallSiteDiscovery( + caller, + declaration, + semanticModel, + CancellationToken.None) + .Get(callerContracts: null); + + Assert.That(candidates, Is.Not.Null); + Assert.That( + candidates!.Value.Select(static candidate => + candidate.TargetMethod.MethodKind), + Is.EqualTo([MethodKind.PropertyGet, MethodKind.Ordinary])); + } + + [Test] + public void CoalesceSetterReconciliationStaysInsideTheCaller() + { + var compilation = AnalyzerTestHost.CreateCompilation( + """ + #nullable enable + public sealed class Box { + public string? Value { get; set; } + } + public static class Subject { + public static void Call(Box box) { + void NeverCalled() { box.Value ??= null; } + } + } + """, + []); + var tree = compilation.SyntaxTrees.Single(); + var declaration = tree.GetRoot().DescendantNodes() + .OfType() + .Single(); + var semanticModel = compilation.GetSemanticModel(tree); + var caller = (IMethodSymbol)semanticModel.GetDeclaredSymbol(declaration)!; + var candidates = new RequiresCallSiteDiscovery( + caller, + declaration, + semanticModel, + CancellationToken.None) + .Get(callerContracts: null); + + Assert.That(candidates, Is.Not.Null); + Assert.That(candidates!.Value, Is.Empty); + } + + [Test] + public void CoalesceSetterReconciliationSkipsUnreachableBlocks() + { + var compilation = AnalyzerTestHost.CreateCompilation( + """ + #nullable enable + public sealed class Box { + public string? Value { get; set; } + } + public static class Subject { + public static void Call(Box box) { + if (false) { + box.Value ??= null; + } + } + } + """, + []); + var tree = compilation.SyntaxTrees.Single(); + var declaration = tree.GetRoot().DescendantNodes() + .OfType() + .Single(static method => method.Identifier.ValueText == "Call"); + var semanticModel = compilation.GetSemanticModel(tree); + var caller = (IMethodSymbol)semanticModel.GetDeclaredSymbol(declaration)!; + var candidates = new RequiresCallSiteDiscovery( + caller, + declaration, + semanticModel, + CancellationToken.None) + .Get(callerContracts: null); + + Assert.That(candidates, Is.Not.Null); + Assert.That(candidates!.Value, Is.Empty); + } + [Test] public void AccessorRequiresArePotentialCallPreconditions() { @@ -520,6 +772,60 @@ public static int Read( } } + [Test] + public async Task ListPatternImplicitAccessorsHonorPreconditionsAndOrder() + { + var compilation = AnalyzerTestHost.CreateCompilation( + """ + using SharpProof.Attributes; + public sealed class LengthContractList { + public int Length { + get { Contract.Requires(false); return 0; } + } + public int this[int index] => 0; + } + public sealed class EmptyIndexerContractList { + public int Length => 0; + public int this[int index] { + get { Contract.Requires(false); return 0; } + } + } + public sealed class OneIndexerContractList { + public int Length => 1; + public int this[int index] { + get { Contract.Requires(false); return 0; } + } + } + public sealed class SliceContractList { + public int Length => 1; + public int this[int index] => 0; + public SliceContractList Slice(int start, int length) { + Contract.Requires(false); + return this; + } + } + public static class Subject { + public static bool EmptyLength(LengthContractList value) => + value is []; + public static bool LengthMismatchSkipsIndexer() => + new EmptyIndexerContractList() is [0]; + public static bool ReachableIndexer() => + new OneIndexerContractList() is [0]; + public static bool ReachableSlice() => + new SliceContractList() is [.. var rest]; + } + """, + ["SP0027"]); + + var diagnostics = await AnalyzerTestHost.AnalyzeAsync( + compilation, + mode: "CONTRACTS"); + + Assert.That( + diagnostics.Select(static diagnostic => diagnostic.Id), + Is.EqualTo(Enumerable.Repeat("SP0027", 3))); + } + [Test] public void PotentialPreconditionScreenFailsClosedWithoutTrustedApiIdentity() { diff --git a/SharpProof.AnalyzerConsumer.props b/SharpProof.AnalyzerConsumer.props index 54e160d1d..82c496ab6 100644 --- a/SharpProof.AnalyzerConsumer.props +++ b/SharpProof.AnalyzerConsumer.props @@ -4,9 +4,12 @@ advisory all - <_SharpProofProfileNormalized>$([System.String]::Copy('$(SharpProofProfile)').ToLowerInvariant()) + <_SharpProofProfileNormalized>$([System.String]::Copy('$(SharpProofProfile)').Trim().ToLowerInvariant()) + <_SharpProofFeaturesNormalized>$([System.String]::Copy('$(SharpProofFeatures)').Trim().ToLowerInvariant()) true false + <_SharpProofContractsRuntimeEnabled + Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(DefineConstants)', '(^|[;,])\s*SHARPPROOF_CONTRACTS\s*($|[;,])'))">true @@ -61,4 +64,30 @@ + + + + + + + + + + + + + + diff --git a/SharpProof.ArchitectureTest/AcceptanceScriptTests.cs b/SharpProof.ArchitectureTest/AcceptanceScriptTests.cs index d29b72b1d..62027630a 100644 --- a/SharpProof.ArchitectureTest/AcceptanceScriptTests.cs +++ b/SharpProof.ArchitectureTest/AcceptanceScriptTests.cs @@ -171,6 +171,13 @@ private static string WriteHarness(string fixture) File.Copy( Path.Combine(root, "eng", "acceptance", "contract.json"), Path.Combine(acceptance, "contract.json")); + var fixtureScripts = Path.Combine(fixture, "scripts"); + Directory.CreateDirectory(fixtureScripts); + File.Copy( + Path.Combine( + root, "scripts", "SharpProof.FuzzEvidenceLifecycle.ps1"), + Path.Combine( + fixtureScripts, "SharpProof.FuzzEvidenceLifecycle.ps1")); var harnessPath = Path.Combine(acceptance, "VerifyHarness.ps1"); var setup = """ $contract = Get-Content -LiteralPath $contractPath -Raw | diff --git a/SharpProof.ArchitectureTest/ArchitectureTests.cs b/SharpProof.ArchitectureTest/ArchitectureTests.cs index 8f890f7b8..d65a5bdd3 100644 --- a/SharpProof.ArchitectureTest/ArchitectureTests.cs +++ b/SharpProof.ArchitectureTest/ArchitectureTests.cs @@ -43,6 +43,7 @@ public sealed class ArchitectureTests "SharpProof.Specs", "SharpProof.Dataflow", "SharpProof.Frontend", + "SharpProof.Fuzz", "SharpProof.Host", "SharpProof.Contracts", "SharpProof.Effects", @@ -191,6 +192,14 @@ public void NewLayerProjectReferencesFollowTheDependencyDag() "SharpProof.Attributes", "SharpProof.Ir" ], + ["SharpProof.Fuzz"] = [ + "SharpProof.Frontend", + "SharpProof.Host", + "SharpProof.Ir", + "SharpProof.Smt", + "SharpProof.Testing", + "SharpProof.Verify" + ], ["SharpProof.Contracts"] = [ "SharpProof.Frontend", "SharpProof.Ir" @@ -307,7 +316,7 @@ public void LanguageNeutralLayersHaveNoCSharpSyntaxDependency() } [Test] - public void OnlyTheSmtLayerReferencesZ3InTheProductionGraph() + public void OnlyTheSmtLayerAndFuzzHarnessReferenceZ3InTheProductionGraph() { foreach (var project in ProductionProjects) { @@ -319,7 +328,7 @@ public void OnlyTheSmtLayerReferencesZ3InTheProductionGraph() .ToArray(); Assert.That( packages.Contains("Microsoft.Z3", StringComparer.Ordinal), - Is.EqualTo(project == "SharpProof.Smt"), + Is.EqualTo(project is "SharpProof.Smt" or "SharpProof.Fuzz"), project); } } @@ -466,8 +475,9 @@ public void ProofProducingOutcomeConstructorsStayInTheKernel() Assert.That( FindRelativeCallers(productionFiles, "new Assumption("), Is.EqualTo([ - "SharpProof.Worker/CallableEvidenceBuilder.cs", - "SharpProof.Worker/PostconditionObligationBuilder.cs" + "SharpProof.Worker/CallableEvidenceBuilder.cs", + "SharpProof.Worker/PostconditionObligationBuilder.cs", + "Tools/SharpProof.Fuzz/FiniteDomainSmtFuzzing.cs" ])); Assert.That( FindRelativeCallers(productionFiles, "new EffectSummary("), @@ -489,6 +499,7 @@ public void AlgorithmLayerSizeRatchetManifestIsWellFormed() "SharpProof.Dataflow/SequenceCardinalityDomain.cs", "SharpProof.Effects/EffectAnalysisSession.cs", "SharpProof.Effects/ExternalEffectResolver.cs", + "SharpProof.Effects/OperationEffectScanner.Assignments.cs", "SharpProof.Effects/OperationEffectScanner.cs", "SharpProof.Frontend/RoslynOperationLowerer.cs", "SharpProof.Frontend/RoslynProgramLowerer.cs", @@ -2215,7 +2226,14 @@ private static string[] ProjectPackages(string project) private static string ProjectFile(string project) { - return Path.Combine(RepositoryRoot(), project, project + ".csproj"); + return Path.Combine(ProjectDirectory(project), project + ".csproj"); + } + + private static string ProjectDirectory(string project) + { + return project == "SharpProof.Fuzz" + ? Path.Combine(RepositoryRoot(), "Tools", project) + : Path.Combine(RepositoryRoot(), project); } private static string ReadProductionSources(string project) @@ -2229,7 +2247,7 @@ private static string ReadProductionSources(string project) private static IEnumerable ProductionSourceFiles(string project) { return Directory.GetFiles( - Path.Combine(RepositoryRoot(), project), + ProjectDirectory(project), "*.cs", SearchOption.AllDirectories) .Where(static path => @@ -2277,10 +2295,15 @@ public void NightlyFuzzCampaignIsContainerConnectedAndEvidenceBound() .GetProperty("fuzz") .GetProperty("nightlyCases") .GetInt32(); + var maximumCampaignCases = contract.RootElement + .GetProperty("fuzz") + .GetProperty("maximumCampaignCases") + .GetInt32(); using (Assert.EnterMultipleScope()) { Assert.That(nightlyCases, Is.Positive); + Assert.That(maximumCampaignCases, Is.GreaterThan(nightlyCases)); Assert.That(workflow, Does.Contain("tooling fuzz-nightly")); Assert.That( Directory.EnumerateFiles( @@ -2302,15 +2325,19 @@ public void NightlyFuzzCampaignIsContainerConnectedAndEvidenceBound() .And.Contain("requires clean exact-commit source")); Assert.That(campaign, Does.Contain("contract.fuzz.nightlyCases") + .And.Contain("contract.fuzz.maximumCampaignCases") + .And.Contain("Assert-SharpProofFuzzCampaignBudget") .And.Contain("ContainsKey('RotatingSeed')") - .And.Contain("retained.seeds") + .And.Contain("Read-SharpProofRetainedFuzzSeedManifest") + .And.Contain("$retained.Seeds") .And.Contain("Invoke-FuzzRun") .And.Contain("yyyyMMdd") .And.Contain("schemaVersion = 3") .And.Contain("commit = $sourceCommit") .And.Contain("rotatingCases = $effectiveRotatingCases") .And.Contain("retainedCasesPerSeed = $effectiveRetainedCases") - .And.Contain("retainedSeeds = @($retained.seeds") + .And.Contain("retainedSeeds = $retainedSeeds") + .And.Contain("retainedSeedManifestSha256 = $retained.Sha256") .And.Contain("resultSha256") .And.Contain("status = if")); Assert.That(acceptance, diff --git a/SharpProof.ArchitectureTest/BoundaryEnforcementTests.cs b/SharpProof.ArchitectureTest/BoundaryEnforcementTests.cs index 9d7b08353..92b7152d9 100644 --- a/SharpProof.ArchitectureTest/BoundaryEnforcementTests.cs +++ b/SharpProof.ArchitectureTest/BoundaryEnforcementTests.cs @@ -20,6 +20,7 @@ public sealed class BoundaryEnforcementTests "SharpProof.Dataflow", "SharpProof.Effects", "SharpProof.Frontend", + "SharpProof.Fuzz", "SharpProof.Gates", "SharpProof.Host", "SharpProof.Ir", @@ -131,7 +132,7 @@ public void GeneratedProductionFilesAreExplicitlyApproved() var actual = BannedApiProjects .SelectMany(project => Directory.GetFiles( - Path.Combine(root, project), + Path.Combine(root, ProjectDirectory(project)), "*.cs", SearchOption.AllDirectories)) .Where(static path => @@ -564,7 +565,7 @@ private static string[] ProjectPackages(string project) private static IEnumerable SourceFiles(string project) { return Directory.GetFiles( - Path.Combine(RepositoryRoot(), project), + Path.Combine(RepositoryRoot(), ProjectDirectory(project)), "*.cs", SearchOption.AllDirectories) .Where(static path => @@ -586,7 +587,17 @@ private static string ReadProductionSources(string project) private static string ProjectFile(string project) { - return Path.Combine(RepositoryRoot(), project, project + ".csproj"); + return Path.Combine( + RepositoryRoot(), + ProjectDirectory(project), + project + ".csproj"); + } + + private static string ProjectDirectory(string project) + { + return project == "SharpProof.Fuzz" + ? Path.Combine("Tools", project) + : project; } private static string Relative(string path) diff --git a/SharpProof.ArchitectureTest/ContainerSourceCleanlinessTests.cs b/SharpProof.ArchitectureTest/ContainerSourceCleanlinessTests.cs index f09fb987a..5b1c25f4b 100644 --- a/SharpProof.ArchitectureTest/ContainerSourceCleanlinessTests.cs +++ b/SharpProof.ArchitectureTest/ContainerSourceCleanlinessTests.cs @@ -118,6 +118,31 @@ await File.WriteAllTextAsync( } } + [Test] + public async Task GitBoundCommandAcceptsRepositoryWithDifferentOwner() + { + var repository = await CreateRepositoryAsync(); + try + { + var result = await RunEntrypointAsync( + repository, + "package-consumers", + assumeDifferentOwner: true); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.ExitCode, Is.Zero, result.Error); + Assert.That( + result.Output, + Does.Contain("executed:package-consumers")); + } + } + finally + { + Directory.Delete(repository, recursive: true); + } + } + [TestCase("contract")] [TestCase("build")] public async Task FiniteCommandsRunFromAnArchiveWithoutGit(string command) @@ -303,14 +328,21 @@ await File.WriteAllTextAsync( private static Task RunEntrypointAsync( string repository, - string command) + string command, + bool assumeDifferentOwner = false) { + var environment = new Dictionary + { + ["SHARPPROOF_REPO_ROOT"] = repository + }; + if (assumeDifferentOwner) + { + environment["GIT_TEST_ASSUME_DIFFERENT_OWNER"] = "1"; + } + return RunAsync( repository, - new Dictionary - { - ["SHARPPROOF_REPO_ROOT"] = repository - }, + environment, "bash", Path.Combine( RepositoryRoot(), diff --git a/SharpProof.ArchitectureTest/GeneratedFileHelperTests.cs b/SharpProof.ArchitectureTest/GeneratedFileHelperTests.cs new file mode 100644 index 000000000..ed57d159a --- /dev/null +++ b/SharpProof.ArchitectureTest/GeneratedFileHelperTests.cs @@ -0,0 +1,115 @@ +using System.Diagnostics; +using NUnit.Framework; + +namespace SharpProof.ArchitectureTest; + +[TestFixture] +[Platform("Linux")] +public sealed class GeneratedFileHelperTests +{ + [Test] + public async Task VerifyRejectsCrlfByteDrift() + { + var fixture = Path.Combine( + Path.GetTempPath(), + "SharpProof.GeneratedFileHelper." + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(fixture); + try + { + var probe = Path.Combine(fixture, "probe.ps1"); + var output = Path.Combine(fixture, "generated.txt"); + await File.WriteAllTextAsync( + probe, + """ + param( + [Parameter(Mandatory = $true)][string]$Helper, + [Parameter(Mandatory = $true)][string]$Output + ) + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + . $Helper + $content = "first`nsecond`n" + Update-SharpProofGeneratedFile ` + -Path $Output ` + -Content $content ` + -DisplayPath 'generated.txt' ` + -GeneratorCommand 'fixture generator' + Update-SharpProofGeneratedFile ` + -Path $Output ` + -Content $content ` + -DisplayPath 'generated.txt' ` + -GeneratorCommand 'fixture generator' ` + -Verify + $crlf = [IO.File]::ReadAllText($Output).Replace("`n", "`r`n") + [IO.File]::WriteAllText( + $Output, + $crlf, + [Text.UTF8Encoding]::new($false)) + try { + Update-SharpProofGeneratedFile ` + -Path $Output ` + -Content $content ` + -DisplayPath 'generated.txt' ` + -GeneratorCommand 'fixture generator' ` + -Verify + } + catch { + exit 0 + } + throw 'Generated-file verification accepted CRLF byte drift.' + """); + + var info = new ProcessStartInfo + { + FileName = "pwsh", + WorkingDirectory = fixture, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var argument in new[] + { + "-NoLogo", + "-NoProfile", + "-File", + probe, + "-Helper", + Path.Combine(RepositoryRoot(), "scripts", "GeneratedFileHelpers.ps1"), + "-Output", + output + }) + { + info.ArgumentList.Add(argument); + } + + using var process = Process.Start(info)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + Assert.That( + process.ExitCode, + Is.Zero, + await stdout + Environment.NewLine + await stderr); + } + finally + { + Directory.Delete(fixture, recursive: true); + } + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "SharpProof.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root."); + } +} diff --git a/SharpProof.ArchitectureTest/PublicationDestinationAuthorityTests.cs b/SharpProof.ArchitectureTest/PublicationDestinationAuthorityTests.cs index a5bbaa821..9f3c4270e 100644 --- a/SharpProof.ArchitectureTest/PublicationDestinationAuthorityTests.cs +++ b/SharpProof.ArchitectureTest/PublicationDestinationAuthorityTests.cs @@ -30,6 +30,7 @@ public sealed class PublicationDestinationAuthorityTests [TestCase("mocked-main-missing", true)] [TestCase("mocked-main-exists", false)] [TestCase("mocked-main-error", false)] + [TestCase("mocked-main-query-base", false)] [TestCase("zero-symbol-preflight", true)] [TestCase("fixture-empty", true)] [TestCase("fixture-foreign", true)] diff --git a/SharpProof.ArchitectureTest/PublicationPlanIdentityTests.cs b/SharpProof.ArchitectureTest/PublicationPlanIdentityTests.cs index d8ff63412..b6d04ac53 100644 --- a/SharpProof.ArchitectureTest/PublicationPlanIdentityTests.cs +++ b/SharpProof.ArchitectureTest/PublicationPlanIdentityTests.cs @@ -14,6 +14,23 @@ public sealed class PublicationPlanIdentityTests [TestCase("stale-checksums", false)] [TestCase("missing-identity", false)] [TestCase("duplicate-identity", false)] + [TestCase("version-syntax", true)] + [TestCase("commit-syntax", true)] + [TestCase("string-schema", false)] + [TestCase("decimal-bytes", false)] + [TestCase("array-version", false)] + [TestCase("array-commit", false)] + [TestCase("array-artifact-text", false)] + [TestCase("version-authority-hash-tamper", false)] + [TestCase("destination-tamper", false)] + [TestCase("package-action-tamper", false)] + [TestCase("fixture-canonical", true)] + [TestCase("fixture-authority-tamper", false)] + [TestCase("fixture-nonexistent-archive", true)] + [TestCase("registry-canonical", true)] + [TestCase("registry-url-tamper", false)] + [TestCase("targetless-publish-tamper", false)] + [TestCase("json-roundtrip", true)] public async Task ReplayRehashesEveryImmutablePlanInput( string mutation, bool expectedValid) diff --git a/SharpProof.ArchitectureTest/ReleaseCoverageBaselineTests.cs b/SharpProof.ArchitectureTest/ReleaseCoverageBaselineTests.cs index e4e577b9d..1cc1379d0 100644 --- a/SharpProof.ArchitectureTest/ReleaseCoverageBaselineTests.cs +++ b/SharpProof.ArchitectureTest/ReleaseCoverageBaselineTests.cs @@ -127,6 +127,15 @@ public void QualificationWriterRevalidatesArtifactsAndGateReceipts() Assert.That(writer, Does.Contain("packages.Count -ne 6")); Assert.That(writer, Does.Contain("does not match checkout HEAD")); Assert.That(writer, Does.Contain("requires a clean checkout")); + Assert.That( + writer, + Does.Contain("Resolve-SharpProofContainedPath.ps1")); + Assert.That( + writer, + Does.Contain("Resolve-SharpProofContainedPath")); + Assert.That(writer, Does.Contain("$allUntrackedChanges")); + Assert.That(writer, Does.Contain("$packagePrefix")); + Assert.That(writer, Does.Not.Contain(":(exclude)")); Assert.That(writer, Does.Contain("annotated tag at checkout HEAD")); foreach (var gate in new[] { diff --git a/SharpProof.ArchitectureTest/ReleaseQualificationMatrixTests.cs b/SharpProof.ArchitectureTest/ReleaseQualificationMatrixTests.cs index e37b17903..2d0c0f384 100644 --- a/SharpProof.ArchitectureTest/ReleaseQualificationMatrixTests.cs +++ b/SharpProof.ArchitectureTest/ReleaseQualificationMatrixTests.cs @@ -140,6 +140,88 @@ await File.WriteAllTextAsync(evidence, JsonSerializer.Serialize(new } } + [Test] + public async Task ReceiptWriterRequiresReviewedPilotEvidence() + { + var sourceRoot = RepositoryRoot(); + var fixture = Directory.CreateTempSubdirectory("sp004-pilot-receipt-"); + try + { + var scripts = Directory.CreateDirectory(Path.Combine( + fixture.FullName, + "scripts")); + File.Copy( + Path.Combine( + sourceRoot, + "scripts", + "Write-SharpProofQualificationReceipt.ps1"), + Path.Combine( + scripts.FullName, + "Write-SharpProofQualificationReceipt.ps1")); + await File.WriteAllTextAsync( + Path.Combine(scripts.FullName, "Test-SharpProofPilotReport.ps1"), + "function Test-SharpProofPilotReport { return $true }\n"); + await RunAsync(fixture.FullName, "git", "init", "-q"); + await RunAsync( + fixture.FullName, + "git", + "config", + "user.email", + "fixture@example.invalid"); + await RunAsync( + fixture.FullName, + "git", + "config", + "user.name", + "Fixture"); + await File.WriteAllTextAsync( + Path.Combine(fixture.FullName, "tracked.txt"), + "fixture\n"); + await RunAsync(fixture.FullName, "git", "add", "tracked.txt"); + await RunAsync( + fixture.FullName, + "git", + "commit", + "-q", + "-m", + "fixture"); + var evidence = Path.Combine(fixture.FullName, "pilots.json"); + var packages = Enumerable.Range(0, 6).Select(index => new + { + fileName = $"package-{index}.nupkg", + bytes = 1, + sha256 = new string((char)('a' + index), 64) + }).ToArray(); + + async Task WriteAsync(string reviewStatus) + { + await File.WriteAllTextAsync(evidence, JsonSerializer.Serialize(new + { + reviewStatus, + packageArtifacts = packages, + pilots = Array.Empty() + })); + return await RunExitCodeAsync( + fixture.FullName, + "pwsh", "-NoLogo", "-NoProfile", "-File", + Path.Combine( + scripts.FullName, + "Write-SharpProofQualificationReceipt.ps1"), + "-Gate", "pilots", "-EvidencePath", evidence); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(await WriteAsync("Reviewed"), Is.Zero); + Assert.That(await WriteAsync("Unreviewed"), Is.Not.Zero); + } + } + finally + { + fixture.Delete(recursive: true); + } + } + private static string Job(string workflow, string name, string next) { var start = workflow.IndexOf(" " + name + ":", StringComparison.Ordinal); diff --git a/SharpProof.BuildTasks/Program.cs b/SharpProof.BuildTasks/Program.cs new file mode 100644 index 000000000..995c8bc13 --- /dev/null +++ b/SharpProof.BuildTasks/Program.cs @@ -0,0 +1,23 @@ +namespace SharpProof.BuildTasks; + +internal static class Program +{ + private const string SupervisorArgument = "--supervise-verifier"; + private const string WorkerArgument = "--run-verifier-child"; + + private static int Main(string[] arguments) + { + if (arguments.Length < 2) + { + return 2; + } + return arguments[0] switch + { + SupervisorArgument => + VerifierProcessSupervisor.Run(arguments[1..]), + WorkerArgument => + VerifierProcessSupervisor.RunWorker(arguments[1..]), + _ => 2 + }; + } +} diff --git a/SharpProof.BuildTasks/RunVerifier.cs b/SharpProof.BuildTasks/RunVerifier.cs index 868674601..922157858 100644 --- a/SharpProof.BuildTasks/RunVerifier.cs +++ b/SharpProof.BuildTasks/RunVerifier.cs @@ -1,19 +1,62 @@ using System.ComponentModel; +using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using SharpProof.Host; namespace SharpProof.BuildTasks; -public sealed class RunVerifier : Microsoft.Build.Utilities.Task, ICancelableTask +public sealed partial class RunVerifier : Microsoft.Build.Utilities.Task, + ICancelableTask, IDisposable { + internal const int LauncherProcessReserveMilliseconds = 1000; + // The supervisor and worker launcher run from this instrumentable + // assembly. Keep additional bounded room for their final timeout + // publication and authenticated cleanup when coverage or a heavily loaded + // host slows managed startup. Direct task callers retain their original + // deadline semantics. + private const int WorkerLauncherProcessReserveMilliseconds = 5000; + private const int CleanupAuthenticationWaitMilliseconds = 5000; + internal const int MaximumCapturedOutputCharacters = 1_048_576; + internal const int OutputDrainPollingMilliseconds = 25; + private const int MaximumProtocolLineCharacters = 160; + private const int PidFdSendSignalSystemCall = 424; + private const int PidFdOpenSystemCall = 434; + private const int SignalTerminate = 15; + private const int SignalStop = 19; + private const int SignalKill = 9; + private const string ProcessGroupLauncher = "/usr/bin/setsid"; + private const string ProcessGateStartMessage = "SharpProof.Start/1"; + private const string SupervisorArmedMessage = "SharpProof.Armed/1"; + private const string SupervisorCleanupMessage = "SharpProof.Cleanup/1"; + private static readonly ConcurrentDictionary + RetainedCleanupAnchors = new(); + private static long _nextCleanupAnchor; private readonly object _synchronization = new(); + private readonly ManualResetEventSlim _cancellationSignal = new(); + private readonly ManualResetEventSlim _outputLimitSignal = new(); private Process? _process; + private int _processGroupId; + private int _processGroupPidFd = -1; + private System.Threading.Tasks.TaskCompletionSource? + _supervisorArmedSignal; + private System.Threading.Tasks.Task? + _supervisorOutputCompletion; private bool _canceled; + internal Func? OpenPidFdOverride { get; set; } + internal Func? TryTerminateOverride { get; set; } + internal Action? ContainmentAuthenticationFailureOverride { get; set; } + + internal static int RetainedCleanupAnchorCount => + RetainedCleanupAnchors.Count; + [Required] public string Executable { get; set; } = string.Empty; @@ -27,6 +70,10 @@ public sealed class RunVerifier : Microsoft.Build.Utilities.Task, ICancelableTas [Required] public string WorkingDirectory { get; set; } = string.Empty; + public int ProjectWallTimeMilliseconds { get; set; } = 300000; + + public int TerminationGraceMilliseconds { get; set; } = 1000; + [Output] public int ExitCode { get; set; } @@ -44,6 +91,13 @@ internal bool HasActiveProcess } } + public void Dispose() + { + _cancellationSignal.Dispose(); + _outputLimitSignal.Dispose(); + _process?.Dispose(); + } + [SuppressMessage( "Design", "CA1031:Do not catch general exception types", @@ -51,23 +105,69 @@ internal bool HasActiveProcess public override bool Execute() { Process? process = null; + var processGroupId = 0; + System.Threading.Tasks.Task? standardOutput = null; + System.Threading.Tasks.Task? standardError = null; + var supervisorArmedSignal = + new System.Threading.Tasks.TaskCompletionSource( + System.Threading.Tasks.TaskCreationOptions + .RunContinuationsAsynchronously); + var supervisorCleanupSignal = + new System.Threading.Tasks.TaskCompletionSource( + System.Threading.Tasks.TaskCreationOptions + .RunContinuationsAsynchronously); + var supervisorNonce = string.Empty; + var retainCleanupAnchor = false; HasStructuredError = false; + ExitCode = 0; + var containmentFailed = false; + if (!_canceled) + { + _cancellationSignal.Reset(); + } + _outputLimitSignal.Reset(); try { ContainerContract.ValidateRequired(); + var processTimeout = ComputeProcessTimeout( + ProjectWallTimeMilliseconds, + TerminationGraceMilliseconds); + var workerLauncherBudget = HasWorkerLauncherBudgetArguments(); + if (workerLauncherBudget) + { + processTimeout = checked(processTimeout + + WorkerLauncherProcessReserveMilliseconds - + LauncherProcessReserveMilliseconds); + } + // The verifier launcher uses the project timeout plus termination + // grace as its own final deadline. Keep the full process deadline + // for that invocation so the reserve remains available for + // containment and output drain. Direct task callers do not have + // that inner deadline and retain the task's original timeout. + var verifierTimeout = workerLauncherBudget + ? processTimeout + : processTimeout - LauncherProcessReserveMilliseconds; + var processStopwatch = Stopwatch.StartNew(); var resolvedExecutable = ResolveDotNetHost(Executable); + supervisorNonce = CreateSupervisorNonce(); process = new Process { StartInfo = new ProcessStartInfo { - FileName = resolvedExecutable, + FileName = ResolveProcessGroupLauncherRequired(), WorkingDirectory = Path.GetFullPath(WorkingDirectory), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, + RedirectStandardInput = true, CreateNoWindow = true } }; + process.StartInfo.ArgumentList.Add(resolvedExecutable); + process.StartInfo.ArgumentList.Add( + ResolveSupervisorAssemblyRequired()); + process.StartInfo.ArgumentList.Add("--supervise-verifier"); + process.StartInfo.ArgumentList.Add(resolvedExecutable); foreach (var argument in Arguments) { process.StartInfo.ArgumentList.Add(argument.ItemSpec); @@ -84,13 +184,98 @@ public override bool Execute() throw new InvalidOperationException( "The SharpProof verifier process could not be started."); } + processGroupId = process.Id; + int processGroupPidFd; + try + { + processGroupPidFd = OpenPidFdRequired(processGroupId); + } + catch + { + TerminateBootstrapProcess(process); + throw; + } _process = process; + _processGroupId = processGroupId; + _processGroupPidFd = processGroupPidFd; + _supervisorArmedSignal = supervisorArmedSignal; + standardOutput = ReadBoundedOutputAsync( + process.StandardOutput, + supervisorNonce, + _outputLimitSignal, + supervisorArmedSignal, + supervisorCleanupSignal); + standardError = ReadBoundedOutputAsync( + process.StandardError, + supervisorNonce: null, + _outputLimitSignal); + _supervisorOutputCompletion = standardOutput; + process.StandardInput.WriteLine( + ProcessGateStartMessage + " " + supervisorNonce); + process.StandardInput.Close(); + } + var timedOut = !WaitForExitOrCancellation( + process, + Math.Min( + verifierTimeout, + RemainingMilliseconds( + processStopwatch, + processTimeout))); + var canceled = _cancellationSignal.IsSet; + if (timedOut) + { + var processWasAlive = !process.HasExited; + var contained = TryTerminate( + process, + processGroupId, + RemainingMilliseconds( + processStopwatch, + processTimeout)); + retainCleanupAnchor |= processWasAlive; + if (!contained) + { + containmentFailed = true; + } + canceled = _cancellationSignal.IsSet; + if (!canceled && !_outputLimitSignal.IsSet) + { + _ = process.WaitForExit(RemainingMilliseconds( + processStopwatch, + processTimeout)); + } + } + var outputCompleted = WaitForOutputCompletion( + System.Threading.Tasks.Task.WhenAll( + standardOutput, + standardError), + RemainingMilliseconds( + processStopwatch, + processTimeout), + () => _cancellationSignal.IsSet || + _outputLimitSignal.IsSet); + var interrupted = _cancellationSignal.IsSet || + _outputLimitSignal.IsSet; + if (!outputCompleted) + { + timedOut = true; + var processWasAlive = !process.HasExited; + var contained = TryTerminate( + process, + processGroupId, + RemainingMilliseconds( + processStopwatch, + processTimeout)); + retainCleanupAnchor |= processWasAlive; + containmentFailed |= !contained; } - var standardOutput = process.StandardOutput.ReadToEndAsync(); - var standardError = process.StandardError.ReadToEndAsync(); - process.WaitForExit(); - var output = standardOutput.GetAwaiter().GetResult(); - var error = standardError.GetAwaiter().GetResult(); + var outputResult = standardOutput.IsCompletedSuccessfully + ? standardOutput.Result + : null; + var errorResult = standardError.IsCompletedSuccessfully + ? standardError.Result + : null; + var output = outputResult?.Text ?? string.Empty; + var error = errorResult?.Text ?? string.Empty; if (!string.IsNullOrWhiteSpace(output)) { Log.LogMessage(MessageImportance.High, "{0}", output); @@ -99,10 +284,48 @@ public override bool Execute() { LogStandardError(error); } - ExitCode = process.ExitCode; + if (_outputLimitSignal.IsSet || + outputResult?.LimitExceeded == true || + errorResult?.LimitExceeded == true) + { + Log.LogError( + "SharpProof verifier output exceeded the bounded " + + "diagnostic capture limit."); + } + var supervisorArmed = outputResult?.SupervisorArmed == true || + supervisorArmedSignal.Task.IsCompletedSuccessfully; + var authenticationRequired = supervisorArmed || + process.HasExited && process.ExitCode != 125; + var deferAuthentication = + canceled || + ShouldDeferSupervisorAuthentication( + authenticationRequired, + interrupted, + outputCompleted); + if (deferAuthentication) + { + retainCleanupAnchor = true; + } + else if (!RequireSupervisorCleanupReceipt( + outputResult?.CleanupAuthenticated == true, + authenticationRequired)) + { + containmentFailed = true; + } + ExitCode = containmentFailed + ? -1 + : timedOut + ? 124 + : process.ExitCode; } catch (Exception exception) { + var contained = TryTerminate( + process, + processGroupId, + LauncherProcessReserveMilliseconds); + retainCleanupAnchor = !contained && + process is { HasExited: false }; ExitCode = -1; Log.LogMessage( MessageImportance.High, @@ -111,11 +334,40 @@ public override bool Execute() } finally { + var processGroupPidFd = -1; lock (_synchronization) { if (ReferenceEquals(_process, process)) { _process = null; + _processGroupId = 0; + processGroupPidFd = _processGroupPidFd; + _processGroupPidFd = -1; + _supervisorArmedSignal = null; + _supervisorOutputCompletion = null; + } + } + if (processGroupPidFd >= 0) + { + if (retainCleanupAnchor && process != null) + { + Action? authenticationFailure = + _canceled + ? null + : HandleContainmentAuthenticationFailure; + RetainCleanupAnchor( + process, + processGroupPidFd, + standardOutput, + standardError, + supervisorNonce, + supervisorCleanupSignal.Task, + authenticationFailure); + process = null; + } + else + { + _ = NativeMethods.Close(processGroupPidFd); } } process?.Dispose(); @@ -123,6 +375,627 @@ public override bool Execute() return true; } + internal static string CreateSupervisorNonce() + { + return Convert.ToHexString( + RandomNumberGenerator.GetBytes(32)).ToUpperInvariant(); + } + + internal static bool WaitForOutputCompletion( + System.Threading.Tasks.Task outputCompletion, + int timeoutMilliseconds, + Func isInterrupted, + Func? waitOverride = null) + { + ArgumentNullException.ThrowIfNull(outputCompletion); + ArgumentNullException.ThrowIfNull(isInterrupted); + if (timeoutMilliseconds <= 0) + { + return outputCompletion.IsCompleted; + } + + var stopwatch = Stopwatch.StartNew(); + while (true) + { + if (outputCompletion.IsCompleted) + { + return true; + } + if (isInterrupted()) + { + return false; + } + + var remaining = RemainingMilliseconds( + stopwatch, + timeoutMilliseconds); + if (remaining <= 0) + { + return outputCompletion.IsCompleted; + } + + var slice = Math.Min( + OutputDrainPollingMilliseconds, + remaining); + var completed = waitOverride == null + ? outputCompletion.Wait(slice) + : waitOverride(slice); + if (completed) + { + return true; + } + } + } + + internal static SupervisorReadiness WaitForSupervisorReadiness( + System.Threading.Tasks.Task armed, + System.Threading.Tasks.Task outputCompletion, + Func hasExited, + int timeoutMilliseconds, + Func? waitOverride = null) + { + ArgumentNullException.ThrowIfNull(armed); + ArgumentNullException.ThrowIfNull(outputCompletion); + ArgumentNullException.ThrowIfNull(hasExited); + var stopwatch = Stopwatch.StartNew(); + while (true) + { + if (armed.IsCompletedSuccessfully) + { + return SupervisorReadiness.Armed; + } + if (hasExited() && outputCompletion.IsCompletedSuccessfully) + { + return armed.IsCompletedSuccessfully + ? SupervisorReadiness.Armed + : SupervisorReadiness.ExitedBeforeArmed; + } + + var remaining = RemainingMilliseconds( + stopwatch, + timeoutMilliseconds); + if (remaining <= 0) + { + return SupervisorReadiness.NotReady; + } + + var slice = Math.Min(OutputDrainPollingMilliseconds, remaining); + _ = waitOverride == null + ? armed.Wait(slice) + : waitOverride(slice); + } + } + + internal static bool HasSupervisorProtocolRecord( + string output, + string message, + string nonce) + { + var expected = message + " " + nonce; + return output.Split('\n').Any(line => + string.Equals( + line.EndsWith('\r') ? line[..^1] : line, + expected, + StringComparison.Ordinal)); + } + + internal static bool ShouldDeferSupervisorAuthentication( + bool authenticationRequired, + bool interrupted, + bool outputCompleted) + { + _ = interrupted; + return authenticationRequired && !outputCompleted; + } + + internal static async System.Threading.Tasks.Task + ReadBoundedOutputAsync( + TextReader reader, + string? supervisorNonce, + ManualResetEventSlim outputLimitSignal, + System.Threading.Tasks.TaskCompletionSource? + supervisorArmedSignal = null, + System.Threading.Tasks.TaskCompletionSource? + supervisorCleanupSignal = null) + { + var captured = new StringBuilder(); + var protocolLine = new StringBuilder(); + var protocolLineTooLong = false; + var limitExceeded = false; + var supervisorArmed = false; + var cleanupAuthenticated = false; + var buffer = new char[4096]; + while (true) + { + var count = await reader.ReadAsync( + buffer, + 0, + buffer.Length).ConfigureAwait(false); + if (count == 0) + { + break; + } + var remaining = MaximumCapturedOutputCharacters - + captured.Length; + if (remaining > 0) + { + captured.Append(buffer, 0, Math.Min(remaining, count)); + } + if (count > remaining) + { + limitExceeded = true; + outputLimitSignal.Set(); + } + if (supervisorNonce == null) + { + continue; + } + for (var index = 0; index < count; index++) + { + var character = buffer[index]; + if (character == '\n') + { + if (!protocolLineTooLong) + { + var line = protocolLine.ToString(); + if (line.EndsWith('\r')) + { + line = line[..^1]; + } + var armedRecord = string.Equals( + line, + SupervisorArmedMessage + " " + supervisorNonce, + StringComparison.Ordinal); + supervisorArmed |= armedRecord; + if (armedRecord) + { + supervisorArmedSignal?.TrySetResult(true); + } + var cleanupRecord = string.Equals( + line, + SupervisorCleanupMessage + " " + supervisorNonce, + StringComparison.Ordinal); + cleanupAuthenticated |= cleanupRecord; + if (cleanupRecord) + { + supervisorCleanupSignal?.TrySetResult(true); + } + } + protocolLine.Clear(); + protocolLineTooLong = false; + continue; + } + if (!protocolLineTooLong) + { + if (protocolLine.Length < MaximumProtocolLineCharacters) + { + protocolLine.Append(character); + } + else + { + protocolLine.Clear(); + protocolLineTooLong = true; + } + } + } + } + return new BoundedProcessOutput( + captured.ToString(), + limitExceeded, + supervisorArmed, + cleanupAuthenticated); + } + + internal bool RequireSupervisorCleanupReceipt( + bool cleanupAuthenticated, + bool authenticationRequired) + { + if (!authenticationRequired || cleanupAuthenticated) + { + return true; + } + HandleContainmentAuthenticationFailure( + "The SharpProof verifier containment supervisor exited " + + "without an authenticated cleanup receipt."); + return false; + } + + private void HandleContainmentAuthenticationFailure(string message) + { + if (ContainmentAuthenticationFailureOverride is { } handler) + { + handler(message); + return; + } + Environment.FailFast(message); + } + + internal static void RetainCleanupAnchorForTest(Process process) + { + RetainCleanupAnchor(process, -1, null, null, null, null, null); + } + + internal static void RetainCleanupAnchorForTest( + Process process, + System.Threading.Tasks.Task? standardOutput, + string? supervisorNonce, + Action? authenticationFailure) + { + var boundedOutput = standardOutput == null + ? null + : ConvertTestOutputAsync(standardOutput, supervisorNonce); + RetainCleanupAnchor( + process, + -1, + boundedOutput, + null, + supervisorNonce, + null, + authenticationFailure); + } + + private static async System.Threading.Tasks.Task + ConvertTestOutputAsync( + System.Threading.Tasks.Task output, + string? supervisorNonce) + { + var text = await output.ConfigureAwait(false); + return new BoundedProcessOutput( + text, + LimitExceeded: false, + SupervisorArmed: supervisorNonce != null && + HasSupervisorProtocolRecord( + text, + SupervisorArmedMessage, + supervisorNonce), + CleanupAuthenticated: supervisorNonce != null && + HasSupervisorProtocolRecord( + text, + SupervisorCleanupMessage, + supervisorNonce)); + } + + private static void RetainCleanupAnchor( + Process process, + int processGroupPidFd, + System.Threading.Tasks.Task? standardOutput, + System.Threading.Tasks.Task? standardError, + string? supervisorNonce = null, + System.Threading.Tasks.Task? supervisorCleanupSignal = null, + Action? authenticationFailure = null) + { + var token = Interlocked.Increment(ref _nextCleanupAnchor); + var anchor = new CleanupAnchor( + process, + processGroupPidFd, + standardOutput, + standardError, + supervisorNonce, + supervisorCleanupSignal, + authenticationFailure); + if (!RetainedCleanupAnchors.TryAdd(token, anchor)) + { + throw new InvalidOperationException( + "SharpProof could not retain its cleanup anchor."); + } + ObserveFault(anchor.StandardOutput); + ObserveFault(anchor.StandardError); + _ = ObserveCleanupAnchorAsync(token, anchor); + } + + private static async System.Threading.Tasks.Task + ObserveCleanupAnchorAsync(long token, CleanupAnchor anchor) + { + try + { + await anchor.Process.WaitForExitAsync().ConfigureAwait(false); + if (anchor.SupervisorNonce != null && + anchor.AuthenticationFailure != null) + { + var authenticated = anchor.StandardOutput != null && + await AwaitCleanupAuthenticationAfterSupervisorExit( + anchor.StandardOutput, + anchor.SupervisorCleanupSignal).ConfigureAwait(false); + if (!authenticated) + { + anchor.AuthenticationFailure( + "The retained SharpProof verifier containment " + + "supervisor exited without an authenticated " + + "cleanup receipt."); + } + } + } + catch (InvalidOperationException) { } + finally + { + if (anchor.ProcessGroupPidFd >= 0) + { + _ = NativeMethods.Close(anchor.ProcessGroupPidFd); + } + anchor.Process.Dispose(); + _ = RetainedCleanupAnchors.TryRemove(token, out _); + } + } + + private static async System.Threading.Tasks.Task + AwaitCleanupAuthenticationAfterSupervisorExit( + System.Threading.Tasks.Task output, + System.Threading.Tasks.Task? supervisorCleanupSignal) + { + var delay = System.Threading.Tasks.Task.Delay( + CleanupAuthenticationWaitMilliseconds); + var completed = supervisorCleanupSignal == null + ? await System.Threading.Tasks.Task.WhenAny(output, delay) + .ConfigureAwait(false) + : await System.Threading.Tasks.Task.WhenAny( + output, + supervisorCleanupSignal, + delay).ConfigureAwait(false); + if (supervisorCleanupSignal != null && + ReferenceEquals(completed, supervisorCleanupSignal)) + { + return true; + } + if (!ReferenceEquals(completed, output) || + !output.IsCompletedSuccessfully) + { + return false; + } + var outputResult = await output.ConfigureAwait(false); + return outputResult.SupervisorArmed && + outputResult.CleanupAuthenticated; + } + + private static void ObserveFault( + System.Threading.Tasks.Task? task) + { + if (task == null) + { + return; + } + _ = task.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private sealed record CleanupAnchor( + Process Process, + int ProcessGroupPidFd, + System.Threading.Tasks.Task? StandardOutput, + System.Threading.Tasks.Task? StandardError, + string? SupervisorNonce, + System.Threading.Tasks.Task? SupervisorCleanupSignal, + Action? AuthenticationFailure); + + internal sealed record BoundedProcessOutput( + string Text, + bool LimitExceeded, + bool SupervisorArmed, + bool CleanupAuthenticated); + + internal static int ComputeProcessTimeout( + int projectWallTimeMilliseconds, + int terminationGraceMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan( + projectWallTimeMilliseconds, + 1); + ArgumentOutOfRangeException.ThrowIfLessThan( + terminationGraceMilliseconds, + 1); + return checked( + projectWallTimeMilliseconds + + terminationGraceMilliseconds + + LauncherProcessReserveMilliseconds); + } + + private static int RemainingMilliseconds( + Stopwatch stopwatch, + int timeoutMilliseconds) + { + var remaining = timeoutMilliseconds - stopwatch.ElapsedMilliseconds; + return remaining <= 0 + ? 0 + : (int)Math.Min(remaining, int.MaxValue); + } + + private bool WaitForExitOrCancellation( + Process process, + int timeoutMilliseconds) + { + var stopwatch = Stopwatch.StartNew(); + while (!_cancellationSignal.IsSet && !_outputLimitSignal.IsSet) + { + var remaining = RemainingMilliseconds( + stopwatch, + timeoutMilliseconds); + if (remaining == 0) + { + return process.HasExited; + } + if (process.WaitForExit(Math.Min(remaining, 25))) + { + return true; + } + } + return process.HasExited; + } + + private static string ResolveProcessGroupLauncherRequired() + { + if (!File.Exists(ProcessGroupLauncher)) + { + throw new InvalidOperationException( + "SharpProof could not establish the verifier process boundary."); + } + return LinuxPathIdentity.Canonicalize(ProcessGroupLauncher); + } + + private bool HasWorkerLauncherBudgetArguments() + { + return Arguments.Any(static argument => + string.Equals( + argument.ItemSpec, + "--project-wall-ms", + StringComparison.Ordinal)); + } + + private static string ResolveSupervisorAssemblyRequired() + { + var assembly = typeof(RunVerifier).Assembly.Location; + if (!File.Exists(assembly) || + !File.Exists(Path.ChangeExtension( + assembly, + ".runtimeconfig.json"))) + { + throw new InvalidOperationException( + "SharpProof could not establish the verifier supervisor."); + } + return LinuxPathIdentity.Canonicalize(assembly); + } + + private static void TerminateBootstrapProcess(Process process) + { + try + { + process.Kill(entireProcessTree: true); + _ = process.WaitForExit(1000); + } + catch (InvalidOperationException) { } + catch (Win32Exception) { } + } + + private bool TryTerminate( + Process? process, + int processGroupId, + int terminationWaitMilliseconds) + { + if (TryTerminateOverride is { } terminateOverride) + { + return terminateOverride( + process, + processGroupId, + terminationWaitMilliseconds); + } + if (process == null) + { + return true; + } + lock (_synchronization) + { + if (!ReferenceEquals(_process, process) || + _processGroupId != processGroupId || + _processGroupPidFd < 0) + { + return true; + } + + var terminationStopwatch = Stopwatch.StartNew(); + if (_supervisorArmedSignal == null || + _supervisorOutputCompletion == null) + { + HandleContainmentAuthenticationFailure( + "SharpProof verifier supervisor readiness was not " + + "published before termination."); + return false; + } + var readiness = WaitForSupervisorReadiness( + _supervisorArmedSignal.Task, + _supervisorOutputCompletion, + () => process.HasExited, + Math.Min( + terminationWaitMilliseconds, + LauncherProcessReserveMilliseconds)); + if (readiness == SupervisorReadiness.ExitedBeforeArmed) + { + return process.ExitCode == 125; + } + if (readiness != SupervisorReadiness.Armed) + { + HandleContainmentAuthenticationFailure( + "SharpProof verifier supervisor readiness could not be " + + "authenticated before termination."); + return false; + } + + var terminateSent = SendPidFdSignal( + _processGroupPidFd, + SignalTerminate) == 0; + var boundedWait = Math.Min( + RemainingMilliseconds( + terminationStopwatch, + terminationWaitMilliseconds), + LauncherProcessReserveMilliseconds); + if (terminateSent && boundedWait > 0 && + process.WaitForExit(boundedWait)) + { + return process.ExitCode != 125; + } + if (terminateSent && !process.HasExited) + { + // The supervisor remains the subreaper while it retries its + // individually bounded cleanup batches. The caller retains + // the live supervisor as a cleanup anchor; killing it here + // would reparent session-escaping descendants beyond the + // containment boundary. + return true; + } + var cleanup = VerifierProcessSupervisor.StopDescendants( + processGroupId, + Math.Min(RemainingMilliseconds( + terminationStopwatch, + terminationWaitMilliseconds), + LauncherProcessReserveMilliseconds)); + if (SendPidFdSignal(_processGroupPidFd, SignalStop) == 0) + { + // The stopped session leader keeps this process-group identity + // live while the group-directed signal is delivered. + _ = NativeMethods.Kill(-processGroupId, SignalKill); + _ = SendPidFdSignal(_processGroupPidFd, SignalKill); + } + else if (Marshal.GetLastPInvokeError() != 3) + { + _ = SendPidFdSignal(_processGroupPidFd, SignalKill); + } + return cleanup.Complete; + } + } + + private int OpenPidFdRequired(int processId) + { + if (!OperatingSystem.IsLinux() || + RuntimeInformation.ProcessArchitecture != Architecture.X64) + { + throw new PlatformNotSupportedException( + "SharpProof verifier containment requires Linux amd64."); + } + var descriptor = OpenPidFdOverride?.Invoke(processId) ?? + checked((int)NativeMethods.SystemCall2( + PidFdOpenSystemCall, + processId, + 0)); + if (descriptor < 0) + { + throw new InvalidOperationException( + "SharpProof could not pin the verifier process boundary " + + $"(errno {Marshal.GetLastPInvokeError()})."); + } + return descriptor; + } + + private static int SendPidFdSignal(int descriptor, int signal) + { + return checked((int)NativeMethods.SystemCall4( + PidFdSendSignalSystemCall, + descriptor, + signal, + 0, + 0)); + } + internal void LogStandardError(string standardError) { using var reader = new StringReader(standardError); @@ -308,24 +1181,29 @@ internal static string ResolveDotNetHost(string executable) public void Cancel() { Process? process; + int processGroupId; lock (_synchronization) { _canceled = true; + _cancellationSignal.Set(); process = _process; + processGroupId = _processGroupId; } if (process == null) { return; } - try - { - if (!process.HasExited) - { - process.Kill(); - } - } - catch (InvalidOperationException) { } - catch (Win32Exception) { } + _ = TryTerminate( + process, + processGroupId, + LauncherProcessReserveMilliseconds); + } + + internal enum SupervisorReadiness + { + Armed, + ExitedBeforeArmed, + NotReady } private static string ResolveDotNetFromPath() @@ -377,4 +1255,31 @@ private static string ValidateDotNetInstallation(string candidate) return resolved; } + private static partial class NativeMethods + { + [LibraryImport("libc", EntryPoint = "close", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial int Close(int descriptor); + + [LibraryImport("libc", EntryPoint = "kill", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial int Kill(int processId, int signal); + + [LibraryImport("libc", EntryPoint = "syscall", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial nint SystemCall2( + nint number, + int argument1, + uint argument2); + + [LibraryImport("libc", EntryPoint = "syscall", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial nint SystemCall4( + nint number, + int argument1, + int argument2, + nint argument3, + uint argument4); + } + } diff --git a/SharpProof.BuildTasks/SharpProof.BuildTasks.csproj b/SharpProof.BuildTasks/SharpProof.BuildTasks.csproj index a0785d23c..f3bb0d90e 100644 --- a/SharpProof.BuildTasks/SharpProof.BuildTasks.csproj +++ b/SharpProof.BuildTasks/SharpProof.BuildTasks.csproj @@ -1,8 +1,10 @@ + Exe net9.0 true false + true + { + context.Cancel = true; + cancellation.Cancel(); + }); + using var interrupt = PosixSignalRegistration.Create( + PosixSignal.SIGINT, + context => + { + context.Cancel = true; + cancellation.Cancel(); + }); + try + { + var gate = Console.In.ReadLine(); + var nonce = gate != null && + gate.StartsWith(StartMessage + " ", + StringComparison.Ordinal) + ? gate[(StartMessage.Length + 1)..] + : string.Empty; + if (!IsValidNonce(nonce)) + { + return 125; + } + Console.Out.WriteLine(ArmedMessage + " " + nonce); + Console.Out.Flush(); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = command[0], + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.StartInfo.ArgumentList.Add( + typeof(VerifierProcessSupervisor).Assembly.Location); + process.StartInfo.ArgumentList.Add("--run-verifier-child"); + foreach (var argument in command) + { + process.StartInfo.ArgumentList.Add(argument); + } + if (!process.Start()) + { + WriteCleanupReceipt(nonce); + return 125; + } + + while (!process.WaitForExit(25) && + !cancellation.IsCancellationRequested) + { + } + var directExitCode = process.HasExited + ? process.ExitCode + : 143; + var descriptorReserves = cleanupDescriptorReserves; + cleanupDescriptorReserves = []; + var cleanup = StopDescendants( + Environment.ProcessId, + CleanupMilliseconds, + descriptorReserves: descriptorReserves); + var hadDescendants = cleanup.HadDescendants; + var retryDelayMilliseconds = 10; + while (!cleanup.Complete) + { + Thread.Sleep(retryDelayMilliseconds); + retryDelayMilliseconds = Math.Min( + retryDelayMilliseconds * 2, + 5000); + cleanup = StopDescendants( + Environment.ProcessId, + RetryCleanupMilliseconds); + hadDescendants |= cleanup.HadDescendants; + } + if (!process.HasExited && !process.WaitForExit(1000)) + { + return 125; + } + ReapOwnedDescendants(); + WriteCleanupReceipt(nonce); + return cancellation.IsCancellationRequested + ? 143 + : hadDescendants + ? 124 + : directExitCode; + } + finally + { + CloseDescriptors(cleanupDescriptorReserves); + } + } + + internal static bool IsValidNonce(string nonce) + { + return nonce.Length == 64 && nonce.All(static character => + character is >= '0' and <= '9' or >= 'A' and <= 'F'); + } + + private static void WriteCleanupReceipt(string nonce) + { + // The verifier may leave its final stdout line unterminated. Cleanup + // runs after every writer has exited, so this separator gives the + // authenticated record an unambiguous frame on the shared stream. + Console.Out.WriteLine(); + Console.Out.WriteLine(CleanupMessage + " " + nonce); + Console.Out.Flush(); + } + + internal static int RunWorker(string[] command) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = command[0], + UseShellExecute = false, + CreateNoWindow = true + } + }; + foreach (var argument in command.Skip(1)) + { + process.StartInfo.ArgumentList.Add(argument); + } + return process.Start() + ? WaitForWorkerExit(process) + : 125; + } + + private static int WaitForWorkerExit(Process process) + { + process.WaitForExit(); + return process.ExitCode; + } + + internal static DescendantStopResult StopDescendants( + int supervisorId, + int maximumMilliseconds, + Func? openPidFd = null, + Func? sendSignal = null, + IReadOnlyList? descriptorReserves = null) + { + CloseDescriptors(descriptorReserves ?? []); + var foundAny = false; + var deadline = Stopwatch.StartNew(); + while (deadline.ElapsedMilliseconds < maximumMilliseconds) + { + var discovered = DescendantProcessIds(supervisorId); + if (discovered.Count == 0) + { + return new DescendantStopResult( + foundAny, + Complete: true); + } + foundAny = true; + foreach (var processId in discovered) + { + var descriptor = openPidFd?.Invoke(processId) ?? + OpenPidFd(processId); + if (descriptor < 0) + { + if (Marshal.GetLastPInvokeError() != ProcessNotFound) + { + Thread.Sleep(1); + } + continue; + } + try + { + if (!IsDescendant( + processId, + supervisorId, + ReadProcessParents())) + { + continue; + } + if ((sendSignal?.Invoke( + descriptor, + SignalStop) ?? + SendPidFdSignal(descriptor, SignalStop)) != 0) + { + if (Marshal.GetLastPInvokeError() != ProcessNotFound) + { + Thread.Sleep(1); + } + continue; + } + if ((sendSignal?.Invoke( + descriptor, + SignalKill) ?? + SendPidFdSignal(descriptor, SignalKill)) != 0 && + Marshal.GetLastPInvokeError() != ProcessNotFound) + { + Thread.Sleep(1); + } + } + finally + { + _ = NativeMethods.Close(descriptor); + } + } + if (supervisorId == Environment.ProcessId) + { + ReapExitedChildren(); + } + Thread.Yield(); + } + return new DescendantStopResult( + foundAny, + Complete: DescendantProcessIds(supervisorId).Count == 0); + } + + internal readonly record struct DescendantStopResult( + bool HadDescendants, + bool Complete); + + private static void CloseDescriptors(IEnumerable descriptors) + { + foreach (var descriptor in descriptors.Where( + static descriptor => descriptor >= 0)) + { + _ = NativeMethods.Close(descriptor); + } + } + + private static void ReapOwnedDescendants() + { + var deadline = Stopwatch.StartNew(); + while (deadline.ElapsedMilliseconds < 1000) + { + ReapExitedChildren(); + if (DescendantProcessIds(Environment.ProcessId).Count == 0) + { + return; + } + Thread.Sleep(10); + } + } + + private static HashSet DescendantProcessIds(int supervisorId) + { + var parents = ReadProcessParents(); + return parents.Keys + .Where(processId => + IsDescendant(processId, supervisorId, parents)) + .ToHashSet(); + } + + private static bool IsDescendant( + int processId, + int supervisorId, + Dictionary parents) + { + var seen = new HashSet(); + for (var current = processId; + current > 1 && seen.Add(current) && + parents.TryGetValue(current, out var parent); + current = parent) + { + if (parent == supervisorId) + { + return true; + } + } + return false; + } + + private static Dictionary ReadProcessParents() + { + var result = new Dictionary(); + foreach (var directory in Directory.EnumerateDirectories("/proc")) + { + if (!int.TryParse( + Path.GetFileName(directory), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var processId)) + { + continue; + } + try + { + var stat = File.ReadAllText( + Path.Combine(directory, "stat")); + var close = stat.LastIndexOf(')'); + if (close < 0) + { + continue; + } + var fields = stat.AsSpan(close + 2) + .ToString() + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (fields.Length >= 2 && + int.TryParse( + fields[1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var parentId)) + { + result[processId] = parentId; + } + } + catch (DirectoryNotFoundException) { } + catch (FileNotFoundException) { } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + return result; + } + + private static int OpenPidFd(int processId) + { + return (int)NativeMethods.SystemCall2( + PidFdOpenSystemCall, + processId, + 0); + } + + private static int SendPidFdSignal(int descriptor, int signal) + { + return (int)NativeMethods.SystemCall4( + PidFdSendSignalSystemCall, + descriptor, + signal, + 0, + 0); + } + + private static void ReapExitedChildren() + { + while (NativeMethods.WaitForProcess( + -1, + out _, + 1) > 0) + { + } + } + + private static partial class NativeMethods + { + [LibraryImport("libc", EntryPoint = "close", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial int Close(int descriptor); + + [LibraryImport("libc", EntryPoint = "prctl", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial int ControlProcess( + int option, + nuint argument2, + nuint argument3, + nuint argument4, + nuint argument5); + + [LibraryImport("libc", EntryPoint = "syscall", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial nint SystemCall2( + nint number, + int argument1, + uint argument2); + + [LibraryImport("libc", EntryPoint = "syscall", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial nint SystemCall4( + nint number, + int argument1, + int argument2, + nint argument3, + uint argument4); + + [LibraryImport("libc", EntryPoint = "waitpid", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial int WaitForProcess( + int processId, + out int status, + int options); + } +} diff --git a/SharpProof.CompilerArtifact/CompilerArtifactModel.generated.cs b/SharpProof.CompilerArtifact/CompilerArtifactModel.generated.cs index 6305f211b..d00437df3 100644 --- a/SharpProof.CompilerArtifact/CompilerArtifactModel.generated.cs +++ b/SharpProof.CompilerArtifact/CompilerArtifactModel.generated.cs @@ -11,7 +11,7 @@ namespace SharpProof.CompilerArtifact; internal static class CompilerManifestArtifactVersions { internal const string Schema = "SharpProof.CompilerManifest"; - internal const int Current = 14; + internal const int Current = 15; } internal static class CompilerRelationalSummaryVersions diff --git a/SharpProof.CompilerArtifact/CompilerArtifactModel.schema.json b/SharpProof.CompilerArtifact/CompilerArtifactModel.schema.json index 79bc88268..3ac144ec2 100644 --- a/SharpProof.CompilerArtifact/CompilerArtifactModel.schema.json +++ b/SharpProof.CompilerArtifact/CompilerArtifactModel.schema.json @@ -4,7 +4,7 @@ "jsonNamingPolicy": "camelCase", "artifactEnvelope": { "schema": "SharpProof.CompilerManifest", - "version": 14 + "version": 15 }, "declarations": [ { @@ -21,7 +21,7 @@ "accessibility": "internal", "name": "Current", "type": "int", - "value": 14 + "value": 15 } ] }, diff --git a/SharpProof.CompilerArtifact/CompilerFeatureScopeFingerprint.cs b/SharpProof.CompilerArtifact/CompilerFeatureScopeFingerprint.cs index 88a0f2840..2c4e9629f 100644 --- a/SharpProof.CompilerArtifact/CompilerFeatureScopeFingerprint.cs +++ b/SharpProof.CompilerArtifact/CompilerFeatureScopeFingerprint.cs @@ -5,7 +5,7 @@ namespace SharpProof.CompilerArtifact; internal static class CompilerFeatureScopeFingerprint { private const string Domain = "SharpProof.CompilerFeatureScope"; - private const int Version = 1; + private const int Version = 2; internal static string ComputeSha256(CompilerManifestArtifact artifact) { @@ -40,6 +40,8 @@ private static void AddCallable( callable.CallableId, callable.FailureReason, callable.Graph != null); + AddJson(hash, callable.Graph); + AddJson(hash, callable.Body); var clauses = callable.Clauses; hash.Add(clauses?.Length ?? -1); @@ -72,10 +74,14 @@ private static void AddCallable( hash.Add( variable.Role, variable.Ordinal, + variable.Variable, + variable.CurrentStateVariable, + variable.SourceOrdinal, variable.Minimum.HasValue, variable.Minimum ?? 0L, variable.Maximum.HasValue, variable.Maximum ?? 0L, + variable.ScalarDomain, variable.ModelLabel); } diff --git a/SharpProof.CompilerArtifact/CompilerLoweredArtifact.cs b/SharpProof.CompilerArtifact/CompilerLoweredArtifact.cs index f00cfb84b..17d50ca8f 100644 --- a/SharpProof.CompilerArtifact/CompilerLoweredArtifact.cs +++ b/SharpProof.CompilerArtifact/CompilerLoweredArtifact.cs @@ -117,7 +117,7 @@ variable.CurrentStateVariable is { } current && .Concat(body.SummaryCalls.Values.Select(static call => ( call.Instruction, call.CallIdentity))) - .OrderBy(static call => call.Instruction.Value) + .OrderBy(call => encoded.InstructionIndices[call.Instruction]) .ToArray(); foreach (var call in allCalls) { @@ -525,8 +525,8 @@ item.CurrentStateVariable is { } currentState && CompilerVariableRole.PreState => item.Ordinal == -1 && item.CurrentStateVariable.HasValue && current.Contains(item.CurrentStateVariable.Value) && item.ModelLabel.StartsWith("pre:", StringComparison.Ordinal) && - int.TryParse(item.ModelLabel.Substring(4), NumberStyles.None, CultureInfo.InvariantCulture, out var ordinal) && - ordinal >= 0, + int.TryParse(item.ModelLabel.Substring(4), NumberStyles.None, + CultureInfo.InvariantCulture, out var ordinal) && ordinal >= 0, _ => false }; if (!shape || item.ModelLabel != label || @@ -637,8 +637,9 @@ private static bool IsPrimitiveInterval(CompilerIntegerInterval value) var summaries = ImmutableDictionary.CreateBuilder(); var calls = graph.Instructions.OfType().ToArray(); var summaryVariables = new HashSet(); - var portableCalls = portable.Blocks.SelectMany(static block => block.Instructions).Where( - static instruction => instruction.Kind == IrInstructionKind.Call).ToArray(); + var portableInstructions = portable.Blocks + .SelectMany(static block => block.Instructions) + .ToArray(); if (row.Calls.Length != calls.Length || row.SpecCalls.Length + row.SummaryCalls.Length != calls.Length) { @@ -651,10 +652,22 @@ private static bool IsPrimitiveInterval(CompilerIntegerInterval value) { var identity = row.Calls[index] ?? throw new InvalidDataException("A lowered call identity is invalid."); - var call = calls[index]; - if (At(graph.Instructions, identity.Instruction, "instruction").Id != call.Id || + var instruction = At( + graph.Instructions, + identity.Instruction, + "instruction"); + var portableInstruction = At( + portableInstructions, + identity.Instruction, + "instruction"); + if (instruction is not IrCallInstruction call || + portableInstruction.Kind != IrInstructionKind.Call || string.IsNullOrWhiteSpace(identity.Identity) || - At(portable.Members, portableCalls[index].B, "member").DocumentationCommentId != identity.Identity) + At( + portable.Members, + portableInstruction.B, + "member").DocumentationCommentId != identity.Identity || + identities.ContainsKey(call.Id)) { throw new InvalidDataException("A lowered call descriptor is invalid."); } diff --git a/SharpProof.CompilerArtifact/CompilerManifestArtifact.cs b/SharpProof.CompilerArtifact/CompilerManifestArtifact.cs index 5262c9d80..e6693aee3 100644 --- a/SharpProof.CompilerArtifact/CompilerManifestArtifact.cs +++ b/SharpProof.CompilerArtifact/CompilerManifestArtifact.cs @@ -867,19 +867,35 @@ private static FileStream Open( out int length, int maximumBytes) { + // Inspect the directory entry before opening it. On Unix, opening a + // FIFO for reading blocks until a writer arrives, so the regular-file + // and byte-limit checks must happen before FileStream opens the path. + var fileInfo = new FileInfo(path); + var fileLength = fileInfo.Length; + if (fileLength <= 0) + { + throw new InvalidDataException( + "The compiler manifest must be a nonempty regular file."); + } + if (fileLength > maximumBytes) + { + throw new InvalidDataException( + "The compiler manifest exceeds the byte limit."); + } + var stream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read); - if (stream.Length > maximumBytes) + if (stream.Length != fileLength) { stream.Dispose(); throw new InvalidDataException( - "The compiler manifest exceeds the byte limit."); + "The compiler manifest changed while it was opened."); } - length = checked((int)stream.Length); + length = checked((int)fileLength); return stream; } diff --git a/SharpProof.CompilerArtifact/PortableIrGraphCodec.cs b/SharpProof.CompilerArtifact/PortableIrGraphCodec.cs index 65f4d7ef8..0f6354656 100644 --- a/SharpProof.CompilerArtifact/PortableIrGraphCodec.cs +++ b/SharpProof.CompilerArtifact/PortableIrGraphCodec.cs @@ -230,6 +230,7 @@ private sealed partial class Encoder private readonly EncodingTable _members; private readonly EncodingTable _operations; private readonly EncodingTable _terms; + private readonly Dictionary _termDepths = []; private IrBasicBlock[] _blocks = []; private Dictionary _blockIndices = []; private Dictionary _instructionIndices = []; @@ -364,6 +365,22 @@ private PortableIrInstruction InstructionRow( private int TypeIndex(IrTypeId id) { + var depth = 0; + for (var current = id; ;) + { + depth++; + if (depth > MaximumGraphDepth) + { + throw Bad("Portable IR type depth exceeds the supported limit."); + } + var info = _factory.GetTypeInfo(current); + if (info.Kind != IrTypeKind.Sequence || + !info.ElementType.HasValue) + { + break; + } + current = info.ElementType.Value; + } return _types.Add(id); } @@ -385,6 +402,14 @@ private int OperationIndex(OperationId id) private int TermIndex(IrTerm term) { _factory.EnsureTerm(term, nameof(term)); + if (_terms.Indices.TryGetValue(term.Id, out var existing)) + { + return existing; + } + if (IrTermAnalysis.GetDepth(term, _termDepths) > MaximumGraphDepth) + { + throw Bad("Portable IR term depth exceeds the supported limit."); + } return _terms.Add(term.Id); } diff --git a/SharpProof.ContractForGenerator.Test/ContractForValidatorGeneratorTests.cs b/SharpProof.ContractForGenerator.Test/ContractForValidatorGeneratorTests.cs index d292f4d20..d155aed52 100644 --- a/SharpProof.ContractForGenerator.Test/ContractForValidatorGeneratorTests.cs +++ b/SharpProof.ContractForGenerator.Test/ContractForValidatorGeneratorTests.cs @@ -1820,7 +1820,7 @@ public static void Ghost(ITarget receiver) { } [TestCase( "sharpproof_profile", "advisory", - "build_property.SharpProofProfile", "off", true)] + "build_property.SharpProofProfile", "off", false)] [TestCase( "sharpproof_profile", "off", "build_property.SharpProofProfile", "advisory", false)] @@ -1832,7 +1832,7 @@ public static void Ghost(ITarget receiver) { } "build_property.SharpProofProfile", " OFF ", false)] [TestCase( "build_property.sharpproof_profile", " advisory ", - "build_property.SharpProofProfile", "off", true)] + "build_property.SharpProofProfile", "off", false)] [TestCase( "sharpproof_profile", " AdViSoRy ", "sharpproof_features", "contracts", true)] diff --git a/SharpProof.Contracts.Test/ConstructedGenericContractTests.cs b/SharpProof.Contracts.Test/ConstructedGenericContractTests.cs index 4b9c22dc1..4e7c2dc49 100644 --- a/SharpProof.Contracts.Test/ConstructedGenericContractTests.cs +++ b/SharpProof.Contracts.Test/ConstructedGenericContractTests.cs @@ -132,9 +132,9 @@ public static string[] Call( } [Test] - public void PointerTargetTypesAreRecursivelySpecialized() + public void PointerContractExpressionsAbstain() { - AssertBinds( + AssertFailure( """ using SharpProof.Attributes; @@ -158,13 +158,13 @@ public static void Call( int* value) => buffer.Read(value); } """, - expectedClauses: 1); + ContractBindingFailure.UnsupportedExpression); } [Test] - public void FunctionPointerTargetTypesAreRecursivelySpecialized() + public void FunctionPointerContractExpressionsAbstain() { - AssertBinds( + AssertFailure( """ using SharpProof.Attributes; @@ -189,13 +189,13 @@ public static unsafe class Caller { delegate* value) => transformer.Map(value); } """, - expectedClauses: 1); + ContractBindingFailure.UnsupportedExpression); } [Test] - public void FunctionPointerRefReadonlyModifiersSurviveConstruction() + public void FunctionPointerRefReadonlyContractExpressionsAbstain() { - AssertBinds( + AssertFailure( """ using SharpProof.Attributes; @@ -220,9 +220,319 @@ public static void Call( reader.Read(callback); } """, + ContractBindingFailure.UnsupportedExpression); + } + + [Test] + public void ConstructedPartialGenericMethodUsesItsImplementationBody() + { + AssertBinds( + """ + using SharpProof.Attributes; + + public static partial class Target where T : class { + public static partial T Read(T value); + public static partial T Read(T value) { + Contract.Requires(value != null); + return value; + } + } + + public static class Caller { + public static string Call(string value) => + Target.Read(value); + } + """, + expectedClauses: 1); + } + + [Test] + public void ConstructedPartialGenericCompanionUsesItsImplementationBody() + { + AssertBinds( + """ + using SharpProof.Attributes; + + public interface ITarget where T : class { + T Read(T value); + } + + [ContractFor(typeof(ITarget<>))] + public static partial class TargetContracts where T : class { + public static partial T Read( + ITarget receiver, + T value); + public static partial T Read( + ITarget receiver, + T value) { + Contract.Requires(value != null); + return value; + } + } + + public static class Caller { + public static string Call( + ITarget target, + string value) => target.Read(value); + } + """, expectedClauses: 1); } + [Test] + public void ConstructedPartialMethodTypeParametersAreSpecialized() + { + AssertBinds( + """ + using SharpProof.Attributes; + + public static partial class Target { + public static partial T Read(T value) where T : class; + public static partial T Read(T value) where T : class { + Contract.Requires(value != null); + Contract.Ensures(Contract.Result() != null); + return value; + } + } + + public static class Caller { + public static string Call(string value) => + Target.Read(value); + } + """, + expectedClauses: 2); + } + + [Test] + public void NotNullTypeParametersAreAdmittedAfterSpecialization() + { + AssertBinds( + """ + using SharpProof.Attributes; + + public static class Target { + public static T[] Read(T[] value) where T : notnull { + Contract.Requires(value.Length >= 0); + return value; + } + } + + public static class Caller { + public static string[] Call(string[] value) => + Target.Read(value); + } + """, + expectedClauses: 1); + } + + [Test] + public void BindingCachePreservesConstructedMethodNullability() + { + var compilation = CreateCompilation( + """ + using SharpProof.Attributes; + + public static class Target { + public static T Echo(T value) { + Contract.Requires(true); + return value; + } + } + + public static class Caller { + public static void Call() { + _ = Target.Echo(""); + _ = Target.Echo(null); + } + } + """); + var targets = GetConstructedTargets(compilation, "Target.Echo"); + var binder = new ContractBinder(compilation, new IrFactory()); + + var first = binder.Bind(targets[0]); + var second = binder.Bind(targets[1]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first.IsSuccess, Is.True, first.Failure.ToString()); + Assert.That(second.IsSuccess, Is.True, second.Failure.ToString()); + Assert.That( + first.Contracts!.Target.TypeArguments[0].NullableAnnotation, + Is.EqualTo(NullableAnnotation.NotAnnotated)); + Assert.That( + second.Contracts!.Target.TypeArguments[0].NullableAnnotation, + Is.EqualTo(NullableAnnotation.Annotated)); + } + } + + [Test] + public void ClauseInventoryCachePreservesConstructedMethodNullability() + { + var compilation = CreateCompilation( + """ + using SharpProof.Attributes; + + public static class Target { + public static T Echo(T value) { + Contract.Requires(true); + return value; + } + } + + public static class Caller { + public static void Call() { + _ = Target.Echo(""); + _ = Target.Echo(null); + } + } + """); + var targets = GetConstructedTargets(compilation, "Target.Echo"); + var binder = new ContractBinder(compilation, new IrFactory()); + + var first = binder.GetClauseInventory(targets[0]); + var second = binder.GetClauseInventory(targets[1]); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + first.Callable.TypeArguments[0].NullableAnnotation, + Is.EqualTo(NullableAnnotation.NotAnnotated)); + Assert.That( + second.Callable.TypeArguments[0].NullableAnnotation, + Is.EqualTo(NullableAnnotation.Annotated)); + } + } + + [Test] + public void SharedCompanionResolutionCachePreservesMethodNullability() + { + var compilation = CreateCompilation( + """ + using SharpProof.Attributes; + + public interface ITarget { + T Echo(T value); + } + + [ContractFor(typeof(ITarget))] + public static class TargetContracts { + public static T Echo(ITarget receiver, T value) { + Contract.Requires(true); + return value; + } + } + + public static class Caller { + public static void Call(ITarget target) { + _ = target.Echo(""); + _ = target.Echo(null); + } + } + """); + var targets = GetConstructedTargets(compilation, "target.Echo"); + + var first = new ContractBinder(compilation, new IrFactory()) + .Bind(targets[0]); + var second = new ContractBinder(compilation, new IrFactory()) + .Bind(targets[1]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first.IsSuccess, Is.True, first.Failure.ToString()); + Assert.That(second.IsSuccess, Is.True, second.Failure.ToString()); + Assert.That( + first.Contracts!.Source.TypeArguments[0].NullableAnnotation, + Is.EqualTo(NullableAnnotation.NotAnnotated)); + Assert.That( + second.Contracts!.Source.TypeArguments[0].NullableAnnotation, + Is.EqualTo(NullableAnnotation.Annotated)); + } + } + + [Test] + public void ConstructedPartialCompanionMethodTypeParametersAreSpecialized() + { + AssertBinds( + """ + using SharpProof.Attributes; + + public interface ITarget { + T Read(T value) where T : class; + } + + [ContractFor(typeof(ITarget))] + public static partial class TargetContracts { + public static partial T Read( + ITarget receiver, + T value) where T : class; + public static partial T Read( + ITarget receiver, + T value) where T : class { + Contract.Requires(value != null); + Contract.Ensures(Contract.Result() != null); + return value; + } + } + + public static class Caller { + public static string Call(ITarget target, string value) => + target.Read(value); + } + """, + expectedClauses: 2); + } + + [Test] + public void ConstructedPartialRejectsResultInsideRequires() + { + AssertFailure( + """ + using SharpProof.Attributes; + + public static partial class Target where T : class { + public static partial T Read(T value); + public static partial T Read(T value) { + Contract.Requires(Contract.Result() != null); + return value; + } + } + + public static class Caller { + public static string Call(string value) => + Target.Read(value); + } + """, + ContractBindingFailure.ResultOutsideEnsures); + } + + [Test] + public void ConstructedPartialCompanionRejectsResultInsideRequires() + { + AssertFailure( + """ + using SharpProof.Attributes; + + public interface ITarget where T : class { + T Read(T value); + } + + [ContractFor(typeof(ITarget<>))] + public static partial class TargetContracts where T : class { + public static partial T Read(ITarget receiver, T value); + public static partial T Read(ITarget receiver, T value) { + Contract.Requires(Contract.Result() != null); + return value; + } + } + + public static class Caller { + public static string Call(ITarget target, string value) => + target.Read(value); + } + """, + ContractBindingFailure.ResultOutsideEnsures); + } + private static void AssertBinds(string source, int expectedClauses) { var compilation = CreateCompilation(source); @@ -245,6 +555,43 @@ .Symbol as IMethodSymbol ?? Has.Length.EqualTo(expectedClauses)); } + private static void AssertFailure( + string source, + ContractBindingFailure expectedFailure) + { + var compilation = CreateCompilation(source); + var tree = compilation.SyntaxTrees.Single(); + var invocation = tree.GetRoot() + .DescendantNodes() + .OfType() + .Last(); + var target = compilation.GetSemanticModel(tree) + .GetSymbolInfo(invocation) + .Symbol as IMethodSymbol ?? + throw new InvalidOperationException(invocation.ToString()); + + var result = new ContractBinder(compilation, new IrFactory()) + .Bind(target); + + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.Failure, Is.EqualTo(expectedFailure)); + } + + private static IMethodSymbol[] GetConstructedTargets( + CSharpCompilation compilation, + string expressionPrefix) + { + var tree = compilation.SyntaxTrees.Single(); + var semanticModel = compilation.GetSemanticModel(tree); + return [.. tree.GetRoot() + .DescendantNodes() + .OfType() + .Where(invocation => invocation.Expression.ToString() + .StartsWith(expressionPrefix, StringComparison.Ordinal)) + .Select(invocation => semanticModel.GetSymbolInfo(invocation).Symbol) + .OfType()]; + } + private static CSharpCompilation CreateCompilation(string source) { var syntaxTree = CSharpSyntaxTree.ParseText( diff --git a/SharpProof.Contracts.Test/ContractBinderTests.cs b/SharpProof.Contracts.Test/ContractBinderTests.cs index 3e4d5cc29..54ad50b00 100644 --- a/SharpProof.Contracts.Test/ContractBinderTests.cs +++ b/SharpProof.Contracts.Test/ContractBinderTests.cs @@ -287,6 +287,60 @@ public static class TargetContracts { Is.EqualTo(BoundContractEvidence.ClosedAttribute)); } + [Test] + public void StaticConstructorDirectContractsBind() + { + const string source = + """ + using SharpProof.Attributes; + public sealed class Target { + static Target() { + Contract.Ensures(true); + } + } + """; + using var subject = ContractSubject.Create(source); + + var result = subject.BindMethodKind( + "Target", + MethodKind.StaticConstructor); + + Assert.That(result.IsSuccess, Is.True, result.Failure.ToString()); + Assert.That(result.Contracts!.Clauses, Has.Length.EqualTo(1)); + Assert.That( + result.Contracts.Clauses[0].Kind, + Is.EqualTo(BoundContractKind.Ensures)); + } + + [Test] + public void ExplicitInterfaceImplementationDirectContractsBind() + { + const string source = + """ + using SharpProof.Attributes; + public interface ITarget { + int Read(int value); + } + public sealed class Target : ITarget { + int ITarget.Read(int value) { + Contract.Requires(value > 0); + return value; + } + } + """; + using var subject = ContractSubject.Create(source); + + var result = subject.BindMethodKind( + "Target", + MethodKind.ExplicitInterfaceImplementation); + + Assert.That(result.IsSuccess, Is.True, result.Failure.ToString()); + Assert.That(result.Contracts!.Clauses, Has.Length.EqualTo(1)); + Assert.That( + result.Contracts.Clauses[0].Kind, + Is.EqualTo(BoundContractKind.Requires)); + } + [Test] public void NestedCallableClausesDoNotPoisonContainingContracts() { @@ -1550,6 +1604,18 @@ internal ContractBindingResult BindConstructor(string typeName) return _binder.Bind(constructor); } + internal ContractBindingResult BindMethodKind( + string typeName, + MethodKind methodKind) + { + var type = Compilation.GetTypeByMetadataName(typeName) ?? + throw new InvalidOperationException(typeName); + var method = type.GetMembers() + .OfType() + .Single(candidate => candidate.MethodKind == methodKind); + return _binder.Bind(method); + } + internal ContractBindingResult BindCallRequires( string callerTypeName, string callerMethodName, diff --git a/SharpProof.Contracts.Test/PartialMethodContractTests.cs b/SharpProof.Contracts.Test/PartialMethodContractTests.cs index ebbef9fd3..4c5e9c077 100644 --- a/SharpProof.Contracts.Test/PartialMethodContractTests.cs +++ b/SharpProof.Contracts.Test/PartialMethodContractTests.cs @@ -77,6 +77,108 @@ public static partial long Identity(long value) { Assert.That(inventory.Clauses[0].IsValid, Is.True); } + [TestCase(MethodKind.PropertyGet)] + [TestCase(MethodKind.PropertySet)] + public void PartialPropertyAccessorsUseImplementationBodies( + MethodKind accessorKind) + { + var compilation = CreateCompilation( + ( + "Definition.cs", + """ + public partial class Subject { + public partial int Value { get; set; } + } + """), + ( + "Implementation.cs", + """ + using SharpProof.Attributes; + public partial class Subject { + public partial int Value { + get { + Contract.Requires(true); + return 1; + } + set { + Contract.Requires(value >= 0); + } + } + } + """)); + var property = compilation.GetTypeByMetadataName("Subject")! + .GetMembers("Value") + .OfType() + .Single(static property => + property.PartialImplementationPart != null); + var definitionAccessor = accessorKind == MethodKind.PropertyGet + ? property.GetMethod! + : property.SetMethod!; + + var inventory = new ContractClauseInventoryBuilder(compilation) + .Create(definitionAccessor); + + using (Assert.EnterMultipleScope()) + { + Assert.That(inventory.ImplementationBody, Is.Not.Null); + Assert.That(inventory.Clauses, Has.Length.EqualTo(1)); + Assert.That(inventory.Clauses[0].IsValid, Is.True); + } + } + + [TestCase(MethodKind.PropertyGet)] + [TestCase(MethodKind.PropertySet)] + public void ConstructedPartialPropertyAccessorsKeepSpecialization( + MethodKind accessorKind) + { + var compilation = CreateCompilation( + ( + "Definition.cs", + """ + public partial class Subject where T : class { + public partial T Value { get; set; } + } + """), + ( + "Implementation.cs", + """ + using SharpProof.Attributes; + public partial class Subject where T : class { + public partial T Value { + get { + Contract.Requires(true); + return null!; + } + set { + Contract.Requires(value != null); + } + } + } + """)); + var generic = compilation.GetTypeByMetadataName("Subject`1")!; + var constructed = generic.Construct( + compilation.GetSpecialType(SpecialType.System_String)); + var property = constructed.GetMembers("Value") + .OfType() + .Single(); + var accessor = accessorKind == MethodKind.PropertyGet + ? property.GetMethod! + : property.SetMethod!; + + var result = new ContractBinder(compilation, new IrFactory()) + .BindRequires(accessor); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.IsSuccess, Is.True, result.Failure.ToString()); + Assert.That(result.Contracts!.Clauses, Has.Length.EqualTo(1)); + Assert.That( + result.Contracts.Source.ContainingType.TypeArguments[0] + .SpecialType, + Is.EqualTo(SpecialType.System_String)); + } + } + [Test] public void CompanionContractsBindFromTheImplementationAcrossSyntaxTrees() { @@ -166,7 +268,7 @@ private static CSharpCompilation CreateCompilation( params (string FileName, string Source)[] sources) { var parseOptions = new CSharpParseOptions( - LanguageVersion.CSharp12, + LanguageVersion.Preview, preprocessorSymbols: ["SHARPPROOF_CONTRACTS"]); var compilation = CSharpCompilation.Create( "PartialContracts_" + Guid.NewGuid().ToString("N"), diff --git a/SharpProof.Contracts/ContractBinder.cs b/SharpProof.Contracts/ContractBinder.cs index dbc5e8ef7..b85ba4ce9 100644 --- a/SharpProof.Contracts/ContractBinder.cs +++ b/SharpProof.Contracts/ContractBinder.cs @@ -14,9 +14,9 @@ public sealed class ContractBinder( private readonly ContractClauseInventoryBuilder _clauseInventory = clauseInventory ?? ContractClauseInventoryBuilder.ForCompilation(compilation); private readonly ConcurrentDictionary _bindings = - new(SymbolEqualityComparer.Default); + new(SymbolEqualityComparer.IncludeNullability); private readonly ConcurrentDictionary _requiresBindings = - new(SymbolEqualityComparer.Default); + new(SymbolEqualityComparer.IncludeNullability); private readonly EffectiveContractSourceResolver _contractSources = clauseInventory == null ? EffectiveContractSourceResolver.ForCompilation(compilation) @@ -74,10 +74,12 @@ private ContractBindingResult BindCore( if (target.MethodKind is not ( MethodKind.Ordinary or MethodKind.Constructor or + MethodKind.StaticConstructor or MethodKind.PropertyGet or MethodKind.PropertySet or MethodKind.EventAdd or - MethodKind.EventRemove)) + MethodKind.EventRemove or + MethodKind.ExplicitInterfaceImplementation)) { return ContractBindingResult.Fail(ContractBindingFailure.UnsupportedTarget); } diff --git a/SharpProof.Contracts/ContractCanonicalization.cs b/SharpProof.Contracts/ContractCanonicalization.cs index cce6348c0..75f7d61f3 100644 --- a/SharpProof.Contracts/ContractCanonicalization.cs +++ b/SharpProof.Contracts/ContractCanonicalization.cs @@ -35,6 +35,24 @@ internal sealed class ContractCanonicalization( source.OriginalDefinition.Parameters[index].Type, source.Parameters[index].Type); } + var partialCounterpart = + source.OriginalDefinition.PartialImplementationPart ?? + source.OriginalDefinition.PartialDefinitionPart; + if (partialCounterpart != null) + { + AddParameters( + partialCounterpart.TypeParameters, + source.TypeArguments); + AddSignatureType( + partialCounterpart.ReturnType, + source.ReturnType); + for (var index = 0; index < source.Parameters.Length; index++) + { + AddSignatureType( + partialCounterpart.Parameters[index].Type, + source.Parameters[index].Type); + } + } return Specialize; @@ -295,9 +313,9 @@ internal ContractCanonicalVariables CreateVariables( IrVarId? canonicalVariable = null; if (binding.Symbol is IParameterSymbol parameter && parameter.ContainingSymbol is IMethodSymbol owner && - SymbolEqualityComparer.Default.Equals( - owner.OriginalDefinition, - source.OriginalDefinition)) + ContractClauseInventoryBuilder.HaveSameDefinition( + owner, + source)) { var ordinal = parameter.Ordinal - (usesCompanion && canonical.Receiver.HasValue ? 1 : 0); diff --git a/SharpProof.Contracts/ContractClauseInventoryBuilder.cs b/SharpProof.Contracts/ContractClauseInventoryBuilder.cs index 3a141f446..9258a02b8 100644 --- a/SharpProof.Contracts/ContractClauseInventoryBuilder.cs +++ b/SharpProof.Contracts/ContractClauseInventoryBuilder.cs @@ -12,7 +12,7 @@ public sealed class ContractClauseInventoryBuilder(Compilation compilation) .Select(static (tree, ordinal) => (tree, ordinal)) .ToDictionary(static item => item.tree, static item => item.ordinal); private readonly ConcurrentDictionary _cache = - new(SymbolEqualityComparer.Default); + new(SymbolEqualityComparer.IncludeNullability); internal static ContractClauseInventoryBuilder ForCompilation(Compilation compilation) { @@ -124,8 +124,7 @@ private ContractClausePlacement Classify( { var enclosing = model.GetEnclosingSymbol(invocation.Syntax.SpanStart); if (enclosing is not IMethodSymbol method || - !SymbolEqualityComparer.Default.Equals( - callable.OriginalDefinition, method.OriginalDefinition)) + !HaveSameDefinition(callable, method)) { return ContractClausePlacement.NestedCallable; } @@ -246,12 +245,47 @@ private static ImmutableArray GetBodies( IMethodSymbol callable, IOperation? implementationBody) { - return implementationBody != null - ? [GetBody(implementationBody.Syntax) ?? implementationBody.Syntax] - : [.. callable.DeclaringSyntaxReferences - .Select(static reference => GetBody(reference.GetSyntax())) - .Where(static body => body != null) - .Select(static body => body!)]; + if (implementationBody != null) + { + return [GetBody(implementationBody.Syntax) ?? implementationBody.Syntax]; + } + + var bodies = GetDeclaredBodies(callable); + if (!bodies.IsDefaultOrEmpty || + GetPartialImplementation(callable) is not { } implementation) + { + return bodies; + } + + return GetDeclaredBodies(implementation); + } + + private static IMethodSymbol? GetPartialImplementation( + IMethodSymbol callable) + { + if (callable.OriginalDefinition.PartialImplementationPart is + { } methodImplementation) + { + return methodImplementation; + } + if (callable.OriginalDefinition.AssociatedSymbol is + IPropertySymbol property && + property.PartialImplementationPart is { } propertyImplementation) + { + return callable.MethodKind == MethodKind.PropertyGet + ? propertyImplementation.GetMethod + : propertyImplementation.SetMethod; + } + return null; + } + + private static ImmutableArray GetDeclaredBodies( + IMethodSymbol callable) + { + return [.. callable.DeclaringSyntaxReferences + .Select(static reference => GetBody(reference.GetSyntax())) + .Where(static body => body != null) + .Select(static body => body!)]; } internal static SyntaxNode? GetBody(SyntaxNode syntax) @@ -292,6 +326,35 @@ private static bool HasSameSite(SyntaxNode left, SyntaxNode right) internal static IMethodSymbol NormalizeCallable(IMethodSymbol method) { + if (method.AssociatedSymbol is IPropertySymbol property && + property.PartialImplementationPart is { } implementation) + { + return method.MethodKind == MethodKind.PropertyGet + ? implementation.GetMethod ?? method + : implementation.SetMethod ?? method; + } return method.PartialImplementationPart ?? method; } + + internal static bool HaveSameDefinition( + IMethodSymbol left, + IMethodSymbol right) + { + return SymbolEqualityComparer.Default.Equals( + GetPartialDefinition(left), + GetPartialDefinition(right)); + } + + private static IMethodSymbol GetPartialDefinition(IMethodSymbol method) + { + var definition = method.OriginalDefinition; + if (definition.AssociatedSymbol is IPropertySymbol property && + property.PartialDefinitionPart is { } partialDefinition) + { + return definition.MethodKind == MethodKind.PropertyGet + ? partialDefinition.GetMethod ?? definition + : partialDefinition.SetMethod ?? definition; + } + return definition.PartialDefinitionPart ?? definition; + } } diff --git a/SharpProof.Contracts/ContractIntrinsicValidator.cs b/SharpProof.Contracts/ContractIntrinsicValidator.cs index 4f71f63a1..bc20c4437 100644 --- a/SharpProof.Contracts/ContractIntrinsicValidator.cs +++ b/SharpProof.Contracts/ContractIntrinsicValidator.cs @@ -110,8 +110,8 @@ private IntrinsicContext GetContext(IOperation operation, IMethodSymbol owner) private static bool SameCallable(IMethodSymbol? left, IMethodSymbol right) { - return left != null && SymbolEqualityComparer.Default.Equals(left.OriginalDefinition, - right.OriginalDefinition); + return left != null && + ContractClauseInventoryBuilder.HaveSameDefinition(left, right); } private readonly struct IntrinsicContext(BoundContractKind? clause, bool insideOld) diff --git a/SharpProof.Contracts/EffectiveContractSourceResolver.cs b/SharpProof.Contracts/EffectiveContractSourceResolver.cs index 854c63d40..63e6db44f 100644 --- a/SharpProof.Contracts/EffectiveContractSourceResolver.cs +++ b/SharpProof.Contracts/EffectiveContractSourceResolver.cs @@ -22,7 +22,7 @@ private static readonly ConditionalWeakTable< private readonly ImmutableArray _companions; private readonly ConcurrentDictionary< IMethodSymbol, EffectiveContractSourceResolution> _cache = - new(SymbolEqualityComparer.Default); + new(SymbolEqualityComparer.IncludeNullability); internal EffectiveContractSourceResolver( Compilation compilation, diff --git a/SharpProof.Effects.Test/EffectAnalysisTests.cs b/SharpProof.Effects.Test/EffectAnalysisTests.cs index ae38dfe31..adaf6b52a 100644 --- a/SharpProof.Effects.Test/EffectAnalysisTests.cs +++ b/SharpProof.Effects.Test/EffectAnalysisTests.cs @@ -1290,7 +1290,6 @@ public static Nested MemberDerived() => } """); var session = new EffectAnalysisSession(compilation); - using (Assert.EnterMultipleScope()) { foreach (var name in new[] { @@ -1438,6 +1437,48 @@ private static int SideEffect() { } } + [Test] + public void ThrowingMemberInitializerSuppressesLaterInitializationAndBody() + { + var compilation = EffectTestHost.CreateCompilation( + """ + public sealed class Sample { + private static int s_state; + private readonly int _zFirst = Fail(); + private readonly int _aSecond = Mutate(); + + public Sample() { + s_state++; + } + + private static int Fail() => + throw new System.InvalidOperationException(); + + private static int Mutate() { + s_state++; + return 1; + } + } + """); + var constructor = EffectTestHost.RequireType(compilation, "Sample") + .InstanceConstructors + .Single(static method => !method.IsImplicitlyDeclared); + var result = new EffectAnalysisSession(compilation).Analyze(constructor); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Static()), + Is.False); + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Receiver), + Is.False); + Assert.That( + result.Summary.Completeness, + Is.EqualTo(EffectCompleteness.Complete)); + } + } + [Test] public void PossibleTypeInitializationFailsClosed() { @@ -1897,6 +1938,40 @@ public static void Explicit(Resource resource) => } } + [Test] + public void UsingValueTypesDisposeOnlyTheirAcquiredCopies() + { + var compilation = EffectTestHost.CreateCompilation( + """ + using System; + + public struct Resource : IDisposable { + public int Value; + public void Dispose() => Value++; + } + + public static class Sample { + public static void Statement(ref Resource input) { + using (input) { } + } + + public static void Declaration(ref Resource input) { + using Resource copy = input; + } + } + """); + var session = new EffectAnalysisSession(compilation); + + foreach (var methodName in new[] { "Statement", "Declaration" }) + { + var result = session.Analyze(Method(compilation, methodName)); + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Parameter(0)), + Is.False, + methodName); + } + } + [Test] public void UsingNullNoOpAndInterfaceControlsStaySound() { @@ -2031,6 +2106,34 @@ public static void FreshAlias() { } } + [Test] + public void UnreachableAliasAssignmentDoesNotTaintLocalOwnership() + { + var result = Analyze( + """ + public sealed class Box { + public int Value; + } + + public static class Sample { + public static void Mutate(Box parameter) { + var local = new Box(); + if (false) { + local = parameter; + } + + local.Value = 1; + } + } + """, + "Sample", + "Mutate"); + + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Parameter(0)), + Is.False); + } + [Test] public void FreshArrayContentsDoNotBecomeFreshOwnedAliases() { @@ -3366,6 +3469,9 @@ public void ByValueStructMutationStaysOnTheLocalCopy() public struct Counter { public int Value; public void ClearValue() => Value = 0; + public readonly int ReadValue() => Value; + public readonly void MutateReadonlyThis() => ClearValue(); + public readonly int ReadReadonlyThis() => ReadValue(); } public static class Sample { @@ -3375,6 +3481,14 @@ public static void MutateCopy(Counter value) => value.ClearValue(); public static void WriteRef(ref Counter value) => value.Value = 0; + public static void MutateLocalCopy(ref Counter source) { + Counter copy = source; + copy.ClearValue(); + } + public static void MutateIn(in Counter source) => + source.ClearValue(); + public static int ReadIn(in Counter source) => + source.ReadValue(); } """); var session = new EffectAnalysisSession(compilation); @@ -3384,6 +3498,22 @@ public static void WriteRef(ref Counter value) => Method(compilation, "MutateCopy")).Summary; var byReference = session.Analyze( Method(compilation, "WriteRef")).Summary; + var localCopy = session.Analyze( + Method(compilation, "MutateLocalCopy")).Summary; + var mutateIn = session.Analyze( + Method(compilation, "MutateIn")).Summary; + var readIn = session.Analyze( + Method(compilation, "ReadIn")).Summary; + var mutateReadonlyThis = session.Analyze( + EffectTestHost.RequireMethod( + compilation, + "Counter", + "MutateReadonlyThis")).Summary; + var readReadonlyThis = session.Analyze( + EffectTestHost.RequireMethod( + compilation, + "Counter", + "ReadReadonlyThis")).Summary; var mutableThis = session.Analyze( EffectTestHost.RequireMethod( compilation, @@ -3399,12 +3529,256 @@ public static void WriteRef(ref Counter value) => Assert.That( byReference.Writes.Contains(EffectRegionId.Parameter(0)), Is.True); + Assert.That( + localCopy.Writes.Contains(EffectRegionId.Parameter(0)), + Is.False); + Assert.That( + mutateIn.Writes.Contains(EffectRegionId.Parameter(0)), + Is.False); + Assert.That( + readIn.Reads.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That( + mutateReadonlyThis.Writes.Contains(EffectRegionId.Receiver), + Is.False); + Assert.That( + readReadonlyThis.Reads.Contains(EffectRegionId.Receiver), + Is.True); Assert.That( mutableThis.Writes.Contains(EffectRegionId.Receiver), Is.True); } } + [Test] + public void RefLikeValueCopiesPreserveExternalAliases() + { + var compilation = EffectTestHost.CreateCompilation( + """ + using System.Diagnostics.CodeAnalysis; + public ref struct RefAlias { + private static int s_cell; + public ref int Cell; + public RefAlias([UnscopedRef] ref int cell) { Cell = ref cell; } + public void Bind([UnscopedRef] ref int cell) { Cell = ref cell; } + public void CopyTo(ref RefAlias target) { + target.Cell = ref Cell; + } + public void CopyFrom(RefAlias source) { + Cell = ref source.Cell; + } + private static ref int StaticCell() => ref s_cell; + public void BindStatic() { Cell = ref StaticCell(); } + private static ref int IgnoreAndReturnStatic([UnscopedRef] ref int ignored) => ref s_cell; + public void BindMisleading([UnscopedRef] ref int cell) { Cell = ref IgnoreAndReturnStatic(ref cell); } + public RefAlias Source { set { Cell = ref value.Cell; } } + public int BindOnRead { get { Cell = ref StaticCell(); return 0; } } + public int BindOnSet { get => 0; set { Cell = ref StaticCell(); } } + public static RefAlias operator +(RefAlias value, int ignored) { + value.BindStatic(); + return value; + } + public void Set() => Cell = 1; + public void Dispose() => Cell = 1; + } + + public static class Sample { + public static void MutateValue(RefAlias value) => value.Set(); + public static void MutateIn(in RefAlias value) => value.Set(); + public static void MutateLocal(ref RefAlias source) { + RefAlias copy = source; + copy.Set(); + } + public static void MutateConstruction(ref int cell) { + var alias = new RefAlias(ref cell); + alias.Set(); + } + public static void DisposeValue(RefAlias value) { + using (value) { } + } + public static void BindThenMutate([UnscopedRef] ref int cell) { + RefAlias alias = default; + alias.Cell = ref cell; + alias.Set(); + } + public static void CallBindThenMutate([UnscopedRef] ref int cell) { + RefAlias alias = default; + alias.Bind(ref cell); + alias.Set(); + } + private static void BindStatic( + ref RefAlias alias, + [UnscopedRef] ref int cell) { + alias.Cell = ref cell; + } + public static void CallStaticBindThenMutate([UnscopedRef] ref int cell) { + RefAlias alias = default; + BindStatic(ref alias, ref cell); + alias.Set(); + } + public static void BindAmbientThenMutate() { + RefAlias alias = default; + alias.BindStatic(); + alias.Set(); + } + public static void BindMisleadingThenMutate([UnscopedRef] ref int cell) { + RefAlias alias = default; + alias.BindMisleading(ref cell); + alias.Set(); + } + public static void CopyPropertyThenMutate(RefAlias source) { + RefAlias target = default; + target.Source = source; + target.Set(); + } + public static void GetterThenMutate() { + RefAlias alias = default; + _ = alias.BindOnRead; + alias.Set(); + } + public static void CompoundSetterThenMutate() { + RefAlias alias = default; + alias.BindOnSet += 1; + alias.Set(); + } + public static void ParameterSetterThenMutate(RefAlias alias) { + alias.BindOnSet = 1; + alias.Set(); + } + public static void ReassignParameterThenMutate( + RefAlias alias, + RefAlias source) { + alias = source; + alias.Set(); + } + public static void CompoundReassignThenMutate() { + RefAlias alias = default; + alias += 1; + alias.Set(); + } + public static void CopyReceiverThenMutate(RefAlias source) { + RefAlias target = default; + source.CopyTo(ref target); + target.Set(); + } + public static void CopyValueThenMutate(RefAlias source) { + RefAlias target = default; + target.CopyFrom(source); + target.Set(); + } + } + """); + var session = new EffectAnalysisSession(compilation); + var value = session.Analyze( + Method(compilation, "MutateValue")).Summary; + var local = session.Analyze( + Method(compilation, "MutateLocal")).Summary; + var mutateIn = session.Analyze( + Method(compilation, "MutateIn")).Summary; + var construction = session.Analyze( + Method(compilation, "MutateConstruction")).Summary; + var disposal = session.Analyze( + Method(compilation, "DisposeValue")).Summary; + var rebound = session.Analyze( + Method(compilation, "BindThenMutate")).Summary; + var reboundByCall = session.Analyze( + Method(compilation, "CallBindThenMutate")).Summary; + var reboundByStaticCall = session.Analyze( + Method(compilation, "CallStaticBindThenMutate")).Summary; + var copiedFromReceiver = session.Analyze( + Method(compilation, "CopyReceiverThenMutate")).Summary; + var copiedFromValue = session.Analyze( + Method(compilation, "CopyValueThenMutate")).Summary; + var boundFromAmbient = session.Analyze( + Method(compilation, "BindAmbientThenMutate")).Summary; + var boundFromMisleadingCall = session.Analyze( + Method(compilation, "BindMisleadingThenMutate")).Summary; + var copiedByProperty = session.Analyze( + Method(compilation, "CopyPropertyThenMutate")).Summary; + var reboundByGetter = session.Analyze( + Method(compilation, "GetterThenMutate")).Summary; + var reboundByCompoundSetter = session.Analyze( + Method(compilation, "CompoundSetterThenMutate")).Summary; + var reboundParameter = session.Analyze( + Method(compilation, "ParameterSetterThenMutate")).Summary; + var reassignedParameter = session.Analyze( + Method(compilation, "ReassignParameterThenMutate")).Summary; + var compoundReassigned = session.Analyze( + Method(compilation, "CompoundReassignThenMutate")).Summary; + + using (Assert.EnterMultipleScope()) + { + Assert.That( + value.Writes.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That(value.Writes.IsUnknown, Is.False); + Assert.That( + local.Writes.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That(local.Writes.IsUnknown, Is.False); + Assert.That( + mutateIn.Writes.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That(construction.Writes.IsUnknown, Is.True); + Assert.That( + disposal.Writes.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That( + rebound.Writes.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That( + reboundByCall.Writes.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That(reboundByCall.Writes.IsUnknown, Is.False); + Assert.That( + reboundByStaticCall.Writes.Contains( + EffectRegionId.Parameter(0)), + Is.True); + Assert.That(reboundByStaticCall.Writes.IsUnknown, Is.False); + Assert.That( + copiedFromReceiver.Writes.Contains( + EffectRegionId.Parameter(0)), + Is.True); + Assert.That(copiedFromReceiver.Writes.IsUnknown, Is.False); + Assert.That( + copiedFromValue.Writes.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That(copiedFromValue.Writes.IsUnknown, Is.False); + Assert.That( + boundFromAmbient.Writes.Contains(EffectRegionId.Static()) + || boundFromAmbient.Writes.IsUnknown, + Is.True); + Assert.That( + boundFromMisleadingCall.Writes.Contains(EffectRegionId.Static()) + || boundFromMisleadingCall.Writes.IsUnknown, + Is.True); + Assert.That( + copiedByProperty.Writes.Contains(EffectRegionId.Parameter(0)), + Is.True); + Assert.That(copiedByProperty.Writes.IsUnknown, Is.False); + Assert.That( + reboundByGetter.Writes.Contains(EffectRegionId.Static()) + || reboundByGetter.Writes.IsUnknown, + Is.True); + Assert.That( + reboundByCompoundSetter.Writes.Contains(EffectRegionId.Static()) + || reboundByCompoundSetter.Writes.IsUnknown, + Is.True); + Assert.That( + reboundParameter.Writes.Contains(EffectRegionId.Static()) + || reboundParameter.Writes.IsUnknown, + Is.True); + Assert.That( + reassignedParameter.Writes.Contains( + EffectRegionId.Parameter(1)), + Is.True); + Assert.That( + compoundReassigned.Writes.Contains(EffectRegionId.Static()) + || compoundReassigned.Writes.IsUnknown, + Is.True); + } + } + [Test] public void UnboxingCreatesAValueOwnedCopyWithoutDroppingReferenceAliases() { @@ -4168,7 +4542,341 @@ public void ExceptionHandlersContributeEffectsOnlyWhenReachable() var compilation = EffectTestHost.CreateCompilation( """ using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; public interface IExternal { void Run(); } + public sealed class UserException : Exception { + public UserException(bool fail) { + if (fail) throw new ArgumentException(); + } + } + public sealed class ThrowingConstructionWithInitializer { + public ThrowingConstructionWithInitializer() => + throw new ArgumentException(); + public int Value { + set => throw new InvalidOperationException(); + } + } + public sealed class ThrowingSetter { + public int Value { + get => 0; + set => throw new InvalidOperationException(); + } + } + public sealed class ThrowingGetter { + public int Value => throw new InvalidOperationException(); + } + public record ThrowingCloneRecord { + public ThrowingCloneRecord() { } + protected ThrowingCloneRecord(ThrowingCloneRecord other) => + throw new InvalidOperationException(); + public int Value { get; init; } + } + public record DivergingCloneRecord { + public DivergingCloneRecord() { } + protected DivergingCloneRecord(DivergingCloneRecord other) { + while (true) { } + } + public int Value { get; init; } + } + public sealed class ThrowingDeconstruction { + public void Deconstruct(out int left, out int right) { + left = right = 0; + throw new InvalidOperationException(); + } + } + public sealed class DivergingDeconstruction { + public void Deconstruct(out int left, out int right) { + left = right = 0; + while (true) { } + } + } + public sealed class DivergingDeconstructionTarget { + public int Value { set { while (true) { } } } + } + public readonly struct PatternBomb { + public int Value { get { while (true) { } } } + } + public sealed class ReferencePatternBomb { + public int Value { get { while (true) { } } } + } + public sealed class ReferencePositionalPatternBomb { + public void Deconstruct(out int value) { + value = 0; + while (true) { } + } + } + public sealed class ReferenceListPatternBomb { + public int Length { get { while (true) { } } } + public int this[int index] => 0; + } + public sealed class ReferenceIndexerPatternBomb { + public int Length => 1; + public int this[int index] { get { while (true) { } } } + } + public sealed class ReferenceSlicePatternBomb { + public int Length => 1; + public int this[int index] => 0; + public ReferenceSlicePatternBomb Slice(int start, int length) { + while (true) { } + } + } + public sealed class VariableLengthSlicePatternBomb { + private readonly int length; + public VariableLengthSlicePatternBomb(int length) { + this.length = length; + } + public int Length => length; + public int this[int index] => 0; + public VariableLengthSlicePatternBomb Slice( + int start, + int sliceLength) { + while (true) { } + } + } + public readonly struct VariableLengthSlicePatternStructBomb { + private readonly int length; + public VariableLengthSlicePatternStructBomb(int length) { + this.length = length; + } + public int Length => length; + public int this[int index] => 0; + public VariableLengthSlicePatternStructBomb Slice( + int start, + int sliceLength) { + while (true) { } + } + } + public readonly struct NestedListPatternBomb { + public int Length => 1; + public int this[int index] { get { while (true) { } } } + } + public class VirtualLengthPatternBase { + public virtual int Length => 1; + public int this[int index] { get { while (true) { } } } + } + public sealed class VirtualLengthPatternDerived : + VirtualLengthPatternBase { + public override int Length => 0; + } + public sealed class ThrowingListLengthPattern { + public int Length => throw new InvalidOperationException(); + public int this[int index] => 0; + } + public sealed class ThrowingListIndexerPattern { + public int Length => 1; + public int this[int index] => + throw new ApplicationException(); + } + public sealed class ThrowingListSlicePattern { + public int Length => 1; + public int this[int index] => 0; + public ThrowingListSlicePattern Slice(int start, int length) => + throw new ArgumentException(); + } + public sealed class EmptyThrowingListIndexerPattern { + public int Length => 0; + public int this[int index] => + throw new ApplicationException(); + } + public sealed class ThrowingLengthAndIndexerPattern { + public int Length => throw new InvalidOperationException(); + public int this[int index] => + throw new ApplicationException(); + } + public sealed class ReceiverMutatingListPattern { + private int state; + public int Length => 1; + public int this[int index] { + get { state++; return 0; } + } + } + public sealed class NestedListPatternHolder { + public ReceiverMutatingListPattern Child { get; } = new(); + } + public sealed class NonNullNestedSliceListPatternBomb { + public int Length { get { while (true) { } } } + public int this[int index] => 0; + } + public sealed class NonNullSliceOuterPattern { + public int Length => 1; + public int this[int index] => 0; + public NonNullNestedSliceListPatternBomb Slice( + int start, + int length) => new(); + } + public sealed class NullTarget { + public int Value; + public void Touch() { } + public void TouchValue(int value) { } + public int SetOnly { set { } } + public void Fail() => throw new InvalidOperationException(); + } + public sealed class EventTarget { + public event Action Changed { add { } remove { } } + } + public sealed class NullAwaitable { + public NullAwaiter GetAwaiter() => null!; + } + public sealed class NullAwaiter : INotifyCompletion { + public bool IsCompleted => true; + public void OnCompleted(Action continuation) { } + public void GetResult() { } + } + public sealed class StaticBomb { + static StaticBomb() => throw new ApplicationException(); + public StaticBomb() { } + public static int Value; + public static int Property { set { } } + } + public sealed class DivergingStaticBomb { + static DivergingStaticBomb() { while (true) { } } + public static int Value => 0; + } + public static class BeforeFieldInitBomb { + private static readonly int Value = FailInitialization(); + private static int FailInitialization() => + throw new ApplicationException(); + public static int Read() => Value; + public static void Run() => + throw new InvalidOperationException(); + } + public static class ExternalInitializationState { + public static int Value; + public static void Mark() => Value++; + } + public static class SameTypeBeforeFieldInitBomb { + private static readonly int Value = FailInitialization(); + private static int FailInitialization() => + throw new ApplicationException(); + public static void CatchInitialization() { + try { _ = Value; } + catch (TypeInitializationException) { + ExternalInitializationState.Mark(); + } + } + public static void AfterInitialization() { + _ = Value; + ExternalInitializationState.Mark(); + } + } + public sealed class ThrowingStaticConstruction { + static ThrowingStaticConstruction() => + throw new ApplicationException(); + public ThrowingStaticConstruction() => + throw new InvalidOperationException(); + } + public static class Extensions { + static Extensions() => throw new ApplicationException(); + public static void Touch(this object value) { } + public static ExtensionEnumerator GetEnumerator( + this ExtensionSequence value) => default; + } + public static class DivergingExtensions { + static DivergingExtensions() { while (true) { } } + public static void TouchDiverging(this object value) { } + } + public sealed class ExtensionSequence { } + public struct ExtensionEnumerator { + public bool MoveNext() => false; + public int Current => 0; + } + public sealed class GenericStaticBomb { + static GenericStaticBomb() { + if (typeof(T) == typeof(string)) + throw new ApplicationException(); + } + public static int Value; + private static int s_genericState; + public static void GenericStaticProbe() { + try { _ = GenericStaticBomb.Value; } + catch (TypeInitializationException) { s_genericState++; } + } + } + public readonly struct ThrowingOperator { + public static ThrowingOperator operator +( + ThrowingOperator left, + ThrowingOperator right) => + throw new InvalidOperationException(); + } + public readonly struct ThrowingStaticOperator { + static ThrowingStaticOperator() => + throw new ApplicationException(); + public static ThrowingStaticOperator operator +( + ThrowingStaticOperator left, + ThrowingStaticOperator right) => default; + } + public readonly struct NonThrowingDivide { + public static NonThrowingDivide operator /( + NonThrowingDivide left, + NonThrowingDivide right) => default; + } + public readonly struct ShortCircuitGate { + public static bool operator false(ShortCircuitGate value) => + true; + public static bool operator true(ShortCircuitGate value) => + false; + public static ShortCircuitGate operator &( + ShortCircuitGate left, + ShortCircuitGate right) => left; + } + public sealed class ThrowingCompoundSetter { + public ThrowingOperator Item { + get => default; + set => throw new ApplicationException(); + } + } + public sealed class ThrowingResource : IDisposable { + public void Dispose() => + throw new InvalidOperationException(); + } + public sealed class DivergingResource : IDisposable { + public void Dispose() { while (true) { } } + } + public sealed class ApplicationThrowingResource : IDisposable { + public void Dispose() => throw new ApplicationException(); + } + public sealed class ThrowingMutatingResource : IDisposable { + private int _state; + public void Dispose() { + _state++; + throw new InvalidOperationException(); + } + } + public sealed class RecursiveResource : IDisposable { + public void Dispose() => Dispose(); + } + public sealed class RecursiveThrowingResource : IDisposable { + private static bool s_throw; + public void Dispose() { + if (s_throw) throw new InvalidOperationException(); + Dispose(); + } + } + public sealed class ThrowingSequence { + public Enumerator GetEnumerator() => + throw new InvalidOperationException(); + public struct Enumerator { + public bool MoveNext() => false; + public int Current => 0; + } + } + public sealed class ThrowingMoveNextSequence { + public Enumerator GetEnumerator() => new Enumerator(); + public struct Enumerator { + public bool MoveNext() => + throw new InvalidOperationException(); + public int Current => + throw new ApplicationException(); + } + } + public sealed class NullEnumeratorSequence { + public Enumerator GetEnumerator() => null!; + public sealed class Enumerator { + public bool MoveNext() => false; + public int Current => 0; + } + } public static class Sample { private static int s_state; public static void EmptyTry() { try { } catch { s_state++; } } @@ -4178,12 +4886,171 @@ public static class Sample { public static void FalseFilter() { try { throw new InvalidOperationException(); } catch (InvalidOperationException) when (false) { s_state++; } } public static void TrueFilter() { try { throw new InvalidOperationException(); } catch (InvalidOperationException) when (true) { s_state++; } } public static void OrderedHierarchy() { try { throw new InvalidOperationException(); } catch (InvalidOperationException) { } catch (Exception) { s_state++; } } + public static void MismatchedCatch() { try { throw new InvalidOperationException(); } catch (ArgumentException) { } s_state++; } + public static void FinallyAfterFailure() { Fail(); try { } finally { s_state++; } } + public static void FinallyAfterDivergence() { try { Spin(); } finally { s_state++; } } + public static void AfterNonreturningFinally() { try { } finally { Spin(); } s_state++; } + public static void AfterConstantNonreturningFinally() { try { } finally { _ = true ? SpinInteger() : 0; } s_state++; } + public static void AfterShortCircuitedFinally() { try { } finally { _ = false && SpinInteger() == 0; } s_state++; } + private static ShortCircuitGate SpinGate() { while (true) { } } + public static void AfterUserShortCircuitedFinally() { try { } finally { _ = new ShortCircuitGate() && SpinGate(); } s_state++; } + public static void AfterNonreturningArgument() { Sink(SpinInteger()); s_state++; } + private static void Sink(int value) { } + private static int SpinInteger() { while (true) { } } + public static void AfterNestedNonreturningFinally() { try { try { } finally { _ = 1; } } finally { Spin(); } s_state++; } + public static void NestedFinallyAfterDivergence() { try { try { throw new InvalidOperationException(); } finally { Spin(); } } finally { s_state++; } } + public static void BranchedFinallyAfterDivergence(bool condition) { try { if (condition) Spin(); else Spin(); } finally { s_state++; } } + public static void InnerFinallyCaughtOutside() { try { try { throw new InvalidOperationException(); } finally { s_state++; } } catch (InvalidOperationException) { } } + public static int ReturnThroughFinally() { try { return 1; } finally { s_state++; } } + private static void Fail() => throw new InvalidOperationException(); + private static void Spin() { while (true) { } } + public static void ThrowOperandFailure() { try { throw Make(); } catch (ArgumentException) { s_state++; } } + public static void MismatchedThrowOperandFailure() { try { throw Make(); } catch (InvalidOperationException) { s_state++; } } + private static Exception Make() => throw new ArgumentException(); + public static void DivergentThrowOperand() { try { throw SpinException(); } catch { s_state++; } } + private static Exception SpinException() { while (true) { } } + public static void ConstructorOperandFailure(bool fail) { try { throw new UserException(fail); } catch (ArgumentException) { s_state++; } catch (UserException) { } } + public static void ConstructorNotReached() { try { _ = new UserException(ThrowBoolean()); } catch (ArgumentException) { s_state++; } catch (InvalidOperationException) { } } + public static void CheckedConversionAfterFailure() { try { _ = checked((int)ThrowLong()); } catch (OverflowException) { s_state++; } catch (ArgumentException) { } } + public static void DivisionAfterFailure() { try { _ = ThrowInteger() / 0; } catch (DivideByZeroException) { s_state++; } catch (ArgumentException) { } } + public static void UserDivisionHasNoIntrinsicFailure(NonThrowingDivide left, NonThrowingDivide right) { try { _ = left / right; } catch (DivideByZeroException) { s_state++; } } + public static void CheckedBinaryOverflow(int value) { try { _ = checked(int.MaxValue + value); } catch (OverflowException) { s_state++; } } + public static void CheckedUnaryOverflow(int value) { try { _ = checked(-value); } catch (OverflowException) { s_state++; } } + public static void CheckedCompoundOverflow(int value) { try { var total = int.MaxValue; checked { total += value; } } catch (OverflowException) { s_state++; } } + private static long ThrowLong() => throw new ArgumentException(); + private static int ThrowInteger() => throw new ArgumentException(); + public static void InitializerAfterThrowingConstructor() { try { _ = new ThrowingConstructionWithInitializer { Value = 1 }; } catch (InvalidOperationException) { s_state++; } catch (ArgumentException) { } } + private static bool ThrowBoolean() => throw new InvalidOperationException(); + public static void OperatorFailure(ThrowingOperator left, ThrowingOperator right) { try { _ = left + right; } catch (InvalidOperationException) { s_state++; } } + public static void CompoundSetterNotReached(ThrowingCompoundSetter box, ThrowingOperator value) { try { box.Item += value; } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void UsingDisposalFailure(ThrowingResource resource) { try { using (resource) { } } catch (InvalidOperationException) { s_state++; } } + public static void UsingDisposalAfterDivergence(ThrowingResource resource) { try { using (resource) { Spin(); } } catch (InvalidOperationException) { s_state++; } } + public static void UsingDeclarationAfterDivergence(ThrowingResource resource) { try { using var value = resource; Spin(); } catch (InvalidOperationException) { s_state++; } } + public static void UsingDeclarationGotoSkipsDivergence(ThrowingResource resource) { try { using var value = resource; goto Done; Spin(); Done: ; } catch (InvalidOperationException) { s_state++; } } + public static void UsingDeclarationGotoBeforeLifetime(ThrowingResource resource) { try { Retry: ; { using var value = resource; } goto Retry; } catch (InvalidOperationException) { s_state++; } } + public static void UsingDeclarationGotoInsideLifetimeThenDiverges(ThrowingResource resource) { try { using var value = resource; Retry: ; goto Retry; } catch (InvalidOperationException) { s_state++; } } + public static void UsingInitialAcquisitionFails() { try { using ThrowingResource first = FailResource(), second = new ThrowingResource(); } catch (InvalidOperationException) { s_state++; } catch (ArgumentException) { } } + public static void UsingLaterAcquisitionFails(ThrowingResource resource) { try { using ThrowingResource first = resource, second = FailResource(); } catch (InvalidOperationException) { s_state++; } catch (ArgumentException) { } } + public static void UsingLaterAcquisitionFailsBeforeDivergentBody(ThrowingResource resource) { try { using (ThrowingResource first = resource, second = FailResource()) { Spin(); } } catch (InvalidOperationException) { s_state++; } catch (ArgumentException) { } } + public static void LaterDeclarationDivergesBeforeEarlierDispose(ThrowingResource outer, DivergingResource inner) { try { using var first = outer; using var second = inner; } catch (InvalidOperationException) { s_state++; } } + public static void LaterDeclaratorDivergesBeforeEarlierDispose(ThrowingResource outer, DivergingResource inner) { try { using (IDisposable first = outer, second = inner) { } } catch (InvalidOperationException) { s_state++; } } + public static void LaterDisposeThrowsThenEarlierDisposeRuns(ThrowingResource outer, ApplicationThrowingResource inner) { try { using var first = outer; using var second = inner; } catch (InvalidOperationException) { s_state++; } catch (ApplicationException) { } } + public static void ThrowingDeclaratorsUnwindInReverse(ThrowingMutatingResource outer, ThrowingMutatingResource inner) { using (ThrowingMutatingResource first = outer, second = inner) { } } + public static void RecursiveDeclaratorDoesNotUnwindOuter(ThrowingMutatingResource outer, RecursiveResource inner) { using (IDisposable first = outer, second = inner) { } } + public static void RecursiveThrowingDeclaratorMayUnwindOuter(ThrowingMutatingResource outer, RecursiveThrowingResource inner) { using (IDisposable first = outer, second = inner) { } } + private static ThrowingResource FailResource() => throw new ArgumentException(); + public static void ForeachAcquisitionFailure(ThrowingSequence values) { try { foreach (var value in values) { _ = value; } } catch (InvalidOperationException) { s_state++; } } + public static void ForeachNullReceiverFailure() { ThrowingSequence values = null!; try { foreach (var value in values) { _ = value; } } catch (NullReferenceException) { s_state++; } } + public static void ForeachCurrentAfterMoveNextFailure(ThrowingMoveNextSequence values) { try { foreach (var value in values) { _ = value; } } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void ForeachNullEnumeratorFailure(NullEnumeratorSequence values) { try { foreach (var value in values) { _ = value; } } catch (NullReferenceException) { s_state++; } } + public static void ForeachExtensionInitializationFailure(ExtensionSequence values) { try { foreach (var value in values) { _ = value; } } catch (TypeInitializationException) { s_state++; } } + public static void UnreachableWhileCatch() { try { while (false) { throw new InvalidOperationException(); } } catch (InvalidOperationException) { s_state++; } } + public static void UnreachableForCatch() { try { for (; false;) { throw new InvalidOperationException(); } } catch (InvalidOperationException) { s_state++; } } + public static void ShortCircuitedAndCatch() { try { _ = false && FailBoolean(); } catch (InvalidOperationException) { s_state++; } } + public static void ShortCircuitedOrCatch() { try { _ = true || FailBoolean(); } catch (InvalidOperationException) { s_state++; } } + public static void ConstantSwitchExpressionCatch() { try { _ = 0 switch { 1 => ThrowObject(), _ => new object() }; } catch (InvalidOperationException) { s_state++; } } + public static void ConstantUnmatchedSwitchExpressionCatch() { try { _ = 0 switch { 1 => 1 }; } catch (System.Runtime.CompilerServices.SwitchExpressionException) { s_state++; } } + public static void ConstantMatchedNonExhaustiveSwitchCatch() { try { _ = 0 switch { 0 => 1 }; } catch (System.Runtime.CompilerServices.SwitchExpressionException) { s_state++; } } + public static void ExhaustiveTypePatternSwitchCatch() { try { _ = 0 switch { int value => value }; } catch (System.Runtime.CompilerServices.SwitchExpressionException) { s_state++; } } + public static void ConstantRelationalSwitchCatch() { try { _ = 0 switch { >= 0 => new object(), _ => ThrowObject() }; } catch (InvalidOperationException) { s_state++; } } + public static void NaNSingleRelationalSwitchCatch() { try { _ = float.NaN switch { < 0f => ThrowObject(), _ => new object() }; } catch (InvalidOperationException) { s_state++; } } + public static void NaNDoubleRelationalSwitchCatch() { try { _ = double.NaN switch { < 0d => ThrowObject(), _ => new object() }; } catch (InvalidOperationException) { s_state++; } } + public static void ConstantLogicalSwitchExpressionCatch() { try { _ = 0 switch { not 1 => new object(), _ => ThrowObject() }; } catch (InvalidOperationException) { s_state++; } } + public static void AfterConstantUnmatchedSwitchExpression() { _ = 0 switch { 1 => 1 }; s_state++; } + public static void ConstantSwitchStatementCatch() { try { switch (0) { case 1: ThrowObject(); break; default: break; } } catch (InvalidOperationException) { s_state++; } } + public static void ConstantRelationalSwitchStatementCatch() { try { switch (0) { case >= 0: break; default: ThrowObject(); break; } } catch (InvalidOperationException) { s_state++; } } + public static void NaNSwitchStatementCatch() { try { switch (double.NaN) { case < 0d: ThrowObject(); break; default: break; } } catch (InvalidOperationException) { s_state++; } } + public static void ConstantLogicalSwitchStatementCatch() { try { switch (0) { case not 1: break; default: ThrowObject(); break; } } catch (InvalidOperationException) { s_state++; } } + public static void TotalSliceSwitchExpressionGuardCatch(Span value) { try { _ = value switch { [..] when ThrowBoolean() => 1, _ => ThrowInteger() }; } catch (ArgumentException) { s_state++; } catch (InvalidOperationException) { } } + public static void TotalSliceSwitchStatementGuardCatch(Span value) { try { switch (value) { case [..] when ThrowBoolean(): break; default: ThrowInteger(); break; } } catch (ArgumentException) { s_state++; } catch (InvalidOperationException) { } } + public static void ThrowingSwitchExpressionGuard() { try { _ = 0 switch { 0 when ThrowBoolean() => new object(), _ => ThrowApplicationObject() }; } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void AfterThrowingTotalSwitchGuard(int value) { _ = value switch { _ when ThrowBoolean() => 1, _ => 2 }; s_state++; } + public static void VarPatternThrowingGuardBeforeFallback(int value) { try { _ = value switch { var captured when ThrowBoolean() => 1, _ => ThrowInteger() }; } catch (ArgumentException) { s_state++; } catch (InvalidOperationException) { } } + public static void AfterDivergingPropertyPattern() { _ = new PatternBomb() switch { { Value: 0 } => 1, _ => 2 }; s_state++; } + public static void AfterDivergingReferencePropertyPattern() { _ = new ReferencePatternBomb() switch { { Value: 0 } => 1, _ => 2 }; s_state++; } + public static void AfterDivergingReferencePositionalPattern() { _ = new ReferencePositionalPatternBomb() switch { ReferencePositionalPatternBomb(0) => 1, _ => 2 }; s_state++; } + public static void AfterDivergingReferenceListPattern() { _ = new ReferenceListPatternBomb() switch { [] => 1, _ => 2 }; s_state++; } + public static void AfterDivergingReferenceIndexerPattern() { _ = new ReferenceIndexerPatternBomb() switch { [0] => 1, _ => 2 }; s_state++; } + public static void AfterDivergingParenthesizedIndexerPattern() { _ = (new ReferenceIndexerPatternBomb()) switch { [0] => 1, _ => 2 }; s_state++; } + public static void AfterDivergingReferenceSlicePattern() { _ = new ReferenceSlicePatternBomb() switch { [.. var rest] => 1, _ => 2 }; s_state++; } + public static void AfterDivergingVariableLengthSlicePattern(int length) { _ = new VariableLengthSlicePatternBomb(length) switch { [.. var rest] => 1, _ => 2 }; s_state++; } + public static void AfterDivergingNestedSlicePattern(VariableLengthSlicePatternStructBomb value) { _ = value switch { [.. { Length: 0 }] => 1, _ => 2 }; s_state++; } + public static void VirtualLengthMismatchCompletes() { _ = ((VirtualLengthPatternBase)new VirtualLengthPatternDerived()) switch { [0] => 1, _ => 2 }; s_state++; } + public static void AfterDivergingNestedListPattern() { _ = new NestedListPatternBomb[2] switch { [[0], _] => 1, _ => 2 }; s_state++; } + public static void ThrowingListLengthCatch(ThrowingListLengthPattern value) { if (value is null) return; try { _ = value switch { [] => 1, _ => 2 }; } catch (InvalidOperationException) { s_state++; } } + public static void ThrowingListIndexerCatch() { try { _ = new ThrowingListIndexerPattern() switch { [0] => 1, _ => 2 }; } catch (ApplicationException) { s_state++; } } + public static void ThrowingListSliceCatch() { try { _ = new ThrowingListSlicePattern() switch { [.. var rest] => 1, _ => 2 }; } catch (ArgumentException) { s_state++; } } + public static void LengthMismatchSkipsIndexerCatch() { try { _ = new EmptyThrowingListIndexerPattern() switch { [0] => 1, _ => 2 }; } catch (ApplicationException) { s_state++; } } + public static void NullListSkipsLengthCatch() { try { _ = ((ThrowingListLengthPattern)null!) switch { [] => 1, _ => 2 }; } catch (InvalidOperationException) { s_state++; } } + public static void ThrowingLengthSkipsIndexerCatch() { try { _ = new ThrowingLengthAndIndexerPattern() switch { [0] => 1, _ => 2 }; } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static bool NestedListReceiverWrite(NestedListPatternHolder value) => value is { Child: [0] }; + public static void AfterDivergingNonNullNestedSliceList() { _ = new NonNullSliceOuterPattern() switch { [.. []] => 1, _ => 2 }; s_state++; } + public static void AfterDivergingNegatedPattern() { _ = new PatternBomb() switch { not { Value: 0 } => 1, _ => 2 }; s_state++; } + public static void AfterDivergingAndPattern() { _ = new PatternBomb() switch { { Value: 0 } and _ => 1, _ => 2 }; s_state++; } + public static void AfterDivergingOrPattern() { _ = new ReferencePatternBomb() switch { null or { Value: 0 } => 1, _ => 2 }; s_state++; } + public static void AfterDivergingNotNullAndPattern() { _ = new ReferencePatternBomb() switch { not null and { Value: 0 } => 1, _ => 2 }; s_state++; } + public static void ThrowingSwitchStatementGuard() { try { switch (0) { case 0 when ThrowBoolean(): break; default: ThrowApplicationObject(); break; } } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void VarPatternThrowingSwitchGuard(int value) { try { switch (value) { case var captured when ThrowBoolean(): break; default: ThrowApplicationObject(); break; } } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void ThrowingSwitchStatementGuardBeforeGoto() { try { switch (0) { case 0 when ThrowBoolean(): goto default; default: ThrowApplicationObject(); break; } } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void ThrowingSwitchBodyBeforeGoto() { try { switch (0) { case 0: Fail(); goto default; default: ThrowApplicationObject(); break; } } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void SwitchGotoOrdinaryLabel() { try { switch (0) { case 0: goto Done; throw new InvalidOperationException(); Done: ThrowApplicationObject(); break; } } catch (ApplicationException) { s_state++; } } + public static void GotoNestedOrdinaryLabel() { try { goto Inner; Outer: Inner: ; ThrowApplicationObject(); } catch (ApplicationException) { s_state++; } } + public static void AfterNonreturningCallCatch() { try { Fail(); throw new ApplicationException(); } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void NestedSwallowedCatch() { try { try { throw new InvalidOperationException(); } catch (InvalidOperationException) { } } catch (InvalidOperationException) { s_state++; } } + public static void NestedUnreachableHandler() { try { try { throw new InvalidOperationException(); } catch (ArgumentException) { throw new ApplicationException(); } } catch (ApplicationException) { s_state++; } catch (InvalidOperationException) { } } + public static void NestedRethrowCatch() { try { try { throw new InvalidOperationException(); } catch (InvalidOperationException) { throw; } } catch (InvalidOperationException) { s_state++; } } + public static void UnreachableNestedRethrowCatch() { try { try { throw new InvalidOperationException(); } catch (InvalidOperationException) { Spin(); throw; } } catch (InvalidOperationException) { s_state++; } } + public static void NestedFinallyAfterPureDivergence() { try { try { Spin(); } finally { throw new ApplicationException(); } } catch (ApplicationException) { s_state++; } } + public static void UsingDeclarationNestedBreakThenDivergence(ThrowingResource resource) { try { using var value = resource; while (true) { break; } Spin(); } catch (InvalidOperationException) { s_state++; } } + public static void UsingDeclarationInternalGotoThenDivergence(ThrowingResource resource) { try { using var value = resource; goto Loop; Loop: Spin(); } catch (InvalidOperationException) { s_state++; } } + public static void NonNullCoalesceAssignment() { object value = new object(); try { value ??= ThrowObject(); } catch (InvalidOperationException) { s_state++; } } + public static void NullConditionalAccess() { NullTarget? value = null; try { value?.Fail(); } catch (InvalidOperationException) { s_state++; } } + public static void UnreachableReturnAfterDivergence(ThrowingResource resource) { try { using var value = resource; if (true) { Spin(); return; } } catch (InvalidOperationException) { s_state++; } } + public static void ReturnBlockedByDivergentFinally(ThrowingResource resource) { try { using var value = resource; try { return; } finally { Spin(); } } catch (InvalidOperationException) { s_state++; } } + public static void ReturnThroughCompletingFinally(ThrowingResource resource) { try { using var value = resource; try { return; } finally { _ = 1; } } catch (InvalidOperationException) { s_state++; } } + private static object ThrowObject() => throw new InvalidOperationException(); + private static object ThrowApplicationObject() => throw new ApplicationException(); + private static bool FailBoolean() => throw new InvalidOperationException(); + public static void SetterFailure(ThrowingSetter value) { try { value.Value = 1; } catch (InvalidOperationException) { s_state++; } } + public static void NullReceiverFailure() { NullTarget value = null!; try { value.Touch(); } catch (NullReferenceException) { s_state++; } } + public static void NullReceiverAfterThrowingArgument() { NullTarget value = null!; try { value.TouchValue(ThrowInteger()); } catch (NullReferenceException) { s_state++; } catch (ArgumentException) { } } + public static void ThrowingRhsBeforeNullSetter() { NullTarget value = null!; try { value.SetOnly = ThrowInteger(); } catch (ArgumentException) { s_state++; } catch (NullReferenceException) { } } + public static void NullFieldFailure() { NullTarget value = null!; try { _ = value.Value; } catch (NullReferenceException) { s_state++; } } + public static void NullArrayCannotReachBoundsCatch() { int[] values = null!; try { _ = values[0]; } catch (IndexOutOfRangeException) { s_state++; } catch (NullReferenceException) { } } + public static void StaticInitializationFailure() { try { _ = StaticBomb.Value; } catch (TypeInitializationException) { s_state++; } } + public static void AfterDivergingStaticInitialization() { try { } finally { _ = DivergingStaticBomb.Value; } s_state++; } + public static void BeforeFieldInitMethodMayRun() { try { BeforeFieldInitBomb.Run(); } catch (InvalidOperationException) { s_state++; } catch (TypeInitializationException) { } } + public static void StaticInitializationWrongCatch() { try { _ = StaticBomb.Value; } catch (ApplicationException) { s_state++; } catch (TypeInitializationException) { } } + public static void ConstructionAfterFailingStaticInitialization() { try { _ = new ThrowingStaticConstruction(); } catch (InvalidOperationException) { s_state++; } catch (TypeInitializationException) { } } + public static void StaticOperatorInitializationFailure(ThrowingStaticOperator left, ThrowingStaticOperator right) { try { _ = left + right; } catch (TypeInitializationException) { s_state++; } } + public static void ConstructionInitializationFailure() { try { _ = new StaticBomb(); } catch (TypeInitializationException) { s_state++; } } + public static void StaticPropertyInitializationAfterRhs() { try { StaticBomb.Property = ThrowInteger(); } catch (TypeInitializationException) { s_state++; } catch (ArgumentException) { } } + public static void StaticFieldInitializationAfterRhs() { try { StaticBomb.Value = ThrowInteger(); } catch (TypeInitializationException) { s_state++; } catch (ArgumentException) { } } + public static void ExtensionInitializationAfterReceiver() { try { ThrowObject().Touch(); } catch (TypeInitializationException) { s_state++; } catch (InvalidOperationException) { } } + public static void AfterDivergingExtensionInitialization() { new object().TouchDiverging(); s_state++; } + public static void NameofOperandIsCompileTime(ThrowingGetter value) { try { _ = nameof(value.Value); } catch (InvalidOperationException) { s_state++; } } + public static void WithCloneFailure(ThrowingCloneRecord value) { try { _ = value with { }; } catch (InvalidOperationException) { s_state++; } } + public static void AfterDivergingWithClone(DivergingCloneRecord value) { _ = value with { Value = 1 }; s_state++; } + public static void DeconstructionFailure(ThrowingDeconstruction value) { try { var (left, right) = value; _ = left + right; } catch (InvalidOperationException) { s_state++; } } + public static void AfterDivergingDeconstruction(DivergingDeconstruction value) { var (left, right) = value; _ = left + right; s_state++; } + public static void AfterDivergingDeconstructionSetter(DivergingDeconstructionTarget target) { int ignored; (target.Value, ignored) = (1, 2); s_state++; } + public static void NullLockWrongCatch() { object gate = null!; try { lock (gate) { } } catch (NullReferenceException) { s_state++; } catch (ArgumentNullException) { } } + public static void NullLockCorrectCatch() { object gate = null!; try { lock (gate) { } } catch (ArgumentNullException) { s_state++; } } + public static void NullEventWrongCatch() { EventTarget value = null!; Action handler = FailHandler; try { value.Changed += handler; } catch (InvalidOperationException) { s_state++; } catch (NullReferenceException) { } } + public static void NullEventCorrectCatch() { EventTarget value = null!; Action handler = FailHandler; try { value.Changed += handler; } catch (NullReferenceException) { s_state++; } } + public static async Task NullAwaitWrongCatch() { Task value = null!; try { await value; } catch (InvalidOperationException) { s_state++; } catch (NullReferenceException) { } } + public static async Task NullAwaitCorrectCatch() { Task value = null!; try { await value; } catch (NullReferenceException) { s_state++; } } + public static async Task NullAwaitAfterThrowingOperand() { try { await ThrowTask(); } catch (NullReferenceException) { s_state++; } catch (ArgumentException) { } } + public static async Task NullCustomAwaiterCatch() { try { await new NullAwaitable(); } catch (NullReferenceException) { s_state++; } } + private static Task ThrowTask() => throw new ArgumentException(); + private static void FailHandler() { } + public static void ThrowingFilter() { try { throw new InvalidOperationException(); } catch (InvalidOperationException) when (Filter()) { s_state++; } } + private static bool Filter() => throw new ApplicationException(); public static void Rethrow() { try { try { throw new InvalidOperationException(); } catch (InvalidOperationException) { throw; } } catch (Exception) { s_state++; } } public static void FinallyRuns() { try { } finally { s_state++; } } } """); var session = new EffectAnalysisSession(compilation); - using (Assert.EnterMultipleScope()) { Assert.That(HasStaticWrite("EmptyTry"), Is.False); @@ -4193,16 +5060,305 @@ public static class Sample { Assert.That(HasStaticWrite("FalseFilter"), Is.False); Assert.That(HasStaticWrite("TrueFilter"), Is.True); Assert.That(HasStaticWrite("OrderedHierarchy"), Is.False); - Assert.That(HasStaticWrite("Rethrow"), Is.True); - Assert.That(HasStaticWrite("FinallyRuns"), Is.True); - } - - bool HasStaticWrite(string methodName) - { - return session.Analyze(Method(compilation, methodName)) - .Summary.Writes.Contains(EffectRegionId.Static()); - } - } + Assert.That(HasStaticWrite("MismatchedCatch"), Is.False); + Assert.That(HasStaticWrite("FinallyAfterFailure"), Is.False); + Assert.That(HasStaticWrite("FinallyAfterDivergence"), Is.False); + Assert.That(HasStaticWrite("AfterNonreturningFinally"), Is.False); + Assert.That( + HasStaticWrite("AfterConstantNonreturningFinally"), + Is.False); + Assert.That(HasStaticWrite("AfterShortCircuitedFinally"), Is.True); + Assert.That( + HasStaticWrite("AfterUserShortCircuitedFinally"), + Is.True); + Assert.That(HasStaticWrite("AfterNonreturningArgument"), Is.False); + Assert.That( + HasStaticWrite("AfterNestedNonreturningFinally"), + Is.False); + Assert.That(HasStaticWrite("NestedFinallyAfterDivergence"), Is.False); + Assert.That(HasStaticWrite("BranchedFinallyAfterDivergence"), Is.False); + Assert.That(HasStaticWrite("InnerFinallyCaughtOutside"), Is.True); + Assert.That(HasStaticWrite("ReturnThroughFinally"), Is.True); + Assert.That(HasStaticWrite("ThrowOperandFailure"), Is.True); + Assert.That(HasStaticWrite("MismatchedThrowOperandFailure"), Is.False); + Assert.That(HasStaticWrite("DivergentThrowOperand"), Is.False); + Assert.That(HasStaticWrite("ConstructorOperandFailure"), Is.True); + Assert.That(HasStaticWrite("ConstructorNotReached"), Is.False); + Assert.That(HasStaticWrite("CheckedConversionAfterFailure"), Is.False); + Assert.That(HasStaticWrite("DivisionAfterFailure"), Is.False); + Assert.That( + HasStaticWrite("UserDivisionHasNoIntrinsicFailure"), + Is.False); + Assert.That(HasStaticWrite("CheckedBinaryOverflow"), Is.True); + Assert.That(HasStaticWrite("CheckedUnaryOverflow"), Is.True); + Assert.That(HasStaticWrite("CheckedCompoundOverflow"), Is.True); + Assert.That(HasStaticWrite("InitializerAfterThrowingConstructor"), Is.False); + Assert.That(HasStaticWrite("OperatorFailure"), Is.True); + Assert.That(HasStaticWrite("CompoundSetterNotReached"), Is.False); + Assert.That(HasStaticWrite("UsingDisposalFailure"), Is.True); + Assert.That(HasStaticWrite("UsingDisposalAfterDivergence"), Is.False); + Assert.That(HasStaticWrite("UsingDeclarationAfterDivergence"), Is.False); + Assert.That(HasStaticWrite("UsingDeclarationGotoSkipsDivergence"), Is.True); + Assert.That(HasStaticWrite("UsingDeclarationGotoBeforeLifetime"), Is.True); + Assert.That(HasStaticWrite("UsingDeclarationGotoInsideLifetimeThenDiverges"), Is.False); + Assert.That(HasStaticWrite("UsingInitialAcquisitionFails"), Is.False); + Assert.That(HasStaticWrite("UsingLaterAcquisitionFails"), Is.True); + Assert.That(HasStaticWrite("UsingLaterAcquisitionFailsBeforeDivergentBody"), Is.True); + Assert.That(HasStaticWrite("LaterDeclarationDivergesBeforeEarlierDispose"), Is.False); + Assert.That(HasStaticWrite("LaterDeclaratorDivergesBeforeEarlierDispose"), Is.False); + Assert.That(HasStaticWrite("LaterDisposeThrowsThenEarlierDisposeRuns"), Is.True); + Assert.That(HasStaticWrite("ForeachAcquisitionFailure"), Is.True); + Assert.That(HasStaticWrite("ForeachNullReceiverFailure"), Is.True); + Assert.That(HasStaticWrite("ForeachCurrentAfterMoveNextFailure"), Is.False); + Assert.That(HasStaticWrite("ForeachNullEnumeratorFailure"), Is.True); + Assert.That( + HasStaticWrite("ForeachExtensionInitializationFailure"), + Is.True); + Assert.That(HasStaticWrite("UnreachableWhileCatch"), Is.False); + Assert.That(HasStaticWrite("UnreachableForCatch"), Is.False); + Assert.That(HasStaticWrite("ShortCircuitedAndCatch"), Is.False); + Assert.That(HasStaticWrite("ShortCircuitedOrCatch"), Is.False); + Assert.That(HasStaticWrite("ConstantSwitchExpressionCatch"), Is.False); + Assert.That( + HasStaticWrite("ConstantUnmatchedSwitchExpressionCatch"), + Is.True); + Assert.That( + HasStaticWrite("ConstantMatchedNonExhaustiveSwitchCatch"), + Is.False); + Assert.That( + HasStaticWrite("ExhaustiveTypePatternSwitchCatch"), + Is.False); + Assert.That( + HasStaticWrite("ConstantRelationalSwitchCatch"), + Is.False); + Assert.That( + HasStaticWrite("NaNSingleRelationalSwitchCatch"), + Is.False); + Assert.That( + HasStaticWrite("NaNDoubleRelationalSwitchCatch"), + Is.False); + Assert.That( + HasStaticWrite("ConstantLogicalSwitchExpressionCatch"), + Is.False); + Assert.That( + HasStaticWrite("AfterConstantUnmatchedSwitchExpression"), + Is.False); + Assert.That(HasStaticWrite("ConstantSwitchStatementCatch"), Is.False); + Assert.That( + HasStaticWrite("ConstantRelationalSwitchStatementCatch"), + Is.False); + Assert.That(HasStaticWrite("NaNSwitchStatementCatch"), Is.False); + Assert.That( + HasStaticWrite("ConstantLogicalSwitchStatementCatch"), + Is.False); + Assert.That( + HasStaticWrite("TotalSliceSwitchExpressionGuardCatch"), + Is.False); + Assert.That( + HasStaticWrite("TotalSliceSwitchStatementGuardCatch"), + Is.False); + Assert.That(HasStaticWrite("ThrowingSwitchExpressionGuard"), Is.False); + Assert.That(HasStaticWrite("AfterThrowingTotalSwitchGuard"), Is.False); + Assert.That( + HasStaticWrite("VarPatternThrowingGuardBeforeFallback"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingPropertyPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingReferencePropertyPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingReferencePositionalPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingReferenceListPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingReferenceIndexerPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingParenthesizedIndexerPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingReferenceSlicePattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingVariableLengthSlicePattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingNestedSlicePattern"), + Is.False); + Assert.That( + HasStaticWrite("VirtualLengthMismatchCompletes"), + Is.True); + Assert.That( + HasStaticWrite("AfterDivergingNestedListPattern"), + Is.False); + Assert.That(HasStaticWrite("ThrowingListLengthCatch"), Is.True); + Assert.That(HasStaticWrite("ThrowingListIndexerCatch"), Is.True); + Assert.That(HasStaticWrite("ThrowingListSliceCatch"), Is.True); + Assert.That( + HasStaticWrite("LengthMismatchSkipsIndexerCatch"), + Is.False); + Assert.That(HasStaticWrite("NullListSkipsLengthCatch"), Is.False); + Assert.That( + HasStaticWrite("ThrowingLengthSkipsIndexerCatch"), + Is.False); + Assert.That( + session.Analyze(Method(compilation, "NestedListReceiverWrite")) + .Summary.Writes.IsUnknown, + Is.True); + Assert.That( + HasStaticWrite("AfterDivergingNonNullNestedSliceList"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingNegatedPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingAndPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingOrPattern"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingNotNullAndPattern"), + Is.False); + Assert.That(HasStaticWrite("ThrowingSwitchStatementGuard"), Is.False); + Assert.That(HasStaticWrite("VarPatternThrowingSwitchGuard"), Is.False); + Assert.That(HasStaticWrite("ThrowingSwitchStatementGuardBeforeGoto"), Is.False); + Assert.That(HasStaticWrite("ThrowingSwitchBodyBeforeGoto"), Is.False); + Assert.That(HasStaticWrite("SwitchGotoOrdinaryLabel"), Is.True); + Assert.That(HasStaticWrite("GotoNestedOrdinaryLabel"), Is.True); + Assert.That(HasStaticWrite("AfterNonreturningCallCatch"), Is.False); + Assert.That(HasStaticWrite("NestedSwallowedCatch"), Is.False); + Assert.That(HasStaticWrite("NestedUnreachableHandler"), Is.False); + Assert.That(HasStaticWrite("NestedRethrowCatch"), Is.True); + Assert.That(HasStaticWrite("UnreachableNestedRethrowCatch"), Is.False); + Assert.That(HasStaticWrite("NestedFinallyAfterPureDivergence"), Is.False); + Assert.That(HasStaticWrite("UsingDeclarationNestedBreakThenDivergence"), Is.False); + Assert.That(HasStaticWrite("UsingDeclarationInternalGotoThenDivergence"), Is.False); + Assert.That(HasStaticWrite("NonNullCoalesceAssignment"), Is.False); + Assert.That(HasStaticWrite("NullConditionalAccess"), Is.False); + Assert.That(HasStaticWrite("UnreachableReturnAfterDivergence"), Is.False); + Assert.That(HasStaticWrite("ReturnBlockedByDivergentFinally"), Is.False); + Assert.That(HasStaticWrite("ReturnThroughCompletingFinally"), Is.True); + Assert.That(HasStaticWrite("SetterFailure"), Is.True); + Assert.That(HasStaticWrite("NullReceiverFailure"), Is.True); + Assert.That( + HasStaticWrite("NullReceiverAfterThrowingArgument"), + Is.False); + Assert.That( + HasStaticWrite("ThrowingRhsBeforeNullSetter"), + Is.True); + Assert.That(HasStaticWrite("NullFieldFailure"), Is.True); + Assert.That( + HasStaticWrite("NullArrayCannotReachBoundsCatch"), + Is.False); + Assert.That(HasStaticWrite("StaticInitializationFailure"), Is.True); + Assert.That( + HasStaticWrite("AfterDivergingStaticInitialization"), + Is.False); + Assert.That(HasStaticWrite("BeforeFieldInitMethodMayRun"), Is.True); + Assert.That( + session.Analyze(EffectTestHost.RequireMethod( + compilation, + "SameTypeBeforeFieldInitBomb", + "CatchInitialization")) + .Summary.Writes.Contains(EffectRegionId.Static()), + Is.True); + Assert.That( + session.Analyze(EffectTestHost.RequireMethod( + compilation, + "SameTypeBeforeFieldInitBomb", + "AfterInitialization")) + .Summary.Writes.Contains(EffectRegionId.Static()), + Is.False); + Assert.That( + HasStaticWrite("StaticInitializationWrongCatch"), + Is.False); + Assert.That( + HasStaticWrite("ConstructionAfterFailingStaticInitialization"), + Is.False); + Assert.That( + HasStaticWrite("StaticOperatorInitializationFailure"), + Is.True); + Assert.That( + HasStaticWrite("ConstructionInitializationFailure"), + Is.True); + Assert.That( + HasStaticWrite("StaticPropertyInitializationAfterRhs"), + Is.False); + Assert.That( + HasStaticWrite("StaticFieldInitializationAfterRhs"), + Is.False); + Assert.That( + HasStaticWrite("ExtensionInitializationAfterReceiver"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingExtensionInitialization"), + Is.False); + Assert.That(HasStaticWrite("NameofOperandIsCompileTime"), Is.False); + Assert.That(HasStaticWrite("WithCloneFailure"), Is.True); + Assert.That(HasStaticWrite("AfterDivergingWithClone"), Is.False); + Assert.That(HasStaticWrite("DeconstructionFailure"), Is.True); + Assert.That( + HasStaticWrite("AfterDivergingDeconstruction"), + Is.False); + Assert.That( + HasStaticWrite("AfterDivergingDeconstructionSetter"), + Is.False); + Assert.That(HasStaticWrite("NullLockWrongCatch"), Is.False); + Assert.That(HasStaticWrite("NullLockCorrectCatch"), Is.True); + Assert.That(HasStaticWrite("NullEventWrongCatch"), Is.False); + Assert.That(HasStaticWrite("NullEventCorrectCatch"), Is.True); + Assert.That(HasStaticWrite("NullAwaitWrongCatch"), Is.False); + Assert.That(HasStaticWrite("NullAwaitCorrectCatch"), Is.True); + Assert.That( + HasStaticWrite("NullAwaitAfterThrowingOperand"), + Is.False); + Assert.That(HasStaticWrite("NullCustomAwaiterCatch"), Is.True); + Assert.That( + session.Analyze(EffectTestHost.RequireMethod( + compilation, + "GenericStaticBomb`1", + "GenericStaticProbe")) + .Summary.Writes.Regions.Contains(EffectRegionId.Static()), + Is.True); + Assert.That(HasStaticWrite("ThrowingFilter"), Is.False); + Assert.That(HasStaticWrite("Rethrow"), Is.True); + Assert.That(HasStaticWrite("FinallyRuns"), Is.True); + var reverseDisposal = session.Analyze( + Method(compilation, "ThrowingDeclaratorsUnwindInReverse")); + Assert.That( + reverseDisposal.Summary.Writes.Contains( + EffectRegionId.Parameter(0)), + Is.True); + Assert.That( + reverseDisposal.Summary.Writes.Contains( + EffectRegionId.Parameter(1)), + Is.True); + var recursiveDisposal = session.Analyze( + Method(compilation, "RecursiveDeclaratorDoesNotUnwindOuter")); + Assert.That( + recursiveDisposal.Summary.Writes.Contains( + EffectRegionId.Parameter(0)), + Is.False); + var recursiveThrowingDisposal = session.Analyze(Method( + compilation, + "RecursiveThrowingDeclaratorMayUnwindOuter")); + Assert.That( + recursiveThrowingDisposal.Summary.Writes.Contains( + EffectRegionId.Parameter(0)), + Is.True); + } + + bool HasStaticWrite(string methodName) + { + var summary = session.Analyze(Method(compilation, methodName)).Summary; + return summary.Writes.Contains(EffectRegionId.Static()); + } + } [Test] public void ExceptionFlowReportsOnlyExceptionsThatEscape() @@ -5814,6 +6970,13 @@ public void BoxingReceiver() { """); var session = new EffectAnalysisSession(compilation); + var throwingSummary = session.Analyze( + Method(compilation, "ThrowingObjectReceiver")).Summary; + Assert.That( + throwingSummary.Capabilities.Contains( + EffectCapabilityKind.Synchronization), + Is.False); + AssertKinds( "ObjectReceiver", "managed-allocation", @@ -6065,6 +7228,459 @@ private static string ResultKey(EffectMethodResult result) result.Method.Parameters.Length; } + [Test] + public void ThrowingAssignmentValueSuppressesTargetWrite() + { + var result = Analyze( + """ + public static class Sample { + private static int s_state; + + public static void Assign() { + s_state = Fail(); + s_state++; + } + + private static int Fail() => + throw new System.InvalidOperationException(); + } + """, + "Sample", + "Assign"); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Static()), + Is.False); + Assert.That( + result.Summary.Completeness, + Is.EqualTo(EffectCompleteness.Complete)); + } + } + + [Test] + public void AssignmentTargetEvaluationPrecedesThrowingValue() + { + var result = Analyze( + """ + public sealed class Box { + public int[] Values = new int[1]; + } + + public static class Sample { + private static int s_state; + + public static void Assign(Box box) { + box.Values[RecordIndex()] = Fail(); + } + + private static int RecordIndex() { + s_state++; + return 0; + } + + private static int Fail() => + throw new System.InvalidOperationException(); + } + """, + "Sample", + "Assign"); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Static()), + Is.True); + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Parameter(0)), + Is.False); + Assert.That( + result.Summary.Completeness, + Is.EqualTo(EffectCompleteness.Complete)); + } + } + + [Test] + public void ThrowingLeftBinaryOperandSuppressesRightOperandEffects() + { + var result = Analyze( + """ + public static class Sample { + private static int s_state; + + public static int Evaluate() => Fail() + Mutate(); + + private static int Fail() => + throw new System.InvalidOperationException(); + + private static int Mutate() { + s_state++; + return 1; + } + } + """, + "Sample", + "Evaluate"); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Static()), + Is.False); + Assert.That( + result.Summary.Completeness, + Is.EqualTo(EffectCompleteness.Complete)); + } + } + + [Test] + public void ThrowingOperatorsAndInterpolationHolesSuppressLaterEffects() + { + var compilation = EffectTestHost.CreateCompilation( + """ + public readonly struct Source { + public static Source operator -(Source value) => + throw new System.InvalidOperationException(); + public static Source operator +(Source left, Source right) => + throw new System.InvalidOperationException(); + public static Source operator ++(Source value) => + throw new System.InvalidOperationException(); + public static explicit operator Target(Source value) => + throw new System.InvalidOperationException(); + } + + public readonly struct Target { + } + + public readonly struct Formatted { + public override string ToString() => + throw new System.InvalidOperationException(); + } + + public static class Sample { + private static int s_state; + + public static void Unary(Source value) { + _ = -value; + s_state++; + } + + public static void Binary(Source value) { + _ = value + value; + s_state++; + } + + public static void Increment(Source value) { + value++; + s_state++; + } + + public static void Conversion(Source value) { + _ = (Target)value; + s_state++; + } + + public static string Interpolation() => + $"{Fail()}{Mutate()}"; + + public static string InterpolationFormatting(Formatted value) => + $"{value}{Mutate()}"; + + private static int Fail() => + throw new System.InvalidOperationException(); + + private static int Mutate() { + s_state++; + return 1; + } + } + """); + var session = new EffectAnalysisSession(compilation); + + using (Assert.EnterMultipleScope()) + { + foreach (var methodName in new[] { + "Unary", "Binary", "Conversion", "Interpolation", + "InterpolationFormatting", "Increment" + }) + { + var result = session.Analyze(Method(compilation, methodName)); + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Static()), + Is.False, + methodName); + Assert.That( + result.Summary.Completeness, + Is.EqualTo(EffectCompleteness.Complete), + methodName); + } + } + } + + [Test] + public void ThrowingIncrementAndConstructorArgumentsSuppressLaterEffects() + { + var compilation = EffectTestHost.CreateCompilation( + """ + public struct Counter { + public static Counter operator ++(Counter value) => + throw new System.InvalidOperationException(); + } + + public sealed class Box { + public Box(int value) { } + } + + public sealed class Sample { + private Counter _counter; + private static int s_state; + + public void Increment() { + _counter++; + s_state++; + } + + public static Box Allocate() => new Box(Fail()); + + private static int Fail() => throw null!; + } + """); + var session = new EffectAnalysisSession(compilation); + var increment = session.Analyze(Method(compilation, "Increment")); + var allocation = session.Analyze(Method(compilation, "Allocate")); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + increment.Summary.Writes.Contains(EffectRegionId.Receiver), + Is.False); + Assert.That( + increment.Summary.Writes.Contains(EffectRegionId.Static()), + Is.False); + Assert.That( + allocation.Summary.Allocation, + Is.EqualTo(EffectAllocationKind.None)); + } + } + + [Test] + public void FailingThrowExpressionsStaySequenced() + { + var compilation = EffectTestHost.CreateCompilation( + """ + using System; + + public static class Sample { + public static void OuterThrow() => throw Make(); + + private static InvalidOperationException Make() => + throw new ArgumentException(); + } + """); + var session = new EffectAnalysisSession(compilation); + var outerThrow = session.Analyze(Method(compilation, "OuterThrow")); + var invalidOperation = compilation.GetTypeByMetadataName( + "System.InvalidOperationException")!; + var argument = compilation.GetTypeByMetadataName( + "System.ArgumentException")!; + + using (Assert.EnterMultipleScope()) + { + Assert.That( + outerThrow.Summary.Throws.Types.Any(type => + SymbolEqualityComparer.Default.Equals( + type, + invalidOperation)), + Is.False); + Assert.That( + outerThrow.Summary.Throws.Types.Any(type => + SymbolEqualityComparer.Default.Equals(type, argument)), + Is.True); + } + } + + [Test] + public void FailingAnonymousInitializerDoesNotAllocate() + { + var compilation = EffectTestHost.CreateCompilation( + """ + public static class Sample { + public static object Anonymous() => + new { Value = Fail() }; + + private static int Fail() => throw null!; + } + """); + var result = new EffectAnalysisSession(compilation) + .Analyze(Method(compilation, "Anonymous")); + + Assert.That( + result.Summary.Allocation, + Is.EqualTo(EffectAllocationKind.None)); + } + + [Test] + public void FailingManagedAllocationsStopEnclosingSequences() + { + var compilation = EffectTestHost.CreateCompilation( + """ + using System; + + public sealed class Box { + public void Run() { } + } + + public static class Sample { + public static object Anonymous() { + var value = new { Value = FailValue() }; + return new object(); + } + + public static object Delegate() { + Action value = FailReceiver().Run; + return new object(); + } + + public static object NullDelegate() { + Box value = null!; + Action callback = value.Run; + return new object(); + } + + private static int FailValue() => throw null!; + + private static Box FailReceiver() => throw null!; + } + """); + var session = new EffectAnalysisSession(compilation); + + foreach (var methodName in new[] { + "Anonymous", "Delegate", "NullDelegate" + }) + { + var method = Method(compilation, methodName); + Assert.That( + session.Analyze(method) + .Summary.Allocation, + Is.EqualTo(EffectAllocationKind.None), + methodName); + } + Assert.That( + session.Analyze(Method(compilation, "NullDelegate")) + .Summary.Throws.Types.Any(type => + type.ToDisplayString() == "System.NullReferenceException"), + Is.True); + } + + [Test] + public void ThrowingCoalesceTargetSuppressesValueAndWriteEffects() + { + var compilation = EffectTestHost.CreateCompilation( + """ + public sealed class Box { + public object? Value { get; set; } + } + + public static class Sample { + private static object? s_state; + + public static void Evaluate() { + Box box = null!; + box.Value ??= Mutate(); + } + + private static object Mutate() => + s_state = new object(); + } + """); + var result = new EffectAnalysisSession(compilation) + .Analyze(Method(compilation, "Evaluate")); + + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Static()), + Is.False); + } + + [Test] + public void ThrowingArrayLengthReceiverSuppressesAccessEffects() + { + var compilation = EffectTestHost.CreateCompilation( + """ + using System; + + public static class Sample { + public static int Read() => Fail().Length; + + private static int[] Fail() => + throw new InvalidOperationException(); + } + """); + var result = new EffectAnalysisSession(compilation) + .Analyze(Method(compilation, "Read")); + var nullReference = compilation.GetTypeByMetadataName( + "System.NullReferenceException")!; + + using (Assert.EnterMultipleScope()) + { + Assert.That( + result.Summary.Throws.Types.Any(type => + SymbolEqualityComparer.Default.Equals( + type, + nullReference)), + Is.False); + Assert.That(result.Summary.Reads.Regions, Is.Empty); + } + } + + [Test] + public void FailingCompoundTargetReadSuppressesValueEffects() + { + var compilation = EffectTestHost.CreateCompilation( + """ + public sealed class Box { + public int Value; + } + + public static class Sample { + private static int s_state; + + public static int Evaluate() { + Box box = null!; + return box.Value += Mutate(); + } + + public static void EvaluateThenContinue() { + Box box = null!; + box.Value += 1; + Mutate(); + } + + private static int Mutate() { + s_state++; + return 1; + } + } + """); + var session = new EffectAnalysisSession(compilation); + + foreach (var methodName in new[] { + "Evaluate", + "EvaluateThenContinue" + }) + { + var result = session.Analyze(Method(compilation, methodName)); + Assert.That( + result.Summary.Writes.Contains(EffectRegionId.Static()), + Is.False, + methodName); + Assert.That( + result.Summary.Completeness, + Is.EqualTo(EffectCompleteness.Complete), + methodName); + } + } + [Test] public void EffectsAfterDefiniteNoncompletionAreSuppressed() { diff --git a/SharpProof.Effects/ConversionOwnershipClassifier.cs b/SharpProof.Effects/ConversionOwnershipClassifier.cs index a3f9f8562..1a5f85a8d 100644 --- a/SharpProof.Effects/ConversionOwnershipClassifier.cs +++ b/SharpProof.Effects/ConversionOwnershipClassifier.cs @@ -3,6 +3,7 @@ namespace SharpProof.Effects; internal sealed class ConversionOwnershipClassifier { private readonly CoalesceAssignmentFlowCaptures _coalesceCaptures; + private readonly Compilation _compilation; private readonly CreationFlowCaptures _creationCaptures; private readonly Dictionary _localRegions = new(SymbolEqualityComparer.Default); @@ -10,10 +11,12 @@ internal sealed class ConversionOwnershipClassifier internal ConversionOwnershipClassifier( IMethodSymbol method, + Compilation compilation, CoalesceAssignmentFlowCaptures coalesceCaptures, CreationFlowCaptures creationCaptures) { _method = method; + _compilation = compilation; _coalesceCaptures = coalesceCaptures; _creationCaptures = creationCaptures; } @@ -46,8 +49,17 @@ when _coalesceCaptures.TryResolve( ClassifyRegion(captured, aliasSource), IFlowCaptureReferenceOperation => EffectRegionSet.Unknown, IFieldReferenceOperation { Field.IsStatic: true } => EffectRegionSet.Create(EffectRegionId.Static()), + IFieldReferenceOperation + { + Field.RefKind: not RefKind.None, + Instance: { } fieldInstance + } when aliasSource => + ClassifyRegion(fieldInstance, aliasSource), IFieldReferenceOperation or IArrayElementReferenceOperation => EffectRegionSet.Unknown, + IObjectCreationOperation creation + when creation.Type?.IsRefLikeType == true => + EffectRegionSet.Unknown, IOperation creation when creation is IObjectCreationOperation or IArrayCreationOperation => EffectRegionSet.Create(EffectRegionId.Fresh(creation.Syntax.SpanStart)), @@ -64,30 +76,44 @@ IOperation creation when creation is internal EffectRegionSet ClassifyParameter(IParameterSymbol parameter) { + EffectRegionSet declaredRegion; if (PrimaryConstructorParameterOwnership.IsReceiverBacked( parameter, _method)) { - return EffectRegionSet.Create(EffectRegionId.Receiver); + declaredRegion = EffectRegionSet.Create(EffectRegionId.Receiver); } - - if (SymbolEqualityComparer.Default.Equals( + else if (SymbolEqualityComparer.Default.Equals( parameter.ContainingSymbol?.OriginalDefinition, _method.OriginalDefinition)) { if (parameter.Type.IsValueType && + !parameter.Type.IsRefLikeType && parameter.RefKind == RefKind.None) { - return EffectRegionSet.Empty; + declaredRegion = EffectRegionSet.Empty; } - - return EffectRegionSet.Create(EffectRegionId.Parameter(parameter.Ordinal)); + else + { + declaredRegion = EffectRegionSet.Create( + EffectRegionId.Parameter(parameter.Ordinal)); + } + } + else + { + declaredRegion = EffectRegionSet.Create( + EffectRegionId.Captured(parameter.Ordinal)); } - return EffectRegionSet.Create(EffectRegionId.Captured(parameter.Ordinal)); + return parameter.Type.IsRefLikeType && + _localRegions.TryGetValue(parameter, out var learnedRegions) + ? declaredRegion.Union(learnedRegions) + : declaredRegion; } - internal void BuildLocalRegions(IOperation root) + internal void BuildLocalRegions( + IOperation root, + Func isReachable) { var relevant = root.DescendantsAndSelf() .Where(operation => !IsInsideNestedCallable(operation, root)) @@ -106,12 +132,216 @@ internal void BuildLocalRegions(IOperation root) changed = false; foreach (var operation in relevant) { - (ILocalSymbol? Target, IOperation? Value) source = operation switch + if (!isReachable(operation)) + { + continue; + } + + if (operation is IInvocationOperation invocation) + { + var argumentRegions = EffectRegionSet.Empty; + var refLikeTargets = new List(); + foreach (var argument in invocation.Arguments) + { + var canRebind = argument.Parameter?.RefKind is + RefKind.Ref or RefKind.Out; + if (canRebind || + argument.Value.Type?.IsRefLikeType == true) + { + argumentRegions = argumentRegions.Union( + ClassifyRegion( + argument.Value, + aliasSource: true)); + if (canRebind && TryGetRefLikeStorageSymbol( + argument.Value, + out var argumentTarget)) + { + refLikeTargets.Add(argumentTarget); + } + } + } + + if (invocation.Instance is { } invocationInstance && + invocationInstance.Type?.IsRefLikeType == true) + { + argumentRegions = argumentRegions.Union( + ClassifyRegion( + invocationInstance, + aliasSource: true)); + } + + if (invocation.Instance is { } invocationInstanceLocal && + TryGetRefLikeStorageSymbol( + invocationInstanceLocal, + out var receiver)) + { + refLikeTargets.Add(receiver); + } + + if (refLikeTargets.Count != 0 && + MethodMayIntroduceUnknownRefAlias( + invocation.TargetMethod)) + { + argumentRegions = argumentRegions.Union( + EffectRegionSet.Unknown); + } + + foreach (var refLikeTarget in refLikeTargets) + { + var previousReceiverRegions = + _localRegions.TryGetValue( + refLikeTarget, + out var receiverRegions) + ? receiverRegions + : EffectRegionSet.Empty; + var joinedReceiverRegions = + previousReceiverRegions.Union(argumentRegions); + if (joinedReceiverRegions != previousReceiverRegions) + { + _localRegions[refLikeTarget] = + joinedReceiverRegions; + changed = true; + } + } + } + + if (TryGetPropertySetter( + operation, + out var property, + out var storedValue, + out var valueIsStoredDirectly) && + property is + { + Instance: { } propertyInstance, + Property.SetMethod: { } setter + } && + TryGetRefLikeStorageSymbol( + propertyInstance, + out var propertyReceiver)) + { + var setterRegions = ClassifyRegion( + propertyInstance, + aliasSource: true); + if (storedValue?.Type?.IsRefLikeType == true) + { + setterRegions = setterRegions.Union(ClassifyRegion( + storedValue, + aliasSource: true)); + } + if (!valueIsStoredDirectly && + property.Type?.IsRefLikeType == true) + { + setterRegions = setterRegions.Union( + EffectRegionSet.Unknown); + } + foreach (var argument in property.Arguments) + { + if (argument.Parameter?.RefKind is + RefKind.Ref or RefKind.Out || + argument.Value.Type?.IsRefLikeType == true) + { + setterRegions = setterRegions.Union(ClassifyRegion( + argument.Value, + aliasSource: true)); + } + } + if (MethodMayIntroduceUnknownRefAlias(setter)) + { + setterRegions = setterRegions.Union( + EffectRegionSet.Unknown); + } + + var previousRegions = _localRegions.TryGetValue( + propertyReceiver, + out var existingRegions) + ? existingRegions + : EffectRegionSet.Empty; + var joinedRegions = previousRegions.Union(setterRegions); + if (joinedRegions != previousRegions) + { + _localRegions[propertyReceiver] = joinedRegions; + changed = true; + } + } + + if (operation is IPropertyReferenceOperation + { + Instance: { } getterInstance, + Property.GetMethod: { } getter + } propertyAccess && + !IsSimpleSetterTarget(propertyAccess) && + TryGetRefLikeStorageSymbol( + getterInstance, + out var getterReceiver)) + { + var getterRegions = ClassifyRegion( + getterInstance, + aliasSource: true); + foreach (var argument in propertyAccess.Arguments) + { + if (argument.Parameter?.RefKind is + RefKind.Ref or RefKind.Out || + argument.Value.Type?.IsRefLikeType == true) + { + getterRegions = getterRegions.Union(ClassifyRegion( + argument.Value, + aliasSource: true)); + } + } + if (MethodMayIntroduceUnknownRefAlias(getter)) + { + getterRegions = getterRegions.Union( + EffectRegionSet.Unknown); + } + + var previousRegions = _localRegions.TryGetValue( + getterReceiver, + out var existingRegions) + ? existingRegions + : EffectRegionSet.Empty; + var joinedRegions = previousRegions.Union(getterRegions); + if (joinedRegions != previousRegions) + { + _localRegions[getterReceiver] = joinedRegions; + changed = true; + } + } + + (ISymbol? Target, IOperation? Value) source = operation switch { IVariableDeclaratorOperation declarator => (declarator.Symbol, declarator.Initializer?.Value), - IAssignmentOperation { Target: ILocalReferenceOperation local } assignment => + ISimpleAssignmentOperation + { Target: ILocalReferenceOperation local } assignment => (local.Local, assignment.Value), + ISimpleAssignmentOperation + { Target: IParameterReferenceOperation parameter } assignment => + (parameter.Parameter, assignment.Value), + ICompoundAssignmentOperation + { Target: { } target } assignment + when TryGetRefLikeStorageSymbol( + target, + out var compoundTarget) => + (compoundTarget, assignment), + IIncrementOrDecrementOperation + { Target: { } target } increment + when TryGetRefLikeStorageSymbol( + target, + out var incrementTarget) => + (incrementTarget, increment), + ISimpleAssignmentOperation + { + IsRef: true, + Target: IFieldReferenceOperation + { + Field.RefKind: not RefKind.None, + Instance: { } instance + } + } assignment + when TryGetRefLikeStorageSymbol( + instance, + out var target) => + (target, assignment.Value), _ => default }; if (source.Value == null || source.Target == null) @@ -119,7 +349,23 @@ internal void BuildLocalRegions(IOperation root) continue; } - var discovered = ClassifyRegion(source.Value, aliasSource: true); + var targetType = source.Target switch + { + ILocalSymbol local => local.Type, + IParameterSymbol parameter => parameter.Type, + _ => null + }; + var targetRefKind = source.Target switch + { + ILocalSymbol local => local.RefKind, + IParameterSymbol parameter => parameter.RefKind, + _ => RefKind.None + }; + var discovered = targetType?.IsValueType == true && + !targetType.IsRefLikeType && + targetRefKind == RefKind.None + ? EffectRegionSet.Empty + : ClassifyRegion(source.Value, aliasSource: true); var previous = _localRegions.TryGetValue(source.Target, out var existing) ? existing : EffectRegionSet.Empty; @@ -135,6 +381,75 @@ internal void BuildLocalRegions(IOperation root) } } + private static bool IsSimpleSetterTarget( + IPropertyReferenceOperation property) + { + return property.Parent is ISimpleAssignmentOperation assignment && + ReferenceEquals(assignment.Target, property); + } + + private static bool TryGetRefLikeStorageSymbol( + IOperation operation, + out ISymbol symbol) + { + operation = DefiniteOperationFacts.UnwrapHarmlessValue(operation); + switch (operation) + { + case ILocalReferenceOperation local + when local.Local.Type.IsRefLikeType: + symbol = local.Local; + return true; + case IParameterReferenceOperation parameter + when parameter.Parameter.Type.IsRefLikeType: + symbol = parameter.Parameter; + return true; + default: + symbol = null!; + return false; + } + } + + private static bool TryGetPropertySetter( + IOperation operation, + out IPropertyReferenceOperation? property, + out IOperation? storedValue, + out bool valueIsStoredDirectly) + { + switch (operation) + { + case ISimpleAssignmentOperation + { Target: IPropertyReferenceOperation target } assignment: + property = target; + storedValue = assignment.Value; + valueIsStoredDirectly = true; + break; + case ICoalesceAssignmentOperation + { Target: IPropertyReferenceOperation target } assignment: + property = target; + storedValue = assignment.Value; + valueIsStoredDirectly = true; + break; + case ICompoundAssignmentOperation + { Target: IPropertyReferenceOperation target } assignment: + property = target; + storedValue = assignment.Value; + valueIsStoredDirectly = false; + break; + case IIncrementOrDecrementOperation + { Target: IPropertyReferenceOperation target }: + property = target; + storedValue = null; + valueIsStoredDirectly = false; + break; + default: + property = null; + storedValue = null; + valueIsStoredDirectly = false; + break; + } + return property?.Property.SetMethod != null; + } + internal static bool IsInsideNestedCallable(IOperation operation, IOperation root) { for (var parent = operation.Parent; parent != null && !ReferenceEquals(parent, root); parent = parent.Parent) @@ -148,6 +463,61 @@ internal static bool IsInsideNestedCallable(IOperation operation, IOperation roo return false; } + private bool MethodMayIntroduceUnknownRefAlias(IMethodSymbol method) + { + method = method.ReducedFrom ?? method; + if (method.DeclaringSyntaxReferences.Length != 1) + { + return true; + } + + var declaration = method.DeclaringSyntaxReferences[0].GetSyntax(); + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(_compilation, declaration.SyntaxTree); + var root = model.GetOperation(declaration); + if (root == null) + { + return true; + } + + foreach (var assignment in root.DescendantsAndSelf() + .OfType() + .Where(static assignment => assignment.IsRef)) + { + if (!IsCallMappedRefSource(assignment.Value, method)) + { + return true; + } + } + + return false; + } + + private static bool IsCallMappedRefSource( + IOperation operation, + IMethodSymbol method) + { + operation = DefiniteOperationFacts.UnwrapHarmlessValue(operation); + return operation switch + { + IInstanceReferenceOperation => true, + IParameterReferenceOperation parameter => + SymbolEqualityComparer.Default.Equals( + parameter.Parameter.ContainingSymbol.OriginalDefinition, + method.OriginalDefinition), + IFieldReferenceOperation { Field.IsStatic: false, Instance: { } instance } => + IsCallMappedRefSource(instance, method), + IConditionalOperation + { + WhenTrue: { } whenTrue, + WhenFalse: { } whenFalse + } => + IsCallMappedRefSource(whenTrue, method) && + IsCallMappedRefSource(whenFalse, method), + _ => false + }; + } + private EffectRegionSet ClassifyConversionRegion( IConversionOperation operation, bool aliasSource) { diff --git a/SharpProof.Effects/EffectAnalysisSession.cs b/SharpProof.Effects/EffectAnalysisSession.cs index dfa5cbd79..fd0dec854 100644 --- a/SharpProof.Effects/EffectAnalysisSession.cs +++ b/SharpProof.Effects/EffectAnalysisSession.cs @@ -146,6 +146,7 @@ public ImmutableArray AnalyzeAll( internal EffectSummary ResolveCall( IMethodSymbol caller, IMethodSymbol target, EffectRegionSet receiver, + EffectRegionSet writeReceiver, ImmutableArray arguments, bool dispatchUncertain, List sourceCalls, IOperation origin, IOperation? instance, @@ -158,6 +159,7 @@ internal EffectSummary ResolveCall( instance = null; arguments = arguments.Insert(0, receiver); receiver = EffectRegionSet.Empty; + writeReceiver = EffectRegionSet.Empty; } var normalized = NormalizeMethod(target); var preconditions = _callPreconditions.Assess( @@ -186,7 +188,12 @@ internal EffectSummary ResolveCall( if (IsSourceMethod(normalized)) { - sourceCalls.Add(new EffectCallSite(normalized, receiver, arguments, origin)); + sourceCalls.Add(new EffectCallSite( + normalized, + receiver, + writeReceiver, + arguments, + origin)); return EffectSummaryOperations.Join( preconditionEvidence, EffectSummaryOperations.DirectCall()); @@ -194,7 +201,11 @@ internal EffectSummary ResolveCall( return EffectSummaryOperations.Join( preconditionEvidence, EffectSummaryOperations.DirectCall(), - EffectSummaryOperations.Remap(_external.Resolve(normalized), receiver, arguments)); + EffectSummaryOperations.Remap( + _external.Resolve(normalized), + receiver, + writeReceiver, + arguments)); } internal EffectSummary ResolveEntryPreconditions( @@ -248,16 +259,77 @@ internal EffectSummary ResolveStaticFieldTypeInitialization( { return EffectSummary.Empty; } + if (OperationCompletionEvaluator + .CanAssumeStaticInitializationComplete(caller, field)) + { + return EffectSummary.Empty; + } var isSourceType = SymbolEqualityComparer.Default.Equals( normalizedTarget.ContainingAssembly, _compilation.Assembly); + if (isSourceType && + field.ContainingType.IsGenericType && + !SymbolEqualityComparer.Default.Equals( + caller.ContainingType, + field.ContainingType) && + normalizedTarget.StaticConstructors.Any( + static constructor => !constructor.IsImplicitlyDeclared)) + { + return EffectSummaryOperations.Throw( + ResolveExceptionSet( + FrameworkTypeMetadataNames.TypeInitializationException)); + } var mayInitialize = !isSourceType || EffectMethodNodeBuilder.HasPotentialStaticInitialization( normalizedTarget, ApiSpecs); - return mayInitialize - ? EffectSummaryOperations.UnknownBoundary(EffectUncertainty.UnmodeledCall) - : EffectSummary.Empty; + if (!mayInitialize || + isSourceType && StaticInitializationCannotComplete(normalizedTarget)) + { + return EffectSummary.Empty; + } + return EffectSummaryOperations.UnknownBoundary( + EffectUncertainty.UnmodeledCall); + } + + private bool StaticInitializationCannotComplete(INamedTypeSymbol type) + { + var facts = new DefiniteOperationFacts( + _compilation, + CancellationToken.None); + foreach (var member in type.GetMembers()) + { + var isStaticInitializable = member switch + { + IFieldSymbol field => field.IsStatic && !field.IsConst, + IPropertySymbol property => property.IsStatic, + IEventSymbol @event => @event.IsStatic, + _ => false + }; + if (!isStaticInitializable) + { + continue; + } + foreach (var reference in member.DeclaringSyntaxReferences) + { + var expression = EffectProjections.GetInitializerExpression( + reference.GetSyntax()); + if (expression == null) + { + continue; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(_compilation, expression.SyntaxTree); + if (model.GetOperation(expression) is { } operation && + !facts.MayCompleteNormally(operation)) + { + return true; + } + } + } + return type.StaticConstructors.Any( + constructor => constructor.DeclaringSyntaxReferences.Length != 0 && + !facts.MethodCanCompleteNormally(constructor)); } private void EnsureAnalyzed( @@ -331,7 +403,10 @@ EffectSummary Compute(IMethodSymbol method) { summary = EffectSummaryDomain.Instance.Join(summary, EffectExceptionFlow.KeepEscaping(EffectSummaryOperations.Remap( - Compute(call.Target), call.Receiver, call.Arguments), + Compute(call.Target), + call.Receiver, + call.WriteReceiver, + call.Arguments), call.Origin, _compilation)); } diff --git a/SharpProof.Effects/EffectCallSiteResolver.cs b/SharpProof.Effects/EffectCallSiteResolver.cs index aaf7af70f..dce766f53 100644 --- a/SharpProof.Effects/EffectCallSiteResolver.cs +++ b/SharpProof.Effects/EffectCallSiteResolver.cs @@ -23,11 +23,35 @@ internal EffectSummary Resolve( IOperation origin, IOperation? instance, IEnumerable? callArguments = null) + { + return Resolve( + target, + receiver, + receiver, + arguments, + actualArguments, + dispatchUncertain, + origin, + instance, + callArguments); + } + + internal EffectSummary Resolve( + IMethodSymbol target, + EffectRegionSet receiver, + EffectRegionSet writeReceiver, + ImmutableArray arguments, + ImmutableArray actualArguments, + bool dispatchUncertain, + IOperation origin, + IOperation? instance, + IEnumerable? callArguments = null) { var summary = _session.ResolveCall( _caller, target, receiver, + writeReceiver, arguments, dispatchUncertain, _sourceCalls, @@ -54,6 +78,7 @@ internal EffectSummary ResolveOperator( : Resolve( target, receiver, + receiver, arguments, actualArguments, dispatchUncertain: false, @@ -71,7 +96,6 @@ internal EffectSummary ResolveConstruction( { return EffectSummaryOperations.Unsupported(); } - var implicitLayers = EffectSummary.Empty; var implicitDepth = 0; while (EffectMethodNodeBuilder.IsProvablyEmptyImplicitConstructorLayer( @@ -107,9 +131,13 @@ internal EffectSummary ResolveConstruction( return EffectSummaryOperations.Join( implicitLayers, + HasExplicitSourceTypeInitialization(constructor) + ? EffectSummaryOperations.TypeInitializationBoundary() + : EffectSummary.Empty, Resolve( constructor, receiver, + receiver, arguments, AlignActualArguments( creation.Arguments, @@ -120,6 +148,18 @@ internal EffectSummary ResolveConstruction( creation.Arguments)); } + private bool HasExplicitSourceTypeInitialization(IMethodSymbol constructor) + { + return SymbolEqualityComparer.Default.Equals( + constructor.ContainingAssembly, + _session.Compilation.Assembly) && + EffectMethodNodeBuilder.HasPotentialStaticInitialization( + constructor.ContainingType, + _session.ApiSpecs) && + constructor.ContainingType.StaticConstructors.Any( + static candidate => !candidate.IsImplicitlyDeclared); + } + internal static ImmutableArray AlignActualArguments( ImmutableArray arguments, int parameterCount) diff --git a/SharpProof.Effects/EffectContractMappings.catalog.json b/SharpProof.Effects/EffectContractMappings.catalog.json index cf575e4ae..966922670 100644 --- a/SharpProof.Effects/EffectContractMappings.catalog.json +++ b/SharpProof.Effects/EffectContractMappings.catalog.json @@ -124,6 +124,7 @@ {"name":"EffectCallSite","access":"internal","parameters":[ {"type":"IMethodSymbol","name":"Target"}, {"type":"EffectRegionSet","name":"Receiver"}, + {"type":"EffectRegionSet","name":"WriteReceiver"}, {"type":"ImmutableArray","name":"Arguments"}, {"type":"IOperation","name":"Origin"} ]}, diff --git a/SharpProof.Effects/EffectContractMappings.generated.cs b/SharpProof.Effects/EffectContractMappings.generated.cs index 71de486e8..1d1855a92 100644 --- a/SharpProof.Effects/EffectContractMappings.generated.cs +++ b/SharpProof.Effects/EffectContractMappings.generated.cs @@ -159,6 +159,7 @@ public enum EffectRegionKind internal readonly record struct EffectCallSite( IMethodSymbol Target, EffectRegionSet Receiver, + EffectRegionSet WriteReceiver, ImmutableArray Arguments, IOperation Origin ); diff --git a/SharpProof.Effects/EffectExceptionFlow.cs b/SharpProof.Effects/EffectExceptionFlow.cs index 9cf0a0195..18fee6bc4 100644 --- a/SharpProof.Effects/EffectExceptionFlow.cs +++ b/SharpProof.Effects/EffectExceptionFlow.cs @@ -66,6 +66,24 @@ internal static EffectThrowSet ResolveRethrow(IOperation operation) return EffectThrowSet.Unknown; } + internal static EffectThrowSet KeepEscapingThroughTry( + EffectThrowSet thrown, + TryStatementSyntax @try, + Compilation compilation) + { + var known = thrown.Types; + var includesUnknown = thrown.IncludesUnknown; + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, @try.SyntaxTree); + ApplyCatches( + @try, + model, + ref known, + ref includesUnknown, + includeRethrows: false); + return EffectThrowSet.Create(known, includesUnknown); + } + private static EffectThrowSet KeepEscaping( EffectThrowSet thrown, SyntaxNode origin, Compilation compilation) { @@ -94,7 +112,12 @@ ancestor is not AnonymousFunctionExpressionSyntax and var inHandler = @try.Catches.Any(@catch => @catch.Block.Span.Contains(origin.Span)); if (inBody) { - ApplyCatches(@try, model, ref known, ref includesUnknown); + ApplyCatches( + @try, + model, + ref known, + ref includesUnknown, + includeRethrows: true); } if ((inBody || inHandler) && @@ -113,7 +136,9 @@ @try.Finally is { } @finally && private static void ApplyCatches( TryStatementSyntax @try, SemanticModel model, - ref ImmutableArray known, ref bool includesUnknown) + ref ImmutableArray known, + ref bool includesUnknown, + bool includeRethrows) { var exceptionType = model.Compilation.GetTypeByMetadataName(FrameworkTypeMetadataNames.Exception); var catches = @try.Catches.Select(@catch => @@ -125,7 +150,7 @@ private static void ApplyCatches( return new CatchFlow( caught, filter, - ContainsRethrow(@catch.Block)); + includeRethrows && ContainsRethrow(@catch.Block)); }).ToImmutableArray(); known = [.. known.Where(type => CanEscape(type, catches))]; diff --git a/SharpProof.Effects/EffectMethodNodeBuilder.cs b/SharpProof.Effects/EffectMethodNodeBuilder.cs index db36b0abf..74e900299 100644 --- a/SharpProof.Effects/EffectMethodNodeBuilder.cs +++ b/SharpProof.Effects/EffectMethodNodeBuilder.cs @@ -46,32 +46,57 @@ internal EffectMethodNode Build( allowDirectWitnesses: graph != null && HasDefiniteBodyEntry(method, _session.ApiSpecs)); - var localSummary = graph == null - ? EffectSummaryOperations.Join( - scanner.Scan(root), - EffectSummaryOperations.Unsupported()) - : AnalyzeControlFlowGraph(graph, scanner); - - // Cyclic scalar flow does not invalidate the conservative all-block effect scan. - if (abstractAnalysis is - { - IsComplete: false, - IncompleteReason: not EffectAnalysisIncompleteReason.CyclicControlFlow - }) + var initializers = ScanConstructorMemberInitializers( + method, + scanner, + cancellationToken); + var localSummary = initializers.Summary; + if (initializers.CompletesNormally) { + var bodySummary = graph == null + ? EffectSummaryOperations.Join( + scanner.Scan(root), + EffectSummaryOperations.Unsupported()) + : AnalyzeControlFlowGraph(graph, scanner); + + // Cyclic scalar flow does not invalidate the conservative + // all-block effect scan. + if (abstractAnalysis is + { + IsComplete: false, + IncompleteReason: not + EffectAnalysisIncompleteReason.CyclicControlFlow + }) + { + bodySummary = EffectSummaryOperations.Join( + bodySummary, + EffectSummaryOperations.IncompleteAnalysis( + abstractAnalysis.IncompleteReason)); + } + localSummary = EffectSummaryOperations.Join( localSummary, - EffectSummaryOperations.IncompleteAnalysis( - abstractAnalysis.IncompleteReason)); + bodySummary, + scanner.ScanLexicalControlEffects(root), + scanner.ScanUsingDisposalEffects(root)); } localSummary = EffectSummaryOperations.Join( localSummary, _session.ResolveEntryPreconditions(method), - scanner.ScanLexicalControlEffects(root), - scanner.ScanUsingDisposalEffects(root), - ScanConstructorMemberInitializers(method, scanner, cancellationToken), CanTriggerOwnTypeInitialization(method) && + (!SymbolEqualityComparer.Default.Equals( + method.ContainingAssembly, + _compilation.Assembly) + ? true + : method.MethodKind == MethodKind.Constructor + ? method.ContainingType.StaticConstructors.All( + static constructor => constructor.IsImplicitlyDeclared) + : !method.ContainingType.IsGenericType && + method.ContainingType.StaticConstructors.Any( + constructor => + !constructor.IsImplicitlyDeclared && + StaticConstructorCanAffectEntry(constructor))) && HasPotentialStaticInitialization( method.ContainingType, _session.ApiSpecs) @@ -80,7 +105,7 @@ internal EffectMethodNode Build( return new EffectMethodNode(localSummary, [.. calls], scanner.DirectWitnesses); } - private EffectSummary ScanConstructorMemberInitializers( + private EffectStep ScanConstructorMemberInitializers( IMethodSymbol method, OperationEffectScanner scanner, CancellationToken cancellationToken) @@ -88,43 +113,58 @@ private EffectSummary ScanConstructorMemberInitializers( var staticInitializers = method.MethodKind == MethodKind.StaticConstructor; if (!staticInitializers && method.MethodKind != MethodKind.Constructor) { - return EffectSummary.Empty; + return EffectStep.Empty; } - var summary = EffectSummary.Empty; + var result = EffectStep.Empty; var write = EffectSummaryOperations.Write(EffectRegionSet.Create( staticInitializers ? EffectRegionId.Static() : EffectRegionId.Receiver)); - foreach (var member in method.ContainingType.GetMembers() - .Where(member => !member.IsImplicitlyDeclared && - IsInitializableMember(member, staticInitializers)) - .OrderBy(static member => member.MetadataName, StringComparer.Ordinal)) + var syntaxTreeOrder = _compilation.SyntaxTrees + .Select(static (tree, ordinal) => (tree, ordinal)) + .ToDictionary( + static item => item.tree, + static item => item.ordinal); + var references = method.ContainingType.GetMembers() + .Where(member => !member.IsImplicitlyDeclared && + IsInitializableMember(member, staticInitializers)) + .SelectMany(static member => member.DeclaringSyntaxReferences) + .OrderBy(reference => syntaxTreeOrder.TryGetValue( + reference.SyntaxTree, + out var ordinal) + ? ordinal + : int.MaxValue) + .ThenBy(static reference => reference.Span.Start); + foreach (var syntaxReference in references) { - foreach (var syntaxReference in member.DeclaringSyntaxReferences - .OrderBy( - static reference => reference.SyntaxTree.FilePath, - StringComparer.Ordinal) - .ThenBy(static reference => reference.Span.Start)) + cancellationToken.ThrowIfCancellationRequested(); + var declaration = syntaxReference.GetSyntax(cancellationToken); + var expression = EffectProjections.GetInitializerExpression(declaration); + if (expression == null) { - cancellationToken.ThrowIfCancellationRequested(); - var declaration = syntaxReference.GetSyntax(cancellationToken); - var expression = EffectProjections.GetInitializerExpression(declaration); - if (expression == null) - { - continue; - } + continue; + } - var model = SharpProof.Frontend.Host.CompilationModelProvider - .GetSemanticModel(_compilation, expression.SyntaxTree); - var operation = model.GetOperation(expression, cancellationToken); - summary = EffectSummaryDomain.Instance.Join( - summary, - operation == null - ? EffectSummaryOperations.Unsupported() - : EffectSummaryOperations.Join(scanner.Scan(operation), write)); + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(_compilation, expression.SyntaxTree); + var operation = model.GetOperation(expression, cancellationToken); + if (operation == null) + { + result = result.Then(new EffectStep( + EffectSummaryOperations.Unsupported(), + true)); + continue; + } + + result = result.Then(scanner.ScanSequence([operation])); + if (!result.CompletesNormally) + { + break; } + + result = result.Then(new EffectStep(write, true)); } - return summary; + return result; } internal static bool HasPotentialStaticInitialization( @@ -147,12 +187,14 @@ internal static bool HasPotentialStaticInitialization( return true; } - return type.StaticConstructors.Any() || - type.GetMembers().Any(member => + var result = type.StaticConstructors.Any(static constructor => + constructor.DeclaringSyntaxReferences.Length != 0) || + type.GetMembers().Any(member => !member.IsImplicitlyDeclared && IsInitializableMember(member, staticInitializers: true) && member.DeclaringSyntaxReferences.Any(reference => EffectProjections.GetInitializerExpression(reference.GetSyntax()) != null)); + return result; } internal static bool HasPotentialConstructionInitialization( @@ -244,6 +286,23 @@ private static bool CanTriggerOwnTypeInitialization(IMethodSymbol method) (method.IsStatic || method.ContainingType.IsValueType); } + private bool StaticConstructorCanAffectEntry( + IMethodSymbol constructor) + { + if (constructor.DeclaringSyntaxReferences.Any(reference => + reference.GetSyntax().DescendantNodesAndSelf().Any( + static syntax => syntax is ThrowStatementSyntax or + ThrowExpressionSyntax))) + { + return true; + } + return constructor.DeclaringSyntaxReferences.Length == 0 || + new DefiniteOperationFacts( + _compilation, + CancellationToken.None).MethodCanCompleteNormally( + constructor); + } + private static bool IsInitializableMember( ISymbol member, bool staticInitializers) @@ -263,8 +322,32 @@ private static EffectSummary AnalyzeControlFlowGraph( OperationEffectScanner scanner) { var summary = EffectSummary.Empty; - foreach (var block in graph.Blocks.Where(static block => block.IsReachable)) + var pending = new SortedSet { graph.Blocks[0].Ordinal }; + var exceptionalRegionOperations = + CreateExceptionalRegionOperations(graph); + var finallyEntries = CreateFinallyEntries(graph); + foreach (var block in graph.Blocks.Where(static block => + block.Predecessors.All(static predecessor => + predecessor.Semantics != + ControlFlowBranchSemantics.Regular))) { + if (IsExceptionalEntryReachable(block)) + { + pending.Add(block.Ordinal); + } + } + + var visited = new HashSet(); + while (pending.Count != 0) + { + var ordinal = pending.Min; + pending.Remove(ordinal); + if (!visited.Add(ordinal)) + { + continue; + } + + var block = graph.Blocks[ordinal]; var step = scanner.ScanSequence( block.Operations.Where(scanner.IsReachable)); if (step.CompletesNormally && @@ -275,6 +358,19 @@ private static EffectSummary AnalyzeControlFlowGraph( } summary = EffectSummaryOperations.Join(summary, step.Summary); + if (!step.Summary.Throws.IsEmpty) + { + AddReachableFinallyEntriesForBlock(block); + } + AddControlTransferFinally(block, block.FallThroughSuccessor, step); + AddControlTransferFinally(block, block.ConditionalSuccessor, step); + if (!step.CompletesNormally) + { + continue; + } + + AddRegularSuccessor(block.FallThroughSuccessor); + AddRegularSuccessor(block.ConditionalSuccessor); } return ManagedAbstractFlow.IsAcyclic(graph) @@ -282,6 +378,227 @@ private static EffectSummary AnalyzeControlFlowGraph( : EffectSummaryOperations.Join( summary, EffectSummaryOperations.MayDiverge()); + + void AddRegularSuccessor(ControlFlowBranch? branch) + { + if (branch is + { + Semantics: ControlFlowBranchSemantics.Regular, + Destination: { IsReachable: true } destination + } && LeavingFinallysMayComplete(branch)) + { + pending.Add(destination.Ordinal); + } + } + + bool LeavingFinallysMayComplete(ControlFlowBranch branch) + { + foreach (var region in branch.LeavingRegions) + { + if (finallyEntries.TryGetValue(region, out var entry) && + entry.Operation is { } operation && + !scanner.CanCompleteNormally(operation)) + { + return false; + } + } + return true; + } + + void AddControlTransferFinally( + BasicBlock source, + ControlFlowBranch? branch, + EffectStep step) + { + if (branch == null || + !step.CompletesNormally && + branch.Semantics is not ( + ControlFlowBranchSemantics.Throw or + ControlFlowBranchSemantics.Rethrow)) + { + return; + } + if (branch.Semantics is + ControlFlowBranchSemantics.Throw or + ControlFlowBranchSemantics.Rethrow) + { + AddReachableFinallyEntriesForBlock(source); + return; + } + AddReachableFinallyEntries(branch); + } + + void AddReachableFinallyEntries(ControlFlowBranch branch) + { + foreach (var region in branch.LeavingRegions) + { + if (finallyEntries.TryGetValue(region, out var entry)) + { + pending.Add(entry.EntryOrdinal); + return; + } + } + } + + bool IsExceptionalEntryReachable(BasicBlock block) + { + for (var region = block.EnclosingRegion; + region != null; + region = region.EnclosingRegion) + { + if (region.Kind == ControlFlowRegionKind.Finally) + { + return false; + } + if (exceptionalRegionOperations.TryGetValue( + region, + out var operation)) + { + return scanner.IsReachable(operation); + } + } + return true; + } + + void AddReachableFinallyEntriesForBlock(BasicBlock block) + { + for (var region = block.EnclosingRegion; + region != null; + region = region.EnclosingRegion) + { + if (finallyEntries.TryGetValue(region, out var entry)) + { + pending.Add(entry.EntryOrdinal); + return; + } + } + } + } + + private readonly record struct FinallyEntry( + int EntryOrdinal, + IOperation? Operation); + + private static Dictionary CreateFinallyEntries( + ControlFlowGraph graph) + { + var regions = graph.Blocks + .SelectMany(static block => EnclosingRegions(block.EnclosingRegion)) + .Distinct() + .ToArray(); + var finallyRegions = regions + .Where(static region => + region.Kind == ControlFlowRegionKind.Finally) + .OrderBy(static region => region.FirstBlockOrdinal) + .ToArray(); + var finallyOperations = graph.OriginalOperation.DescendantsAndSelf() + .OfType() + .Where(@try => + @try.Finally != null && + !ConversionOwnershipClassifier.IsInsideNestedCallable( + @try, + graph.OriginalOperation)) + .OrderBy(static @try => @try.Finally!.Syntax.SpanStart) + .Select(static @try => (IOperation)@try.Finally!) + .ToArray(); + var operationByRegion = new Dictionary(); + if (finallyRegions.Length == finallyOperations.Length) + { + for (var index = 0; index < finallyRegions.Length; index++) + { + operationByRegion.Add( + finallyRegions[index], + finallyOperations[index]); + } + } + var result = new Dictionary(); + foreach (var tryRegion in regions.Where(static region => + region.Kind == ControlFlowRegionKind.Try)) + { + var finallyRegion = regions.FirstOrDefault(region => + region.Kind == ControlFlowRegionKind.Finally && + ReferenceEquals( + region.EnclosingRegion, + tryRegion.EnclosingRegion)); + if (finallyRegion != null) + { + result.Add( + tryRegion, + new FinallyEntry( + finallyRegion.FirstBlockOrdinal, + operationByRegion.TryGetValue( + finallyRegion, + out var operation) + ? operation + : null)); + } + } + return result; + + static IEnumerable EnclosingRegions( + ControlFlowRegion? region) + { + for (; region != null; region = region.EnclosingRegion) + { + yield return region; + } + } + } + + private static Dictionary + CreateExceptionalRegionOperations(ControlFlowGraph graph) + { + var regions = graph.Blocks + .SelectMany(static block => EnclosingRegions(block.EnclosingRegion)) + .Distinct() + .ToArray(); + var catches = graph.OriginalOperation.DescendantsAndSelf() + .OfType() + .Where(@catch => + !ConversionOwnershipClassifier.IsInsideNestedCallable( + @catch, + graph.OriginalOperation)) + .OrderBy(static @catch => @catch.Syntax.SpanStart) + .ToArray(); + var result = new Dictionary(); + AddMappings( + regions.Where(static region => + region.Kind == ControlFlowRegionKind.Catch) + .OrderBy(static region => region.FirstBlockOrdinal) + .ToArray(), + catches.Select(static @catch => @catch.Handler).ToArray()); + AddMappings( + regions.Where(static region => + region.Kind == ControlFlowRegionKind.Filter) + .OrderBy(static region => region.FirstBlockOrdinal) + .ToArray(), + catches.Where(static @catch => @catch.Filter != null) + .Select(static @catch => @catch.Filter!) + .ToArray()); + return result; + + void AddMappings( + ControlFlowRegion[] candidates, + IOperation[] operations) + { + if (candidates.Length != operations.Length) + { + return; + } + for (var index = 0; index < candidates.Length; index++) + { + result.Add(candidates[index], operations[index]); + } + } + + static IEnumerable EnclosingRegions( + ControlFlowRegion? region) + { + for (; region != null; region = region.EnclosingRegion) + { + yield return region; + } + } } private IOperation? GetOperationRoot( diff --git a/SharpProof.Effects/EffectSummaryOperations.cs b/SharpProof.Effects/EffectSummaryOperations.cs index fc2d91d2b..64119e4fa 100644 --- a/SharpProof.Effects/EffectSummaryOperations.cs +++ b/SharpProof.Effects/EffectSummaryOperations.cs @@ -53,6 +53,22 @@ internal static EffectSummary WithThrows(EffectSummary summary, EffectThrowSet e summary.AnalysisIncompleteReason); } + internal static EffectSummary ExceptionConstructionThrow( + EffectSummary construction, + EffectThrowSet exceptions) + { + return new EffectSummary( + EffectRegionSet.Empty, + EffectRegionSet.Empty, + EffectAllocationKind.None, + construction.Capabilities, + exceptions, + EffectTermination.Unknown, + construction.Completeness, + construction.Uncertainty, + construction.AnalysisIncompleteReason); + } + internal static EffectSummary Capability(EffectCapabilityKind capabilities) { return Create(capabilities: new EffectCapabilitySet(capabilities)); @@ -72,6 +88,15 @@ internal static EffectSummary UnknownBoundary(EffectUncertainty uncertainty) EffectCompleteness.Incomplete, uncertainty); } + internal static EffectSummary TypeInitializationBoundary() + { + return new( + EffectRegionSet.Empty, EffectRegionSet.Empty, + EffectAllocationKind.Unknown, EffectCapabilitySet.Unknown, + EffectThrowSet.Unknown, EffectTermination.Unknown, + EffectCompleteness.Incomplete, EffectUncertainty.UnmodeledCall); + } + internal static EffectSummary IncompleteAnalysis(EffectAnalysisIncompleteReason reason) { return new( @@ -98,6 +123,15 @@ internal static EffectSummary Remap( EffectSummary summary, EffectRegionSet receiver, ImmutableArray arguments) + { + return Remap(summary, receiver, receiver, arguments); + } + + internal static EffectSummary Remap( + EffectSummary summary, + EffectRegionSet receiver, + EffectRegionSet writeReceiver, + ImmutableArray arguments) { if (summary.IsBottom) { @@ -106,7 +140,7 @@ internal static EffectSummary Remap( return new EffectSummary( RemapRegions(summary.Reads, receiver, arguments), - RemapRegions(summary.Writes, receiver, arguments), + RemapRegions(summary.Writes, writeReceiver, arguments), summary.Allocation, summary.Capabilities, summary.Throws, summary.Termination, summary.Completeness, summary.Uncertainty, summary.AnalysisIncompleteReason); diff --git a/SharpProof.Effects/ExceptionHandlerReachability.cs b/SharpProof.Effects/ExceptionHandlerReachability.cs index d773d966f..7cd125176 100644 --- a/SharpProof.Effects/ExceptionHandlerReachability.cs +++ b/SharpProof.Effects/ExceptionHandlerReachability.cs @@ -1,10 +1,21 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp; namespace SharpProof.Effects; internal sealed class ExceptionHandlerReachability( Compilation compilation, - ManagedFlowResult? abstractFlow) + IMethodSymbol caller, + ManagedFlowResult? abstractFlow, + Func canCompleteNormally, + Func canMethodCompleteNormally, + Func canCompoundValueComplete, + Func canIncrementValueComplete, + Func canWithCloneComplete, + Func> + getReachableListPatternMembers, + ResolvedApiSpecTable apiSpecs, + Func isKnownNonThrowing) { private readonly Dictionary _cache = new(); private readonly INamedTypeSymbol? _exceptionType = @@ -12,92 +23,2655 @@ internal sealed class ExceptionHandlerReachability( private readonly INamedTypeSymbol? _nullReferenceExceptionType = compilation.GetTypeByMetadataName( FrameworkTypeMetadataNames.NullReferenceException); + private readonly INamedTypeSymbol? _argumentNullExceptionType = + compilation.GetTypeByMetadataName( + FrameworkTypeMetadataNames.ArgumentNullException); + private readonly INamedTypeSymbol? _typeInitializationExceptionType = + compilation.GetTypeByMetadataName( + FrameworkTypeMetadataNames.TypeInitializationException); + private readonly INamedTypeSymbol? _switchExpressionExceptionType = + compilation.GetTypeByMetadataName( + FrameworkTypeMetadataNames.SwitchExpressionException); + private readonly DefiniteOperationFacts _staticInitializationFacts = + new(compilation, CancellationToken.None); + + internal bool IsReachable(CatchClauseSyntax target, bool inFilter) + { + var reachability = GetReachability(target); + return inFilter ? reachability.Filter : reachability.Handler; + } + + private CatchReachability GetReachability(CatchClauseSyntax target) + { + if (_cache.TryGetValue(target, out var cached)) + { + return cached; + } + if (target.Parent is not TryStatementSyntax @try) + { + return new CatchReachability(Filter: true, Handler: true); + } + + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, @try.SyntaxTree); + var protectedBlock = model.GetOperation(@try.Block); + if (protectedBlock == null) + { + return new CatchReachability(Filter: true, Handler: true); + } + var potential = GetPotentialExceptions(protectedBlock); + var filterReachable = potential.Unknown && + CanUnknownReach(target, @try, model) || + potential.Known.Any(type => + CanKnownReach(type, target, @try, model)); + var result = new CatchReachability( + filterReachable, + filterReachable && + GetFilterSelection(target, model) != CatchSelection.Never); + _cache.Add(target, result); + return result; + } + + private PotentialExceptions GetPotentialExceptions( + IOperation protectedBlock) + { + return GetPotentialExceptions( + protectedBlock, + new HashSet(SymbolEqualityComparer.Default), + depth: 0, + keepEscaping: false); + } + + private PotentialExceptions GetPotentialExceptions( + IOperation root, + HashSet activeMethods, + int depth, + bool keepEscaping) + { + var known = ImmutableHashSet.CreateBuilder( + SymbolEqualityComparer.Default); + var unknown = false; + var remaining = new Stack(); + var scheduledSwitchBodies = new HashSet(); + var scheduledGotoLabels = new HashSet( + SymbolEqualityComparer.Default); + var forcedGotoOperations = new HashSet(); + var switchCaseReachability = new Dictionary< + ISwitchCaseOperation, + SwitchCaseReachability>(); + remaining.Push(root); + while (remaining.Count != 0) + { + var operation = remaining.Pop(); + if (ManagedAbstractFlow.IsCompileTimeUnreachable( + compilation, + operation) && + !forcedGotoOperations.Contains(operation) && + operation is not IBranchOperation + { + Syntax: GotoStatementSyntax + }) + { + continue; + } + if (operation is IAnonymousFunctionOperation or + ILocalFunctionOperation) + { + continue; + } + if (operation is IBranchOperation branch && + branch.Syntax is GotoStatementSyntax) + { + var targetCase = GetSwitchGotoTargetCase(branch); + if (targetCase != null && + scheduledSwitchBodies.Add(targetCase)) + { + PushSequential(targetCase.Body); + } + if (targetCase != null) + { + continue; + } + var continuation = GetGotoTargetContinuation(branch); + if (continuation != null) + { + foreach (var targetOperation in continuation.SelectMany( + static item => item.DescendantsAndSelf())) + { + forcedGotoOperations.Add(targetOperation); + } + if (scheduledGotoLabels.Add(branch.Target)) + { + PushSequential(continuation); + } + continue; + } + } + if (operation is ISwitchExpressionOperation switchExpression) + { + if (SwitchExpressionFacts.HasReachableUnmatchedPath( + switchExpression, + canCompleteNormally, + DefiniteOperationFacts.IsDefinitelyNonNull( + switchExpression.Value) || + abstractFlow?.ProvesNonNull( + switchExpression, + switchExpression.Value) == true)) + { + Add( + _switchExpressionExceptionType is { } exceptionType + ? new PotentialExceptions( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default, + exceptionType), + Unknown: false) + : UnknownPotential, + switchExpression); + } + PushChildren(switchExpression); + continue; + } + if (operation is IThrowOperation thrown) + { + if (thrown.Exception is not { } exception) + { + Add( + FromThrowSet( + EffectExceptionFlow.ResolveRethrow(thrown)), + thrown); + continue; + } + + Add( + GetPotentialExceptions( + exception, + activeMethods, + depth, + keepEscaping), + exception); + var operandCompletes = canCompleteNormally(exception); + if (operandCompletes && + (abstractFlow?.ProvesNull(thrown, exception) == true || + exception.ConstantValue is + { HasValue: true, Value: null }) && + _nullReferenceExceptionType is { } nullReferenceException) + { + Add( + new PotentialExceptions( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default, + nullReferenceException), + Unknown: false), + thrown); + } + else if (operandCompletes && + DefiniteOperationFacts.UnwrapHarmlessValue(exception).Type + is INamedTypeSymbol type) + { + Add( + new PotentialExceptions( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default, + type), + Unknown: false), + thrown); + } + continue; + } + if (operation is IInvocationOperation invocation) + { + var prerequisitesComplete = + invocation.Instance is not { } receiver || + canCompleteNormally(receiver); + prerequisitesComplete &= invocation.Arguments.All(argument => + canCompleteNormally(argument.Value)); + var dereferenceCompletes = prerequisitesComplete; + if (prerequisitesComplete && + invocation.Instance is { } instance && + invocation.TargetMethod.ReducedFrom == null) + { + Add( + GetPotentialNullReceiver( + invocation, + instance, + out dereferenceCompletes), + invocation); + } + if (dereferenceCompletes) + { + var initializationCompletes = + AddStaticInitializationPotential( + invocation.TargetMethod.ReducedFrom ?? + invocation.TargetMethod, + invocation, + Add); + if (initializationCompletes) + { + Add( + invocation.IsVirtual + ? UnknownPotential + : GetCallableExceptions( + invocation.TargetMethod, + activeMethods, + depth + 1), + invocation); + } + } + PushChildren(invocation); + continue; + } + if (operation is IDeconstructionAssignmentOperation deconstruction) + { + if (canCompleteNormally(deconstruction.Value)) + { + Add(UnknownPotential, deconstruction); + } + remaining.Push(deconstruction.Value); + continue; + } + if (operation is IWithOperation withOperation) + { + if (canCompleteNormally(withOperation.Operand) && + withOperation.CloneMethod is { } clone) + { + var dereferenceCompletes = true; + if (withOperation.Operand.Type?.IsReferenceType == true) + { + Add( + GetPotentialNullReceiver( + withOperation, + withOperation.Operand, + out dereferenceCompletes), + withOperation); + } + if (dereferenceCompletes) + { + var copyConstructor = OperationCompletionEvaluator + .GetRecordCopyConstructor(clone); + Add( + clone.IsVirtual || clone.IsAbstract + ? UnknownPotential + : GetCallableExceptions( + copyConstructor ?? clone, + activeMethods, + depth + 1), + withOperation); + } + } + PushChildren(withOperation); + continue; + } + if (operation is IEventAssignmentOperation eventAssignment) + { + if (eventAssignment.EventReference is not + IEventReferenceOperation eventReference) + { + Add(UnknownPotential, eventAssignment); + PushChildren(eventAssignment); + continue; + } + var prerequisitesComplete = + eventReference.Instance is not { } receiver || + canCompleteNormally(receiver); + prerequisitesComplete &= + canCompleteNormally(eventAssignment.HandlerValue); + var dereferenceCompletes = prerequisitesComplete; + if (prerequisitesComplete && + eventReference.Instance is { } instance) + { + Add( + GetPotentialNullReceiver( + eventAssignment, + instance, + out dereferenceCompletes), + eventAssignment); + } + if (dereferenceCompletes) + { + var accessor = eventAssignment.Adds + ? eventReference.Event.AddMethod + : eventReference.Event.RemoveMethod; + if (AddStaticInitializationPotential( + eventReference.Event, + eventAssignment, + Add)) + { + Add( + accessor == null || accessor.IsVirtual || + accessor.IsAbstract + ? UnknownPotential + : GetCallableExceptions( + accessor, + activeMethods, + depth + 1), + eventAssignment); + } + } + PushChildren(eventAssignment); + continue; + } + if (operation is ISimpleAssignmentOperation simple) + { + if (simple.Target is IPropertyReferenceOperation property && + CanEvaluatePropertyTarget(property) && + canCompleteNormally(simple.Value)) + { + var dereferenceCompletes = true; + if (property.Instance is { } instance) + { + Add( + GetPotentialNullReceiver( + property, + instance, + out dereferenceCompletes), + simple); + } + if (dereferenceCompletes) + { + if (AddStaticInitializationPotential( + property.Property, + simple, + Add)) + { + AddPropertySetterExceptions( + property, + simple, + activeMethods, + depth, + Add); + } + } + } + if (simple.Target is IFieldReferenceOperation + { Field.IsStatic: true } field && + canCompleteNormally(simple.Value)) + { + AddStaticInitializationPotential( + field.Field, + simple, + Add); + } + if (simple.Target is IFieldReferenceOperation + { Instance: { } instanceField } instanceTarget && + canCompleteNormally(instanceField) && + canCompleteNormally(simple.Value)) + { + Add( + GetPotentialNullReceiver( + instanceTarget, + instanceField, + out _), + simple); + } + if (simple.Target is IArrayElementReferenceOperation array && + canCompleteNormally(array.ArrayReference) && + array.Indices.All(canCompleteNormally) && + canCompleteNormally(simple.Value)) + { + Add( + GetPotentialNullReceiver( + array, + array.ArrayReference, + out var dereferenceCompletes), + simple); + if (dereferenceCompletes) + { + Add(UnknownPotential, simple); + } + } + PushChildren(simple); + continue; + } + if (operation is ICoalesceAssignmentOperation coalesce) + { + var targetCompletes = canCompleteNormally(coalesce.Target); + var targetIsNonNull = + DefiniteOperationFacts.IsDefinitelyNonNull( + coalesce.Target) || + abstractFlow?.ProvesNonNull( + coalesce, + coalesce.Target) == true; + if (coalesce.Target is IPropertyReferenceOperation property && + targetCompletes && + !targetIsNonNull && + canCompleteNormally(coalesce.Value)) + { + AddPropertySetterExceptions( + property, + coalesce, + activeMethods, + depth, + Add); + } + PushChildren(coalesce); + continue; + } + if (operation is ICompoundAssignmentOperation compound) + { + var priorPhasesComplete = + canCompleteNormally(compound.Target) && + canCompleteNormally(compound.Value); + var operatorInitializationCompletes = true; + if (priorPhasesComplete && + compound.OperatorMethod is { } compoundOperator) + { + operatorInitializationCompletes = + AddStaticInitializationPotential( + compoundOperator, + compound, + Add); + if (operatorInitializationCompletes) + { + Add( + GetOperatorExceptions( + compoundOperator, + activeMethods, + depth), + compound); + } + } + if (CanThrowUnknownAfterPrerequisites(compound)) + { + Add(UnknownPotential, compound); + } + var operatorCompletes = + operatorInitializationCompletes && + canCompoundValueComplete(compound); + if (priorPhasesComplete && operatorCompletes && + compound.Target is IPropertyReferenceOperation property) + { + AddPropertySetterExceptions( + property, + compound, + activeMethods, + depth, + Add); + } + PushChildren(compound); + continue; + } + if (operation is IIncrementOrDecrementOperation increment) + { + var priorPhasesComplete = + canCompleteNormally(increment.Target); + var operatorInitializationCompletes = true; + if (priorPhasesComplete && + increment.OperatorMethod is { } incrementOperator) + { + operatorInitializationCompletes = + AddStaticInitializationPotential( + incrementOperator, + increment, + Add); + if (operatorInitializationCompletes) + { + Add( + GetOperatorExceptions( + incrementOperator, + activeMethods, + depth), + increment); + } + } + if (CanThrowUnknownAfterPrerequisites(increment)) + { + Add(UnknownPotential, increment); + } + var operatorCompletes = + operatorInitializationCompletes && + canIncrementValueComplete(increment); + if (priorPhasesComplete && operatorCompletes && + increment.Target is IPropertyReferenceOperation property) + { + AddPropertySetterExceptions( + property, + increment, + activeMethods, + depth, + Add); + } + PushChildren(increment); + continue; + } + if (operation is IObjectCreationOperation creation) + { + var argumentsComplete = creation.Arguments.All(argument => + canCompleteNormally(argument.Value)); + if (argumentsComplete) + { + var initializationCompletes = true; + if (creation.Constructor is { } constructor && + !IsExceptionType(creation.Type)) + { + initializationCompletes = + AddStaticInitializationPotential( + constructor, + creation, + Add); + } + if (initializationCompletes) + { + var constructorExceptions = + creation.Constructor == null + ? UnknownPotential + : GetCallableExceptions( + creation.Constructor, + activeMethods, + depth + 1); + if (IsExceptionType(creation.Type) && + creation.Constructor is + { DeclaringSyntaxReferences.Length: 0 }) + { + constructorExceptions = EmptyPotential; + } + Add( + constructorExceptions, + creation); + } + } + PushChildren(creation); + continue; + } + if (operation is IBinaryOperation binary && + binary.OperatorMethod is { } binaryOperator) + { + if (canCompleteNormally(binary.LeftOperand) && + canCompleteNormally(binary.RightOperand)) + { + if (AddStaticInitializationPotential( + binaryOperator, + binary, + Add)) + { + Add( + GetOperatorExceptions( + binaryOperator, + activeMethods, + depth), + binary); + } + } + if (CanThrowUnknownAfterPrerequisites(binary)) + { + Add(UnknownPotential, binary); + } + PushChildren(binary); + continue; + } + if (operation is IUnaryOperation unary && + unary.OperatorMethod is { } unaryOperator) + { + if (canCompleteNormally(unary.Operand)) + { + if (AddStaticInitializationPotential( + unaryOperator, + unary, + Add)) + { + Add( + GetOperatorExceptions( + unaryOperator, + activeMethods, + depth), + unary); + } + } + if (CanThrowUnknownAfterPrerequisites(unary)) + { + Add(UnknownPotential, unary); + } + PushChildren(unary); + continue; + } + if (operation is IConversionOperation conversion && + conversion.OperatorMethod is { } conversionOperator) + { + if (canCompleteNormally(conversion.Operand)) + { + if (AddStaticInitializationPotential( + conversionOperator, + conversion, + Add)) + { + Add( + GetOperatorExceptions( + conversionOperator, + activeMethods, + depth), + conversion); + } + } + if (CanThrowUnknownAfterPrerequisites(conversion)) + { + Add(UnknownPotential, conversion); + } + PushChildren(conversion); + continue; + } + if (operation is IUsingOperation or IUsingDeclarationOperation) + { + Add( + GetUsingDisposalExceptions( + operation, + activeMethods, + depth), + operation); + PushChildren(operation); + continue; + } + if (operation is ITryOperation nestedTry) + { + Add( + GetNestedTryExceptions( + nestedTry, + activeMethods, + depth), + nestedTry); + continue; + } + if (operation is IForEachLoopOperation forEach) + { + Add( + GetForEachExceptions( + forEach, + activeMethods, + depth, + out var reachesBody), + forEach); + remaining.Push(forEach.Collection); + if (reachesBody) + { + remaining.Push(forEach.LoopControlVariable); + remaining.Push(forEach.Body); + foreach (var nextVariable in forEach.NextVariables) + { + remaining.Push(nextVariable); + } + } + continue; + } + if (operation is IPropertyReferenceOperation propertyReference) + { + if (propertyReference.Parent is ISimpleAssignmentOperation + enclosingAssignment && + ReferenceEquals( + enclosingAssignment.Target, + propertyReference)) + { + PushChildren(propertyReference); + continue; + } + var prerequisitesComplete = + propertyReference.Instance is not { } receiver || + canCompleteNormally(receiver); + prerequisitesComplete &= propertyReference.Arguments.All( + argument => canCompleteNormally(argument.Value)); + var dereferenceCompletes = prerequisitesComplete; + if (prerequisitesComplete && + propertyReference.Instance is { } instance) + { + Add( + GetPotentialNullReceiver( + propertyReference, + instance, + out dereferenceCompletes), + propertyReference); + } + if (dereferenceCompletes) + { + var accessors = GetAccessors(propertyReference).ToArray(); + var initializationCompletes = true; + if (accessors.Length != 0) + { + initializationCompletes = + AddStaticInitializationPotential( + propertyReference.Property, + propertyReference, + Add); + } + if (initializationCompletes) + { + foreach (var accessor in accessors) + { + Add( + accessor == null || accessor.IsVirtual || + accessor.IsAbstract + ? UnknownPotential + : accessor.DeclaringSyntaxReferences.Length == 0 && + propertyReference.Property.ContainingType + ?.IsRefLikeType == true + ? EmptyPotential + : GetCallableExceptions( + accessor, + activeMethods, + depth + 1), + propertyReference); + } + } + } + PushChildren(propertyReference); + continue; + } + if (operation is IListPatternOperation listPattern) + { + var members = getReachableListPatternMembers(listPattern); + foreach (var member in members) + { + if (member.DeclaringSyntaxReferences.Length == 0) + { + continue; + } + Add( + member.IsVirtual || member.IsAbstract + ? UnknownPotential + : GetCallableExceptions( + member, + activeMethods, + depth + 1), + listPattern); + } + PushSequential(listPattern.Patterns); + continue; + } + if (operation is IFieldReferenceOperation fieldReference) + { + if (fieldReference.Instance is { } fieldInstance) + { + Add( + GetPotentialNullReceiver( + fieldReference, + fieldInstance, + out _), + fieldReference); + } + else + { + if (fieldReference.Parent is not + ISimpleAssignmentOperation enclosingAssignment || + !ReferenceEquals( + enclosingAssignment.Target, + fieldReference)) + { + AddStaticInitializationPotential( + fieldReference.Field, + fieldReference, + Add); + } + } + PushChildren(fieldReference); + continue; + } + if (operation is IArrayElementReferenceOperation element) + { + if (canCompleteNormally(element.ArrayReference) && + element.Indices.All(canCompleteNormally)) + { + Add( + GetPotentialNullReceiver( + element, + element.ArrayReference, + out var receiverCompletes), + element); + if (receiverCompletes) + { + Add(UnknownPotential, element); + } + } + PushChildren(element); + continue; + } + if (operation is ILockOperation @lock) + { + if (canCompleteNormally(@lock.LockedValue)) + { + var definitelyNull = IsDefinitelyNull( + @lock, + @lock.LockedValue); + var definitelyNonNull = + abstractFlow?.ProvesNonNull( + @lock, + @lock.LockedValue) == true || + DefiniteOperationFacts.IsDefinitelyNonNull( + @lock.LockedValue); + if (!definitelyNonNull) + { + Add( + _argumentNullExceptionType is { } argumentNull + ? new PotentialExceptions( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default, + argumentNull), + Unknown: false) + : UnknownPotential, + @lock); + } + if (!definitelyNull) + { + Add(UnknownPotential, @lock); + } + } + PushChildren(@lock); + continue; + } + if (operation is IAwaitOperation awaitOperation) + { + if (canCompleteNormally(awaitOperation.Operation)) + { + var model = SharpProof.Frontend.Host + .CompilationModelProvider.GetSemanticModel( + compilation, + awaitOperation.Syntax.SyntaxTree); + var info = awaitOperation.Syntax is + AwaitExpressionSyntax awaitSyntax + ? Microsoft.CodeAnalysis.CSharp.CSharpExtensions + .GetAwaitExpressionInfo(model, awaitSyntax) + : default; + var getAwaiter = info.GetAwaiterMethod; + var phaseCompletes = true; + if (getAwaiter == null) + { + Add(UnknownPotential, awaitOperation); + } + else + { + var dereferenceCompletes = true; + if (!getAwaiter.IsStatic && + getAwaiter.ReducedFrom == null) + { + Add( + GetPotentialNullReceiver( + awaitOperation, + awaitOperation.Operation, + out dereferenceCompletes), + awaitOperation); + } + phaseCompletes = dereferenceCompletes && + AddStaticInitializationPotential( + getAwaiter.ReducedFrom ?? getAwaiter, + awaitOperation, + Add); + if (phaseCompletes) + { + Add( + getAwaiter.IsVirtual || getAwaiter.IsAbstract + ? UnknownPotential + : GetCallableExceptions( + getAwaiter, + activeMethods, + depth + 1), + awaitOperation); + phaseCompletes = + canMethodCompleteNormally(getAwaiter); + if (phaseCompletes && + getAwaiter.ReturnType.IsReferenceType) + { + var returnNullability = + GetReturnNullability(getAwaiter); + if (returnNullability != + ReturnNullability.NonNull && + _nullReferenceExceptionType is + { } nullAwaiter) + { + Add( + new PotentialExceptions( + ImmutableHashSet.Create< + INamedTypeSymbol>( + SymbolEqualityComparer.Default, + nullAwaiter), + Unknown: false), + awaitOperation); + } + if (returnNullability == + ReturnNullability.Null) + { + phaseCompletes = false; + } + } + } + } + var isCompleted = info.IsCompletedProperty?.GetMethod; + if (phaseCompletes) + { + Add( + isCompleted == null || isCompleted.IsVirtual || + isCompleted.IsAbstract + ? UnknownPotential + : GetCallableExceptions( + isCompleted, + activeMethods, + depth + 1), + awaitOperation); + phaseCompletes = isCompleted == null || + canMethodCompleteNormally(isCompleted); + } + var getResult = info.GetResultMethod; + if (phaseCompletes) + { + Add( + getResult == null || getResult.IsVirtual || + getResult.IsAbstract + ? UnknownPotential + : GetCallableExceptions( + getResult, + activeMethods, + depth + 1), + awaitOperation); + } + } + PushChildren(awaitOperation); + continue; + } + if (operation is IMethodReferenceOperation methodReference && + methodReference.Instance is { } methodInstance && + !methodReference.Method.IsStatic) + { + Add( + GetPotentialNullReceiver( + methodReference, + methodInstance, + out _), + methodReference); + PushChildren(methodReference); + continue; + } + if (CanThrowUnknownAfterPrerequisites(operation)) + { + Add(UnknownPotential, operation); + } + PushChildren(operation); + } + return new PotentialExceptions(known.ToImmutable(), unknown); + + void Add(PotentialExceptions potential, IOperation origin) + { + if (keepEscaping) + { + potential = KeepEscaping(potential, origin); + } + known.UnionWith(potential.Known); + unknown |= potential.Unknown; + } + + void PushChildren(IOperation operation) + { + switch (operation) + { + case INameOfOperation or ITypeOfOperation or + ISizeOfOperation: + return; + case IBlockOperation block: + PushSequential(block.Operations); + return; + case ISimpleAssignmentOperation assignment: + var inputs = GetSimpleAssignmentTargetInputs( + assignment.Target).ToArray(); + if (inputs.All(canCompleteNormally)) + { + remaining.Push(assignment.Value); + } + PushSequential(inputs); + return; + case IBinaryOperation + { + OperatorMethod: null, + OperatorKind: BinaryOperatorKind.ConditionalAnd or + BinaryOperatorKind.ConditionalOr + } binary: + var leftCompletes = canCompleteNormally( + binary.LeftOperand); + var leftConstant = binary.LeftOperand.ConstantValue is + { HasValue: true, Value: bool leftValue } + ? leftValue + : (bool?)null; + var evaluatesRight = leftCompletes && + (binary.OperatorKind == + BinaryOperatorKind.ConditionalAnd + ? leftConstant != false + : leftConstant != true); + if (evaluatesRight) + { + remaining.Push(binary.RightOperand); + } + remaining.Push(binary.LeftOperand); + return; + case IConditionalOperation conditional: + if (!canCompleteNormally(conditional.Condition)) + { + remaining.Push(conditional.Condition); + return; + } + var condition = conditional.Condition.ConstantValue is + { HasValue: true, Value: bool conditionValue } + ? conditionValue + : (bool?)null; + if (condition != true && + conditional.WhenFalse is { } whenFalse) + { + remaining.Push(whenFalse); + } + if (condition != false) + { + remaining.Push(conditional.WhenTrue); + } + remaining.Push(conditional.Condition); + return; + case ICoalesceOperation coalesce: + var valueCompletes = canCompleteNormally(coalesce.Value); + var definitelyNonNull = + DefiniteOperationFacts.IsDefinitelyNonNull( + coalesce.Value) || + abstractFlow?.ProvesNonNull( + coalesce, + coalesce.Value) == true; + if (valueCompletes && !definitelyNonNull) + { + remaining.Push(coalesce.WhenNull); + } + remaining.Push(coalesce.Value); + return; + case ICoalesceAssignmentOperation coalesce: + var targetCompletes = canCompleteNormally( + coalesce.Target); + var targetIsNonNull = + DefiniteOperationFacts.IsDefinitelyNonNull( + coalesce.Target) || + abstractFlow?.ProvesNonNull( + coalesce, + coalesce.Target) == true; + if (targetCompletes && !targetIsNonNull) + { + remaining.Push(coalesce.Value); + } + remaining.Push(coalesce.Target); + return; + case IConditionalAccessOperation access: + var receiverCompletes = canCompleteNormally( + access.Operation); + var receiverIsNull = + DefiniteOperationFacts.IsDefinitelyNull( + access.Operation) || + abstractFlow?.ProvesNull( + access, + access.Operation) == true; + if (receiverCompletes && !receiverIsNull) + { + remaining.Push(access.WhenNotNull); + } + remaining.Push(access.Operation); + return; + case IWithOperation withOperation: + if (canWithCloneComplete(withOperation) && + withOperation.Initializer is { } initializer) + { + remaining.Push(initializer); + } + remaining.Push(withOperation.Operand); + return; + case IObjectCreationOperation creation: + if (creation.Initializer != null && + creation.Arguments.All(argument => + canCompleteNormally(argument.Value)) && + creation.Constructor is { } constructor && + canMethodCompleteNormally(constructor)) + { + remaining.Push(creation.Initializer); + } + PushSequential(creation.Arguments); + return; + case ILockOperation @lock: + if (canCompleteNormally(@lock.LockedValue) && + !IsDefinitelyNull(@lock, @lock.LockedValue)) + { + remaining.Push(@lock.Body); + } + remaining.Push(@lock.LockedValue); + return; + case ISwitchOperation @switch: + if (canCompleteNormally(@switch.Value)) + { + var constant = @switch.Value.ConstantValue; + var cases = GetReachableSwitchCases( + @switch, + constant.HasValue, + constant.Value, + scheduledSwitchBodies, + switchCaseReachability); + PushAll(cases); + } + remaining.Push(@switch.Value); + return; + case ISwitchCaseOperation @case + when switchCaseReachability.TryGetValue( + @case, + out var reachability): + if (reachability.BodyReachable) + { + PushSequential(@case.Body); + } + PushAll(reachability.Clauses); + return; + case ISwitchExpressionOperation @switch: + if (canCompleteNormally(@switch.Value)) + { + PushAll(SwitchExpressionFacts.GetReachableArms( + @switch, + canCompleteNormally, + DefiniteOperationFacts.IsDefinitelyNonNull( + @switch.Value) || + abstractFlow?.ProvesNonNull( + @switch, + @switch.Value) == true)); + } + remaining.Push(@switch.Value); + return; + default: + PushSequential(operation.ChildOperations); + return; + } + } + + void PushSequential(IEnumerable children) + { + var reachable = new List(); + foreach (var child in children) + { + reachable.Add(child); + if (!canCompleteNormally(child)) + { + break; + } + } + PushAll(reachable); + } + + void PushAll(IEnumerable children) + { + foreach (var child in children.Reverse()) + { + remaining.Push(child); + } + } + } + + private ISwitchCaseOperation[] GetReachableSwitchCases( + ISwitchOperation @switch, + bool hasConstant, + object? value, + HashSet scheduledSwitchBodies, + Dictionary + switchCaseReachability) + { + var selected = new Dictionary< + ISwitchCaseOperation, + SwitchCaseReachability>(); + var inputDefinitelyNonNull = + DefiniteOperationFacts.IsDefinitelyNonNull(@switch.Value) || + abstractFlow?.ProvesNonNull(@switch, @switch.Value) == true; + ISwitchCaseOperation? defaultCase = null; + var definiteMatch = false; + foreach (var @case in @switch.Cases) + { + var reachableClauses = new List(); + var bodyReachable = false; + var stopsSelection = false; + foreach (var clause in @case.Clauses) + { + if (clause is IDefaultCaseClauseOperation) + { + defaultCase = @case; + continue; + } + var patternSelection = clause is + IPatternCaseClauseOperation patternClause + ? GetPatternSelection( + patternClause.Pattern, + @switch.Value.Type, + hasConstant, + value, + inputDefinitelyNonNull) + : SwitchSelection.Never; + var clauseSelection = clause switch + { + ISingleValueCaseClauseOperation single + when hasConstant && + single.Value.ConstantValue is + { HasValue: true } item => + Equals(value, item.Value) + ? SwitchSelection.Always + : SwitchSelection.Never, + IPatternCaseClauseOperation pattern => + ApplySwitchGuard( + GetPatternSelection( + pattern.Pattern, + @switch.Value.Type, + hasConstant, + value, + inputDefinitelyNonNull), + pattern.Guard), + _ => SwitchSelection.Maybe + }; + if (clauseSelection != SwitchSelection.Never) + { + reachableClauses.Add(clause); + bodyReachable |= CanCaseClauseReachBody( + clause, + clauseSelection); + } + stopsSelection |= clauseSelection == SwitchSelection.Always || + clause is IPatternCaseClauseOperation barrierClause && + SwitchExpressionFacts.IsPatternEvaluationUnavoidable( + barrierClause.Pattern, + @switch.Value.Type, + inputDefinitelyNonNull) && + !canCompleteNormally(barrierClause.Pattern) || + patternSelection == SwitchSelection.Always && + clause is IPatternCaseClauseOperation + { Guard: not null } guarded && + !canCompleteNormally(guarded.Guard); + if (stopsSelection) + { + break; + } + } + if (reachableClauses.Count != 0) + { + selected[@case] = new SwitchCaseReachability( + @case, + reachableClauses, + bodyReachable); + } + if (stopsSelection) + { + definiteMatch = true; + break; + } + } + if (!definiteMatch && defaultCase != null) + { + if (selected.TryGetValue(defaultCase, out var existingDefault)) + { + selected[defaultCase] = existingDefault with + { + BodyReachable = true + }; + } + else + { + selected[defaultCase] = new SwitchCaseReachability( + defaultCase, + [], + BodyReachable: true); + } + } + + foreach (var @case in @switch.Cases) + { + if (!selected.TryGetValue(@case, out var reachability)) + { + continue; + } + switchCaseReachability[@case] = reachability; + if (reachability.BodyReachable) + { + scheduledSwitchBodies.Add(@case); + } + } + return @switch.Cases.Where(selected.ContainsKey).ToArray(); + } + + private static ISwitchCaseOperation? GetSwitchGotoTargetCase( + IBranchOperation branch) + { + var target = branch.Target.DeclaringSyntaxReferences + .Select(static reference => reference.GetSyntax()) + .FirstOrDefault(static syntax => syntax is SwitchLabelSyntax); + if (target == null) + { + return null; + } + ISwitchOperation? @switch = null; + for (var current = branch.Parent; + current != null; + current = current.Parent) + { + if (current is ISwitchOperation candidate) + { + @switch = candidate; + break; + } + } + return @switch?.Cases.FirstOrDefault(candidate => + candidate.Syntax.Span.Contains(target.Span)); + } + + private IOperation[]? GetGotoTargetContinuation( + IBranchOperation branch) + { + var target = branch.Target.DeclaringSyntaxReferences + .Select(static reference => reference.GetSyntax()) + .FirstOrDefault(static syntax => syntax is LabeledStatementSyntax); + if (target == null) + { + return null; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, target.SyntaxTree); + var labeled = model.GetOperation(target); + if (labeled == null) + { + return null; + } + var labeledStatement = target is LabeledStatementSyntax labeledSyntax + ? model.GetOperation(labeledSyntax.Statement) + : null; + var labeledInvocations = target.DescendantNodes() + .OfType() + .Select(syntax => model.GetOperation(syntax)) + .Where(static operation => operation != null) + .Cast() + .ToArray(); + if (labeledInvocations.Length == 0 && + target.AncestorsAndSelf() + .OfType() + .FirstOrDefault() is { } methodSyntax) + { + labeledInvocations = methodSyntax.DescendantNodes() + .OfType() + .Where(invocation => invocation.SpanStart > target.Span.End) + .Select(syntax => model.GetOperation(syntax)) + .Where(static operation => operation != null) + .Cast() + .Take(1) + .ToArray(); + } + + IOperation[] IncludeLabeledStatement(IEnumerable operations) + { + var result = operations.ToList(); + if (labeledStatement != null && + !result.Any(operation => ReferenceEquals(operation, labeledStatement))) + { + result.Insert(1, labeledStatement); + } + foreach (var invocation in labeledInvocations.Reverse()) + { + if (!result.Any(operation => ReferenceEquals(operation, invocation))) + { + result.Insert(1, invocation); + } + } + return result.ToArray(); + } + var sequenceEntry = labeled; + while (sequenceEntry.Parent is ILabeledOperation outerLabel) + { + sequenceEntry = outerLabel; + } + if (sequenceEntry.Parent is IBlockOperation block) + { + var index = block.Operations.IndexOf(sequenceEntry); + return index < 0 + ? null + : IncludeLabeledStatement(block.Operations.Skip(index)); + } + if (sequenceEntry.Parent is ISwitchCaseOperation @case) + { + var index = @case.Body.IndexOf(sequenceEntry); + return index < 0 + ? null + : IncludeLabeledStatement(@case.Body.Skip(index)); + } + return [sequenceEntry]; + } + + private bool CanCaseClauseReachBody( + ICaseClauseOperation clause, + SwitchSelection selection) + { + if (selection == SwitchSelection.Never) + { + return false; + } + if (clause is not IPatternCaseClauseOperation pattern) + { + return true; + } + if (!canCompleteNormally(pattern.Pattern)) + { + return false; + } + if (pattern.Guard == null) + { + return true; + } + return pattern.Guard.ConstantValue is + { HasValue: true, Value: bool guard } + ? guard + : canCompleteNormally(pattern.Guard); + } + + private static SwitchSelection GetPatternSelection( + IPatternOperation pattern, + ITypeSymbol? inputType, + bool hasConstant, + object? value, + bool inputDefinitelyNonNull) + { + var selection = hasConstant + ? SwitchExpressionFacts.GetPatternSelection(pattern, value) + : SwitchExpressionFacts.GetPatternSelectionForUnknownValue( + pattern, + inputType, + inputDefinitelyNonNull); + return selection switch + { + SwitchExpressionSelection.Never => SwitchSelection.Never, + SwitchExpressionSelection.Maybe => SwitchSelection.Maybe, + SwitchExpressionSelection.Always => SwitchSelection.Always, + _ => throw new InvalidOperationException( + "Unknown switch-pattern selection.") + }; + } + + private static SwitchSelection ApplySwitchGuard( + SwitchSelection selection, + IOperation? guard) + { + if (selection == SwitchSelection.Never || guard == null) + { + return selection; + } + return guard.ConstantValue is { HasValue: true, Value: bool value } + ? value + ? selection + : SwitchSelection.Never + : SwitchSelection.Maybe; + } + + private PotentialExceptions GetNestedTryExceptions( + ITryOperation nestedTry, + HashSet activeMethods, + int depth) + { + if (nestedTry.Syntax is not TryStatementSyntax syntax) + { + return UnknownPotential; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, syntax.SyntaxTree); + var body = GetPotentialExceptions( + nestedTry.Body, + activeMethods, + depth, + keepEscaping: false); + var escapingBody = FromThrowSet( + EffectExceptionFlow.KeepEscapingThroughTry( + EffectThrowSet.Create(body.Known, body.Unknown), + syntax, + compilation)); + var result = escapingBody; + var finallyReachable = canCompleteNormally(nestedTry.Body) || + CanExitAbruptlyWithoutExceptions( + nestedTry.Body, + nestedTry.Body) || + escapingBody.Unknown || !escapingBody.Known.IsEmpty; + foreach (var catchOperation in nestedTry.Catches) + { + if (catchOperation.Syntax is not CatchClauseSyntax @catch) + { + return UnknownPotential; + } + var filterReachable = body.Unknown && + CanUnknownReach(@catch, syntax, model) || + body.Known.Any(thrown => + CanKnownReach(thrown, @catch, syntax, model)); + if (!filterReachable || + GetFilterSelection(@catch, model) == CatchSelection.Never) + { + continue; + } + result = Union( + result, + GetPotentialExceptions( + catchOperation.Handler, + activeMethods, + depth, + keepEscaping: false)); + finallyReachable |= canCompleteNormally( + catchOperation.Handler) || + CanExitAbruptly( + catchOperation.Handler, + catchOperation.Handler); + } + if (nestedTry.Finally is not { } finallyOperation || + !finallyReachable) + { + return result; + } + var finallyExceptions = GetPotentialExceptions( + finallyOperation, + activeMethods, + depth, + keepEscaping: false); + return canCompleteNormally(finallyOperation) + ? Union(result, finallyExceptions) + : finallyExceptions; + } + + private PotentialExceptions GetPotentialNullReceiver( + IOperation origin, + IOperation instance, + out bool dereferenceCompletes) + { + if (!canCompleteNormally(instance)) + { + dereferenceCompletes = false; + return EmptyPotential; + } + if (instance.Type?.IsValueType == true) + { + dereferenceCompletes = true; + return EmptyPotential; + } + var definitelyNull = + abstractFlow?.ProvesNull(origin, instance) == true || + instance.ConstantValue is { HasValue: true, Value: null }; + var definitelyNonNull = + abstractFlow?.ProvesNonNull(origin, instance) == true || + DefiniteOperationFacts.IsDefinitelyNonNull(instance); + dereferenceCompletes = !definitelyNull; + if (definitelyNonNull) + { + return EmptyPotential; + } + if (_nullReferenceExceptionType is not { } nullReferenceException) + { + return UnknownPotential; + } + return new PotentialExceptions( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default, + nullReferenceException), + Unknown: false); + } + + private bool AddStaticInitializationPotential( + ISymbol member, + IOperation origin, + Action add) + { + member = OperationCompletionEvaluator + .NormalizeStaticInitializationMember(member); + if ((!member.IsStatic && member is not IMethodSymbol + { MethodKind: MethodKind.Constructor }) || + member is IFieldSymbol { IsConst: true } || + OperationCompletionEvaluator + .CanAssumeStaticInitializationComplete(caller, member) || + member.ContainingType is not { } type || + !EffectMethodNodeBuilder.HasPotentialStaticInitialization( + type, + apiSpecs)) + { + return true; + } + add( + _typeInitializationExceptionType is { } typeInitialization + ? new PotentialExceptions( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default, + typeInitialization), + Unknown: false) + : UnknownPotential, + origin); + return !OperationCompletionEvaluator + .RequiresStaticInitializationCompletion(member) || + StaticInitializationMayComplete(type); + } + + private bool StaticInitializationMayComplete(INamedTypeSymbol type) + { + foreach (var member in type.GetMembers()) + { + var isStaticInitializable = member switch + { + IFieldSymbol field => field.IsStatic && !field.IsConst, + IPropertySymbol property => property.IsStatic, + IEventSymbol @event => @event.IsStatic, + _ => false + }; + if (!isStaticInitializable) + { + continue; + } + foreach (var reference in member.DeclaringSyntaxReferences) + { + var expression = EffectProjections.GetInitializerExpression( + reference.GetSyntax()); + if (expression == null) + { + continue; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, expression.SyntaxTree); + var operation = model.GetOperation(expression); + if (operation != null && + !_staticInitializationFacts.MayCompleteNormally(operation)) + { + return false; + } + } + } + return type.StaticConstructors.All(canMethodCompleteNormally); + } + + private bool IsDefinitelyNull(IOperation origin, IOperation value) + { + return abstractFlow?.ProvesNull(origin, value) == true || + value.ConstantValue is { HasValue: true, Value: null } || + DefiniteOperationFacts.IsDefinitelyNull(value); + } + + private static IEnumerable GetAccessors( + IPropertyReferenceOperation property) + { + if (property.Parent is ISimpleAssignmentOperation simple && + ReferenceEquals(simple.Target, property)) + { + yield break; + } + if ((property.Parent is ICoalesceAssignmentOperation coalesce && + ReferenceEquals(coalesce.Target, property)) || + (property.Parent is ICompoundAssignmentOperation compound && + ReferenceEquals(compound.Target, property)) || + (property.Parent is IIncrementOrDecrementOperation increment && + ReferenceEquals(increment.Target, property))) + { + yield return property.Property.GetMethod; + yield break; + } + yield return property.Property.GetMethod; + } + + private bool CanEvaluatePropertyTarget( + IPropertyReferenceOperation property) + { + return (property.Instance is not { } instance || + canCompleteNormally(instance)) && + property.Arguments.All(argument => + canCompleteNormally(argument.Value)); + } + + private static IEnumerable + GetSimpleAssignmentTargetInputs(IOperation target) + { + switch (target) + { + case IPropertyReferenceOperation property: + if (property.Instance != null) + { + yield return property.Instance; + } + foreach (var argument in property.Arguments) + { + yield return argument.Value; + } + yield break; + case IFieldReferenceOperation field: + if (field.Instance != null) + { + yield return field.Instance; + } + yield break; + case IArrayElementReferenceOperation array: + yield return array.ArrayReference; + foreach (var index in array.Indices) + { + yield return index; + } + yield break; + default: + foreach (var child in target.ChildOperations) + { + yield return child; + } + yield break; + } + } + + private void AddPropertySetterExceptions( + IPropertyReferenceOperation property, + IOperation origin, + HashSet activeMethods, + int depth, + Action add) + { + var setter = property.Property.SetMethod; + add( + setter == null || setter.IsAbstract || setter.IsVirtual + ? UnknownPotential + : GetCallableExceptions( + setter, + activeMethods, + depth + 1), + origin); + } + + private PotentialExceptions GetUsingDisposalExceptions( + IOperation operation, + HashSet activeMethods, + int depth) + { + if (operation is IUsingOperation { IsAsynchronous: true } or + IUsingDeclarationOperation { IsAsynchronous: true }) + { + return UnknownPotential; + } + var scopeExitReachable = operation switch + { + IUsingOperation @using => CanExit(@using.Body), + IUsingDeclarationOperation declaration => + CanReachDeclarationDisposal(declaration), + _ => false + }; + var resources = operation switch + { + IUsingOperation @using => @using.Resources, + IUsingDeclarationOperation declaration => + declaration.DeclarationGroup, + _ => null + }; + if (resources == null) + { + return EmptyPotential; + } + var result = EmptyPotential; + if (resources is IVariableDeclarationGroupOperation group) + { + var acquired = new List<( + ITypeSymbol Type, + IOperation Resource, + IOperation Origin)>(); + var acquisitionFailed = false; + foreach (var declarator in group.Declarations + .SelectMany(static declaration => + declaration.Declarators)) + { + var resource = declarator.Initializer?.Value; + if (!canCompleteNormally(resource)) + { + acquisitionFailed = true; + break; + } + if (resource != null) + { + acquired.Add(( + declarator.Symbol.Type, + resource, + declarator)); + } + } + if (!scopeExitReachable && !acquisitionFailed) + { + return EmptyPotential; + } + foreach (var item in acquired.AsEnumerable().Reverse()) + { + var disposal = GetDisposalExceptions( + item.Type, + item.Resource, + item.Origin, + activeMethods, + depth); + result = Union(result, disposal); + if (!CanDisposalUnwind( + item.Type, + item.Resource, + item.Origin, + disposal)) + { + break; + } + } + return result; + } + return scopeExitReachable + ? GetDisposalExceptions( + resources.Type, + resources, + operation, + activeMethods, + depth) + : EmptyPotential; + } + + internal bool CanExit(IOperation operation) + { + return canCompleteNormally(operation) || + CanExitAbruptly(operation, operation); + } + + internal bool CanExitAbruptly( + IOperation operation, + IOperation scope) + { + var potential = GetPotentialExceptions(operation); + var abrupt = potential.Unknown || !potential.Known.IsEmpty || + CanExitAbruptlyWithoutExceptions(operation, scope); + return abrupt; + } + + private bool CanExitAbruptlyWithoutExceptions( + IOperation operation, + IOperation scope) + { + return CanReachAbruptExit(operation, operation, scope, depth: 0); + } + + private bool CanReachAbruptExit( + IOperation operation, + IOperation root, + IOperation scope, + int depth) + { + if (depth > 256) + { + return true; + } + if (!ReferenceEquals(operation, root) && + HasNestedCallableParent(operation, root)) + { + return false; + } + if (operation is IReturnOperation returned) + { + return returned.ReturnedValue == null || + canCompleteNormally(returned.ReturnedValue); + } + if (operation is IBranchOperation branch) + { + return BranchLeavesScope(branch, scope); + } + if (operation is ITryOperation nestedTry) + { + var finallyAbrupt = nestedTry.Finally != null && + CanReachAbruptExit( + nestedTry.Finally, + root, + scope, + depth + 1); + if (nestedTry.Finally != null && + !canCompleteNormally(nestedTry.Finally)) + { + return finallyAbrupt; + } + if (CanReachAbruptExit( + nestedTry.Body, + root, + scope, + depth + 1)) + { + return true; + } + foreach (var @catch in nestedTry.Catches) + { + if (@catch.Syntax is CatchClauseSyntax syntax && + IsReachable(syntax, inFilter: false) && + CanReachAbruptExit( + @catch.Handler, + root, + scope, + depth + 1)) + { + return true; + } + } + return finallyAbrupt; + } + if (operation is IConditionalOperation conditional) + { + if (CanReachAbruptExit( + conditional.Condition, + root, + scope, + depth + 1)) + { + return true; + } + if (!canCompleteNormally(conditional.Condition)) + { + return false; + } + var condition = conditional.Condition.ConstantValue is + { HasValue: true, Value: bool value } + ? value + : (bool?)null; + return condition != false && + CanReachAbruptExit( + conditional.WhenTrue, + root, + scope, + depth + 1) || + condition != true && conditional.WhenFalse != null && + CanReachAbruptExit( + conditional.WhenFalse, + root, + scope, + depth + 1); + } + if (operation is IBinaryOperation + { + OperatorMethod: null, + OperatorKind: BinaryOperatorKind.ConditionalAnd or + BinaryOperatorKind.ConditionalOr + } binary) + { + var leftAbrupt = CanReachAbruptExit( + binary.LeftOperand, + root, + scope, + depth + 1); + if (leftAbrupt || !canCompleteNormally(binary.LeftOperand)) + { + return leftAbrupt; + } + var left = binary.LeftOperand.ConstantValue is + { HasValue: true, Value: bool value } + ? value + : (bool?)null; + var reachesRight = binary.OperatorKind == + BinaryOperatorKind.ConditionalAnd + ? left != false + : left != true; + return reachesRight && CanReachAbruptExit( + binary.RightOperand, + root, + scope, + depth + 1); + } + if (operation is ICoalesceOperation coalesce) + { + var valueAbrupt = CanReachAbruptExit( + coalesce.Value, + root, + scope, + depth + 1); + if (valueAbrupt || !canCompleteNormally(coalesce.Value)) + { + return valueAbrupt; + } + var nonNull = DefiniteOperationFacts.IsDefinitelyNonNull( + coalesce.Value) || + abstractFlow?.ProvesNonNull( + coalesce, + coalesce.Value) == true; + return !nonNull && CanReachAbruptExit( + coalesce.WhenNull, + root, + scope, + depth + 1); + } + if (operation is IConditionalAccessOperation access) + { + var receiverAbrupt = CanReachAbruptExit( + access.Operation, + root, + scope, + depth + 1); + if (receiverAbrupt || !canCompleteNormally(access.Operation)) + { + return receiverAbrupt; + } + var isNull = DefiniteOperationFacts.IsDefinitelyNull( + access.Operation) || + abstractFlow?.ProvesNull( + access, + access.Operation) == true; + return !isNull && CanReachAbruptExit( + access.WhenNotNull, + root, + scope, + depth + 1); + } + foreach (var child in operation.ChildOperations) + { + if (CanReachAbruptExit(child, root, scope, depth + 1)) + { + return true; + } + if (!canCompleteNormally(child)) + { + return false; + } + } + return false; + } - internal bool IsReachable(CatchClauseSyntax target, bool inFilter) + private static bool BranchLeavesScope( + IBranchOperation branch, + IOperation scope) { - var reachability = GetReachability(target); - return inFilter ? reachability.Filter : reachability.Handler; + SyntaxNode? target = branch.Syntax switch + { + BreakStatementSyntax => branch.Syntax.Ancestors().FirstOrDefault( + static ancestor => ancestor is WhileStatementSyntax or + DoStatementSyntax or ForStatementSyntax or + CommonForEachStatementSyntax or SwitchStatementSyntax), + ContinueStatementSyntax => branch.Syntax.Ancestors().FirstOrDefault( + static ancestor => ancestor is WhileStatementSyntax or + DoStatementSyntax or ForStatementSyntax or + CommonForEachStatementSyntax), + GotoStatementSyntax => branch.Target.DeclaringSyntaxReferences + .Select(static reference => reference.GetSyntax()) + .FirstOrDefault(), + _ => null + }; + return target == null || + target.SyntaxTree != scope.Syntax.SyntaxTree || + !scope.Syntax.Span.Contains(target.Span); } - private CatchReachability GetReachability(CatchClauseSyntax target) + private bool CanReachDeclarationDisposal( + IUsingDeclarationOperation declaration) { - if (_cache.TryGetValue(target, out var cached)) + if (declaration.Parent is not IBlockOperation block) { - return cached; + return true; } - if (target.Parent is not TryStatementSyntax @try) + var index = block.Operations.IndexOf(declaration); + if (index < 0) { - return new CatchReachability(Filter: true, Handler: true); + return true; + } + var pending = new Queue(); + var visited = new HashSet(); + pending.Enqueue(index + 1); + while (pending.Count != 0) + { + var operationIndex = pending.Dequeue(); + if (operationIndex >= block.Operations.Length) + { + return true; + } + if (!visited.Add(operationIndex)) + { + continue; + } + var operation = block.Operations[operationIndex]; + var internalBranches = GetInternalGotoTargets( + operation, + block, + index + 1); + if (internalBranches.LeavesActiveLifetime) + { + return true; + } + foreach (var target in internalBranches.Targets) + { + pending.Enqueue(target); + } + if (CanExitAbruptly(operation, block)) + { + return true; + } + if (operation is IUsingDeclarationOperation laterUsing && + !CanDisposalsCompleteNormally(laterUsing)) + { + continue; + } + if ((canCompleteNormally(operation) || + operation is ILabeledOperation labeled && + labeled.ChildOperations.All(canCompleteNormally)) && + !internalBranches.HasUnconditionalGoto) + { + pending.Enqueue(operationIndex + 1); + } } + return false; + } - var model = SharpProof.Frontend.Host.CompilationModelProvider - .GetSemanticModel(compilation, @try.SyntaxTree); - var protectedBlock = model.GetOperation(@try.Block); - if (protectedBlock == null) + + private bool CanDisposalsCompleteNormally( + IUsingDeclarationOperation declaration) + { + return declaration.DeclarationGroup.Declarations + .SelectMany(static item => item.Declarators) + .Reverse() + .All(declarator => CanDisposalCompleteNormally( + declarator.Symbol.Type, + declarator.Initializer?.Value, + declarator)); + } + + private bool CanDisposalCompleteNormally( + ITypeSymbol? resourceType, + IOperation? resource, + IOperation origin) + { + if (resourceType == null || resource == null || + IsDefinitelyNullResource(origin, resource)) { - return new CatchReachability(Filter: true, Handler: true); + return true; } - var potential = GetPotentialExceptions(protectedBlock); - var filterReachable = potential.Unknown && - CanUnknownReach(target, @try, model) || - potential.Known.Any(type => - CanKnownReach(type, target, @try, model)); - var result = new CatchReachability( - filterReachable, - filterReachable && - GetFilterSelection(target, model) != CatchSelection.Never); - _cache.Add(target, result); - return result; + var dispose = UsingDisposalEffectResolver.ResolveDispose( + compilation, + caller, + GetConcreteResourceType(resourceType, resource)); + return dispose == null || + UsingDisposalEffectResolver.IsDispatchUncertain(dispose) || + canMethodCompleteNormally(dispose); } - private PotentialExceptions GetPotentialExceptions( - IOperation protectedBlock) + private bool CanDisposalUnwind( + ITypeSymbol? resourceType, + IOperation resource, + IOperation origin, + PotentialExceptions exceptions) { - var known = ImmutableHashSet.CreateBuilder( - SymbolEqualityComparer.Default); - var unknown = false; - var remaining = new Stack(); - remaining.Push(protectedBlock); - while (remaining.Count != 0) + if (IsDefinitelyNullResource(origin, resource)) { - var operation = remaining.Pop(); - if (operation is IAnonymousFunctionOperation or - ILocalFunctionOperation) + return true; + } + var dispose = resourceType == null + ? null + : UsingDisposalEffectResolver.ResolveDispose( + compilation, + caller, + GetConcreteResourceType(resourceType, resource)); + return dispose == null || + UsingDisposalEffectResolver.IsDispatchUncertain(dispose) || + canMethodCompleteNormally(dispose) || + exceptions.Unknown || !exceptions.Known.IsEmpty; + } + + private bool IsDefinitelyNullResource( + IOperation origin, + IOperation resource) + { + return resource.ConstantValue is { HasValue: true, Value: null } || + abstractFlow?.ProvesNull(origin, resource) == true; + } + + private static ITypeSymbol GetConcreteResourceType( + ITypeSymbol declaredType, + IOperation resource) + { + resource = DefiniteOperationFacts.UnwrapHarmlessValue(resource); + return declaredType is INamedTypeSymbol + { + TypeKind: TypeKind.Interface + } && + resource.Type is INamedTypeSymbol + { + TypeKind: not TypeKind.Interface + } concrete + ? concrete + : declaredType; + } + + private static InternalGotoTargets GetInternalGotoTargets( + IOperation operation, + IBlockOperation scope, + int firstActiveOperation) + { + var branches = operation.DescendantsAndSelf() + .OfType() + .Where(branch => + branch.Syntax is GotoStatementSyntax) + .ToArray(); + var allTargets = branches + .SelectMany(static branch => + branch.Target.DeclaringSyntaxReferences) + .Select(static reference => reference.GetSyntax()) + .Where(target => + target.SyntaxTree == scope.Syntax.SyntaxTree && + scope.Syntax.Span.Contains(target.Span)) + .Select(target => scope.Operations.IndexOf( + scope.Operations.FirstOrDefault(candidate => + candidate.Syntax.Span.Contains(target.Span) || + candidate.Syntax.Span.IntersectsWith(target.Span) || + target.Span.Contains(candidate.Syntax.Span)) ?? + scope.Operations.First(candidate => + candidate.Syntax.Span.Start >= target.Span.Start))) + .Distinct() + .ToArray(); + return new InternalGotoTargets( + allTargets.Where(target => + target >= firstActiveOperation).ToArray(), + branches.Any(branch => + IsUnconditionalAtOperationLevel(branch, operation)), + allTargets.Any(target => target < firstActiveOperation)); + } + + private static bool IsUnconditionalAtOperationLevel( + IBranchOperation branch, + IOperation operation) + { + if (ReferenceEquals(branch, operation)) + { + return true; + } + for (var parent = branch.Parent; + parent != null; + parent = parent.Parent) + { + if (ReferenceEquals(parent, operation)) { - continue; + return true; } - if (operation is IThrowOperation thrown) + if (parent is not ILabeledOperation) { - if (thrown.Exception is { } nullException && - abstractFlow?.ProvesNull(thrown, nullException) == true && - _nullReferenceExceptionType is { } nullReferenceException) + return false; + } + } + return false; + } + + private PotentialExceptions GetForEachExceptions( + IForEachLoopOperation forEach, + HashSet activeMethods, + int depth, + out bool reachesBody) + { + reachesBody = false; + if (!canCompleteNormally(forEach.Collection)) + { + return EmptyPotential; + } + if (forEach.IsAsynchronous) + { + reachesBody = true; + return UnknownPotential; + } + if (forEach.Syntax is not CommonForEachStatementSyntax syntax) + { + reachesBody = true; + return UnknownPotential; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, syntax.SyntaxTree); + var info = model.GetForEachStatementInfo(syntax); + var result = EmptyPotential; + if (info.GetEnumeratorMethod is { } getEnumerator) + { + if (!getEnumerator.IsStatic && getEnumerator.ReducedFrom == null) + { + result = Union( + result, + GetPotentialNullReceiver( + forEach, + forEach.Collection, + out var receiverCompletes)); + if (!receiverCompletes) { - known.Add(nullReferenceException); + return result; } - else if (thrown.Exception is { } exception && - DefiniteOperationFacts.UnwrapHarmlessValue(exception).Type - is INamedTypeSymbol type) + } + result = Union( + result, + GetImplicitCallableExceptions( + getEnumerator, + forEach, + activeMethods, + depth, + out var getEnumeratorCompletes)); + if (!getEnumeratorCompletes) + { + return result; + } + if (getEnumerator.ReturnType.IsReferenceType) + { + var returnNullability = GetReturnNullability(getEnumerator); + if (returnNullability != ReturnNullability.NonNull && + _nullReferenceExceptionType is { } nullReceiver) { - known.Add(type); + result = Union( + result, + new PotentialExceptions( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default, + nullReceiver), + Unknown: false)); } - else + if (returnNullability == ReturnNullability.Null) { - unknown = true; + return result; } - continue; } - if (CanThrowUnknown(operation)) + } + else if (forEach.Collection.Type is IArrayTypeSymbol && + forEach.Collection is { } collection) + { + result = Union( + result, + GetPotentialNullReceiver( + forEach, + collection, + out var receiverCompletes)); + if (!receiverCompletes) + { + return result; + } + } + if (info.MoveNextMethod is not { } moveNext) + { + reachesBody = true; + return result; + } + var moveNextExceptions = GetImplicitCallableExceptions( + moveNext, + forEach, + activeMethods, + depth, + out var moveNextCompletes); + result = Union(result, moveNextExceptions); + if (moveNextCompletes && + info.CurrentProperty?.GetMethod is { } getCurrent) + { + result = Union( + result, + GetImplicitCallableExceptions( + getCurrent, + forEach, + activeMethods, + depth, + out reachesBody)); + } + else + { + reachesBody = moveNextCompletes; + } + if ((moveNextCompletes || moveNextExceptions.Unknown || + !moveNextExceptions.Known.IsEmpty) && + info.DisposeMethod is { } dispose) + { + result = Union( + result, + GetImplicitCallableExceptions( + dispose, + forEach, + activeMethods, + depth, + out _)); + } + return result; + } + + private PotentialExceptions GetImplicitCallableExceptions( + IMethodSymbol method, + IOperation origin, + HashSet activeMethods, + int depth, + out bool completesNormally) + { + var result = EmptyPotential; + var initializationCompletes = AddStaticInitializationPotential( + method.ReducedFrom ?? method, + origin, + (potential, _) => result = Union(result, potential)); + completesNormally = initializationCompletes && + canMethodCompleteNormally(method); + if (!initializationCompletes) + { + return result; + } + + return Union( + result, + method.IsAbstract || method.IsVirtual || + method.ContainingType?.TypeKind == TypeKind.Interface + ? UnknownPotential + : GetCallableExceptions( + method, + activeMethods, + depth + 1)); + } + + private ReturnNullability GetReturnNullability(IMethodSymbol method) + { + method = method.OriginalDefinition; + if (method.DeclaringSyntaxReferences.Length != 1) + { + return ReturnNullability.MaybeNull; + } + try + { + var declaration = method.DeclaringSyntaxReferences[0].GetSyntax(); + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, declaration.SyntaxTree); + var directBody = GetBodyOperation(declaration, model); + var root = model.GetOperation(declaration) ?? directBody; + if (root == null) { - unknown = true; + return ReturnNullability.MaybeNull; } - foreach (var child in operation.ChildOperations) + var returnedValues = root.DescendantsAndSelf() + .OfType() + .Where(returned => + returned.ReturnedValue != null && + !ManagedAbstractFlow.IsCompileTimeUnreachable( + compilation, + returned) && + !HasNestedCallableParent(returned, root)) + .Select(static returned => returned.ReturnedValue!) + .ToArray(); + if (returnedValues.Length == 0 && directBody != null && + declaration is BaseMethodDeclarationSyntax + { ExpressionBody: not null } or + AccessorDeclarationSyntax { ExpressionBody: not null } or + LocalFunctionStatementSyntax { ExpressionBody: not null }) { - remaining.Push(child); + returnedValues = [directBody]; } + if (returnedValues.Length == 0) + { + return ReturnNullability.MaybeNull; + } + if (returnedValues.All( + DefiniteOperationFacts.IsDefinitelyNonNull)) + { + return ReturnNullability.NonNull; + } + return returnedValues.All(DefiniteOperationFacts.IsDefinitelyNull) + ? ReturnNullability.Null + : ReturnNullability.MaybeNull; } - return new PotentialExceptions(known.ToImmutable(), unknown); + catch (ArgumentException) + { + return ReturnNullability.MaybeNull; + } + } + + private static bool HasNestedCallableParent( + IOperation operation, + IOperation root) + { + for (var parent = operation.Parent; + parent != null && !ReferenceEquals(parent, root); + parent = parent.Parent) + { + if (parent is IAnonymousFunctionOperation or + ILocalFunctionOperation) + { + return true; + } + } + return false; + } + + private enum ReturnNullability + { + Null, + NonNull, + MaybeNull + } + + private PotentialExceptions GetDisposalExceptions( + ITypeSymbol? resourceType, + IOperation? resource, + IOperation origin, + HashSet activeMethods, + int depth) + { + if (resourceType == null || resource == null || + !canCompleteNormally(resource) || + resource.ConstantValue is { HasValue: true, Value: null } || + abstractFlow?.ProvesNull(origin, resource) == true) + { + return EmptyPotential; + } + var dispose = UsingDisposalEffectResolver.ResolveDispose( + compilation, + caller, + GetConcreteResourceType(resourceType, resource)); + return dispose == null || + UsingDisposalEffectResolver.IsDispatchUncertain(dispose) + ? UnknownPotential + : GetCallableExceptions( + dispose, + activeMethods, + depth + 1); + } + + private PotentialExceptions GetCallableExceptions( + IMethodSymbol method, + HashSet activeMethods, + int depth) + { + method = method.OriginalDefinition; + if (isKnownNonThrowing(method) || + method is + { + MethodKind: MethodKind.Constructor, + IsImplicitlyDeclared: true + }) + { + return EmptyPotential; + } + if (depth > 32 || + method.DeclaringSyntaxReferences.Length != 1) + { + return UnknownPotential; + } + if (!activeMethods.Add(method)) + { + return EmptyPotential; + } + + try + { + var declaration = method.DeclaringSyntaxReferences[0].GetSyntax(); + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, declaration.SyntaxTree); + var operation = model.GetOperation(declaration) ?? + GetBodyOperation(declaration, model); + return operation == null + ? UnknownPotential + : GetPotentialExceptions( + operation, + activeMethods, + depth, + keepEscaping: true); + } + catch (ArgumentException) + { + return UnknownPotential; + } + finally + { + activeMethods.Remove(method); + } + } + + internal bool CanMethodThrow(IMethodSymbol method) + { + var potential = GetCallableExceptions( + method, + new HashSet(SymbolEqualityComparer.Default), + depth: 0); + return potential.Unknown || !potential.Known.IsEmpty; + } + + private PotentialExceptions GetOperatorExceptions( + IMethodSymbol method, + HashSet activeMethods, + int depth) + { + return method.IsAbstract || method.IsVirtual + ? UnknownPotential + : GetCallableExceptions(method, activeMethods, depth + 1); + } + + private PotentialExceptions KeepEscaping( + PotentialExceptions potential, + IOperation origin) + { + if (potential.Known.IsEmpty && !potential.Unknown) + { + return potential; + } + var summary = EffectExceptionFlow.KeepEscaping( + EffectSummaryOperations.Throw( + EffectThrowSet.Create( + potential.Known, + potential.Unknown)), + origin, + compilation); + return FromThrowSet(summary.Throws); + } + + private static PotentialExceptions FromThrowSet(EffectThrowSet throws) + { + return new PotentialExceptions( + throws.Types.ToImmutableHashSet( + SymbolEqualityComparer.Default), + throws.IncludesUnknown); + } + + private static PotentialExceptions Union( + PotentialExceptions left, + PotentialExceptions right) + { + return new PotentialExceptions( + left.Known.Union(right.Known), + left.Unknown || right.Unknown); + } + + private static IOperation? GetBodyOperation( + SyntaxNode declaration, + SemanticModel model) + { + var body = declaration switch + { + BaseMethodDeclarationSyntax method => + (SyntaxNode?)method.Body ?? method.ExpressionBody?.Expression, + AccessorDeclarationSyntax accessor => + (SyntaxNode?)accessor.Body ?? accessor.ExpressionBody?.Expression, + LocalFunctionStatementSyntax local => + (SyntaxNode?)local.Body ?? local.ExpressionBody?.Expression, + _ => null + }; + return body == null ? null : model.GetOperation(body); + } + + private static PotentialExceptions EmptyPotential => + new( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default), + Unknown: false); + + private bool IsExceptionType(ITypeSymbol? type) + { + return type is INamedTypeSymbol named && + _exceptionType is { } exception && + EffectTypeFacts.IsDerivedFrom(named, exception); } + private static PotentialExceptions UnknownPotential => + new( + ImmutableHashSet.Create( + SymbolEqualityComparer.Default), + Unknown: true); + private static bool CanThrowUnknown(IOperation operation) { return operation is @@ -108,19 +2682,64 @@ IObjectCreationOperation or IArrayCreationOperation or IArrayElementReferenceOperation or IPropertyReferenceOperation or - IEventAssignmentOperation or ILockOperation or - IAwaitOperation or - IConversionOperation { IsChecked: true } or + IConversionOperation + { IsChecked: true, OperatorMethod: null } or + ICompoundAssignmentOperation + { + IsChecked: true, + OperatorMethod: null + } or + ICompoundAssignmentOperation + { + OperatorMethod: null, + OperatorKind: BinaryOperatorKind.Divide or + BinaryOperatorKind.Remainder + } or IBinaryOperation { + IsChecked: true, + OperatorMethod: null + } or + IBinaryOperation + { + OperatorMethod: null, OperatorKind: BinaryOperatorKind.Divide or BinaryOperatorKind.Remainder } or - IIncrementOrDecrementOperation { IsChecked: true }; + IUnaryOperation + { IsChecked: true, OperatorMethod: null } or + IIncrementOrDecrementOperation + { IsChecked: true, OperatorMethod: null }; + } + + private bool CanThrowUnknownAfterPrerequisites(IOperation operation) + { + if (!CanThrowUnknown(operation)) + { + return false; + } + return operation switch + { + IConversionOperation conversion => + canCompleteNormally(conversion.Operand), + IBinaryOperation binary => + canCompleteNormally(binary.LeftOperand) && + canCompleteNormally(binary.RightOperand), + IIncrementOrDecrementOperation increment => + canCompleteNormally(increment.Target), + IArrayCreationOperation array => array.DimensionSizes.All( + canCompleteNormally), + IArrayElementReferenceOperation element => + canCompleteNormally(element.ArrayReference) && + element.Indices.All(canCompleteNormally), + ILockOperation @lock => + canCompleteNormally(@lock.LockedValue), + _ => operation.ChildOperations.All(canCompleteNormally) + }; } - private static bool CanKnownReach( + private bool CanKnownReach( INamedTypeSymbol thrown, CatchClauseSyntax target, TryStatementSyntax @try, @@ -189,7 +2808,7 @@ private bool CatchesAllExceptions( _exceptionType); } - private static CatchSelection GetFilterSelection( + private CatchSelection GetFilterSelection( CatchClauseSyntax @catch, SemanticModel model) { @@ -197,12 +2816,21 @@ private static CatchSelection GetFilterSelection( { return CatchSelection.Always; } - return model.GetConstantValue(@catch.Filter.FilterExpression) switch + var selection = model.GetConstantValue( + @catch.Filter.FilterExpression) switch { { HasValue: true, Value: true } => CatchSelection.Always, { HasValue: true, Value: false } => CatchSelection.Never, _ => CatchSelection.Maybe }; + if (selection != CatchSelection.Maybe) + { + return selection; + } + var operation = model.GetOperation(@catch.Filter.FilterExpression); + return operation != null && !canCompleteNormally(operation) + ? CatchSelection.Never + : CatchSelection.Maybe; } private readonly record struct CatchReachability( @@ -213,10 +2841,27 @@ private readonly record struct PotentialExceptions( ImmutableHashSet Known, bool Unknown); + private sealed record InternalGotoTargets( + IReadOnlyList Targets, + bool HasUnconditionalGoto, + bool LeavesActiveLifetime); + private enum CatchSelection { Never, Maybe, Always } + + private enum SwitchSelection + { + Never, + Maybe, + Always + } + + private sealed record SwitchCaseReachability( + ISwitchCaseOperation Case, + IReadOnlyList Clauses, + bool BodyReachable); } diff --git a/SharpProof.Effects/ManagedAbstractFlow.cs b/SharpProof.Effects/ManagedAbstractFlow.cs index f14b5a0c4..3e3491eff 100644 --- a/SharpProof.Effects/ManagedAbstractFlow.cs +++ b/SharpProof.Effects/ManagedAbstractFlow.cs @@ -30,11 +30,13 @@ internal sealed class ManagedAbstractFlow private static readonly ConditionalWeakTable Sessions = new(); private readonly ResolvedApiSpecTable _apiSpecs; + private readonly Compilation _compilation; private readonly INamedTypeSymbol? _contractApi; private readonly INamedTypeSymbol? _inRangeAttribute; private readonly INamedTypeSymbol? _notNullAttribute; private readonly INamedTypeSymbol? _positiveAttribute; private readonly TrustedBoundaryPolicy _trustedBoundaries; + private readonly DefiniteOperationFacts _completionFacts; private ManagedAbstractFlow(Compilation compilation) : this(compilation, new ApiSpecResolver(ApiSpecTable.Default).Resolve(compilation)) @@ -46,6 +48,7 @@ private ManagedAbstractFlow( ResolvedApiSpecTable apiSpecs) { compilation = ArgumentNullGuard.NotNull(compilation, nameof(compilation)); + _compilation = compilation; _apiSpecs = ArgumentNullGuard.NotNull(apiSpecs, nameof(apiSpecs)); var contractApi = ContractApiIdentityResolver.ForCompilation(compilation); _contractApi = contractApi.Contract; @@ -54,6 +57,9 @@ private ManagedAbstractFlow( _inRangeAttribute = contractApi.ResolveAttribute(ContractApiMetadata.InRange); _trustedBoundaries = TrustedBoundaryPolicy.ForCompilation(compilation); + _completionFacts = new DefiniteOperationFacts( + compilation, + CancellationToken.None); } internal static ManagedAbstractFlow ForCompilation(Compilation compilation) @@ -1067,6 +1073,31 @@ public override bool LessThanOrEqual(ManagedFlowState left, ManagedFlowState rig return ManagedFlowState.LessThanOrEqual(left, right); } } + internal bool IsBlockedAfterNoncompletingStatement( + IOperation operation) + { + var statement = operation.Syntax.AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + if (statement?.Parent is not BlockSyntax block) + { + return false; + } + + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(_compilation, block.SyntaxTree); + foreach (var prior in block.Statements + .TakeWhile(candidate => candidate != statement)) + { + var priorOperation = model.GetOperation(prior); + if (priorOperation != null && + !_completionFacts.MayCompleteNormally(priorOperation)) + { + return true; + } + } + return false; + } } internal enum ManagedFlowStatus @@ -1158,8 +1189,9 @@ internal bool TryGetState(IOperation operation, out ManagedFlowState state) internal bool IsReachable(IOperation operation) { - return operation.DescendantsAndSelf().Any(candidate => - TryGetState(candidate, out var state) && !state.IsBottom); + return !flow.IsBlockedAfterNoncompletingStatement(operation) && + operation.DescendantsAndSelf().Any(candidate => + TryGetState(candidate, out var state) && !state.IsBottom); } internal static IOperation? GetUnavoidableDirectOperation( @@ -1798,6 +1830,22 @@ ILiteralOperation or ILocalReferenceOperation or IParameterReferenceOperation or IDiscardOperation or IInstanceReferenceOperation or IDefaultValueOperation or ITypeOfOperation or INameOfOperation => true, IInvocationOperation invocation => CompletesNormally(invocation), + IObjectCreationOperation creation => + creation.Arguments.All(argument => + CompletesNormally(argument.Value)) && + (creation.Constructor == null || + creation.Constructor.DeclaringSyntaxReferences.Length != 1 || + CompletesNormally(creation.Constructor)), + IMethodReferenceOperation methodReference => + ChildrenCompleteNormally(methodReference) && + (methodReference.Method.IsStatic || + methodReference.Instance != null && + IsDefinitelyNonNull(methodReference.Instance)), + IFieldReferenceOperation fieldReference => + ChildrenCompleteNormally(fieldReference) && + (fieldReference.Field.IsStatic || + fieldReference.Instance != null && + IsDefinitelyNonNull(fieldReference.Instance)), ISimpleAssignmentOperation assignment => assignment.Target is ILocalReferenceOperation or IParameterReferenceOperation or IDiscardOperation && CompletesNormally(assignment.Value), @@ -1872,11 +1920,14 @@ internal bool MethodCanCompleteNormally(IMethodSymbol method) method = ArgumentNullGuard.NotNull(method, nameof(method)); cancellationToken.ThrowIfCancellationRequested(); var normalized = method.OriginalDefinition; - if (normalized.DeclaringSyntaxReferences.Length != 1 || - !_activeMethods.Add(normalized)) + if (normalized.DeclaringSyntaxReferences.Length != 1) { return true; } + if (!_activeMethods.Add(normalized)) + { + return false; + } try { @@ -1907,7 +1958,7 @@ internal bool MethodCanCompleteNormally(IMethodSymbol method) /// ordinary assignments, writes, external calls, and unsupported shapes /// are all treated as potentially completing. /// - private bool MayCompleteNormally(IOperation? operation) + internal bool MayCompleteNormally(IOperation? operation) { cancellationToken.ThrowIfCancellationRequested(); return operation switch @@ -1931,8 +1982,38 @@ private bool MayCompleteNormally(IOperation? operation) (MayCompleteNormally(conditionalAccess.WhenNotNull) || !DefiniteOperationFacts.IsDefinitelyNonNull( conditionalAccess.Operation)), + ISwitchExpressionOperation switchExpression => + MayCompleteSwitchExpression(switchExpression), + ISwitchExpressionArmOperation arm => + MayCompleteNormally(arm.Pattern) && + (arm.Guard == null || MayCompleteNormally(arm.Guard)) && + MayCompleteNormally(arm.Value), + IIsPatternOperation isPattern => + MayCompleteNormally(isPattern.Value) && + MayCompleteNormally(isPattern.Pattern), + IPropertySubpatternOperation propertySubpattern => + MayCompleteNormally(propertySubpattern.Member) && + MayCompleteNormally(propertySubpattern.Pattern), + IListPatternOperation listPattern => + MayCompleteListPattern(listPattern), + IRecursivePatternOperation recursivePattern => + MayCompleteRecursivePattern(recursivePattern), + IPatternOperation pattern => + ChildrenMayCompleteNormally(pattern), + ICoalesceOperation coalesce => + MayCompleteNormally(coalesce.Value) && + (!IsDefinitelyNull(coalesce.Value) || + MayCompleteNormally(coalesce.WhenNull)), IInvocationOperation invocation => InvocationMayCompleteNormally(invocation), + IAnonymousObjectCreationOperation or + IDelegateCreationOperation => + ChildrenMayCompleteNormally(operation), + IMethodReferenceOperation methodReference => + ChildrenMayCompleteNormally(methodReference) && + (methodReference.Method.IsStatic || + methodReference.Instance == null || + !IsDefinitelyNull(methodReference.Instance)), IObjectCreationOperation creation => CreationMayCompleteNormally(creation), IArrayCreationOperation array => @@ -1946,10 +2027,17 @@ private bool MayCompleteNormally(IOperation? operation) IUnaryOperation or IConversionOperation or IIncrementOrDecrementOperation or ICompoundAssignmentOperation or ISimpleAssignmentOperation or IArrayElementReferenceOperation or - IFieldReferenceOperation or IPropertyReferenceOperation or + IFieldReferenceOperation or IFlowCaptureOperation or IParenthesizedOperation or IArgumentOperation => ChildrenMayCompleteNormally(operation), + IPropertyReferenceOperation property => + ChildrenMayCompleteNormally(property) && + (property.Property.IsStatic || + property.Instance == null || + !IsDefinitelyNull(property.Instance)) && + (property.Property.GetMethod == null || + MethodCanCompleteNormally(property.Property.GetMethod)), IObjectOrCollectionInitializerOperation initializer => SequenceMayCompleteNormally(initializer.ChildOperations), IExpressionStatementOperation or @@ -1958,12 +2046,194 @@ IVariableDeclarationOperation or IVariableDeclaratorOperation or IVariableInitializerOperation => ChildrenMayCompleteNormally(operation), - ITryOperation or ILoopOperation or ISwitchOperation or - ISwitchExpressionOperation => true, + ILabeledOperation labeled => + ChildrenMayCompleteNormally(labeled), + ILoopOperation loop when + LoopConditionIsAlwaysTrue(loop) && + loop.Body != null && + !LoopHasReachableBreak(loop.Body) => false, + ITryOperation @try => + (@try.Finally == null || MayCompleteNormally(@try.Finally)) && + @try.Catches.All(catchClause => + MayCompleteNormally(catchClause.Handler)), + ILoopOperation or ISwitchOperation => true, _ => true }; } + private bool MayCompleteSwitchExpression( + ISwitchExpressionOperation switchExpression) + { + if (!MayCompleteNormally(switchExpression.Value)) + { + return false; + } + + if (SwitchExpressionFacts.HasReachableUnmatchedPath( + switchExpression, + MayCompleteNormally, + IsDefinitelyNonNull(switchExpression.Value))) + { + return false; + } + + return SwitchExpressionFacts.GetReachableArms( + switchExpression, + MayCompleteNormally, + IsDefinitelyNonNull(switchExpression.Value)) + .Any(MayCompleteNormally); + } + + private bool MayCompleteRecursivePattern( + IRecursivePatternOperation pattern) + { + if (pattern.DeconstructSymbol is IMethodSymbol deconstruct && + !MethodCanCompleteNormally(deconstruct)) + { + return false; + } + return pattern.DeconstructionSubpatterns.All(MayCompleteNormally) && + pattern.PropertySubpatterns.All(MayCompleteNormally); + } + + private bool MayCompleteListPattern(IListPatternOperation pattern) + { + var value = SwitchExpressionFacts.GetGoverningValue(pattern); + if (pattern.InputType?.IsValueType != true && + value?.Syntax.ToString().IndexOf( + "null", + StringComparison.Ordinal) >= 0) + { + return true; + } + + var lengthMethod = + SwitchExpressionFacts.GetCallableListPatternMember( + pattern.LengthSymbol); + if (lengthMethod != null && + !MethodCanCompleteNormally(lengthMethod)) + { + return false; + } + + if (TryGetListPatternLength(pattern, out var length)) + { + var requiredLength = pattern.Patterns.Count( + static item => item is not ISlicePatternOperation); + var hasSlice = pattern.Patterns.Any( + static item => item is ISlicePatternOperation); + if (hasSlice ? length < requiredLength : length != requiredLength) + { + return true; + } + } + + foreach (var item in pattern.Patterns) + { + var method = item is ISlicePatternOperation slice + ? slice.Pattern == null + ? null + : SwitchExpressionFacts.GetCallableListPatternMember( + slice.SliceSymbol) + : SwitchExpressionFacts.GetCallableListPatternMember( + pattern.IndexerSymbol); + if (method != null && !MethodCanCompleteNormally(method)) + { + return false; + } + var nested = item is ISlicePatternOperation nestedSlice + ? nestedSlice.Pattern + : item; + if (nested != null && !MayCompleteNormally(nested)) + { + return false; + } + } + return true; + } + + private bool TryGetListPatternLength( + IListPatternOperation pattern, + out long length) + { + var value = SwitchExpressionFacts.GetGoverningValue(pattern); + if (value is IArrayCreationOperation + { DimensionSizes.Length: 1 } array && + array.DimensionSizes[0].ConstantValue is + { HasValue: true, Value: int arrayLength }) + { + length = arrayLength; + return true; + } + + if (pattern.LengthSymbol is IPropertySymbol + { GetMethod: { } getter } && + getter.DeclaringSyntaxReferences.Length == 1) + { + var syntax = getter.DeclaringSyntaxReferences[0].GetSyntax(); + ExpressionSyntax? expression = syntax switch + { + PropertyDeclarationSyntax property + when property.ExpressionBody != null => + property.ExpressionBody.Expression, + AccessorDeclarationSyntax accessor + when accessor.ExpressionBody != null => + accessor.ExpressionBody.Expression, + _ => null + }; + if (expression is { } constantExpression) + { + var constant = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel( + compilation, + constantExpression.SyntaxTree) + .GetConstantValue(constantExpression); + if (constant is { HasValue: true, Value: int constantLength }) + { + length = constantLength; + return length >= 0; + } + } + } + length = 0; + return false; + } + + private static bool LoopConditionIsAlwaysTrue(ILoopOperation loop) + { + return loop switch + { + IWhileLoopOperation + { + ConditionIsTop: true, + ConditionIsUntil: false, + Condition.ConstantValue: { HasValue: true, Value: true } + } => true, + IForLoopOperation { Condition: null } => true, + _ => false + }; + } + + private static bool LoopHasReachableBreak(IOperation body) + { + foreach (var child in body.ChildOperations) + { + if (child is IBranchOperation { BranchKind: BranchKind.Break }) + { + return true; + } + if (child is ILoopOperation or ISwitchOperation) + { + continue; + } + if (LoopHasReachableBreak(child)) + { + return true; + } + } + return false; + } + private bool InvocationMayCompleteNormally(IInvocationOperation invocation) { if (!MayCompleteNormally(invocation.Instance) || @@ -1973,6 +2243,13 @@ private bool InvocationMayCompleteNormally(IInvocationOperation invocation) return false; } + if (!invocation.TargetMethod.IsStatic && + invocation.Instance != null && + IsDefinitelyNull(invocation.Instance)) + { + return false; + } + var target = invocation.TargetMethod.OriginalDefinition; return target.DeclaringSyntaxReferences.Length == 0 || MethodCanCompleteNormally(target); @@ -2113,6 +2390,30 @@ IObjectCreationOperation or IArrayCreationOperation or ITypeOfOperation || operation.ConstantValue is { HasValue: true, Value: not null }; } + internal static bool IsDefinitelyNull(IOperation operation) + { + while (operation is IParenthesizedOperation or IConversionOperation) + { + if (operation is IParenthesizedOperation parenthesized) + { + operation = parenthesized.Operand; + } + else if (operation is IConversionOperation + { + OperatorMethod: null, + IsTryCast: false + } conversion) + { + operation = conversion.Operand; + } + else + { + break; + } + } + return operation.ConstantValue is { HasValue: true, Value: null }; + } + private static bool HarmlessConversion(IConversionOperation conversion) { return conversion.OperatorMethod == null && diff --git a/SharpProof.Effects/OperationCompletionEvaluator.cs b/SharpProof.Effects/OperationCompletionEvaluator.cs index ef1708510..62b17a879 100644 --- a/SharpProof.Effects/OperationCompletionEvaluator.cs +++ b/SharpProof.Effects/OperationCompletionEvaluator.cs @@ -1,21 +1,35 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + namespace SharpProof.Effects; internal sealed class OperationCompletionEvaluator { + private readonly ResolvedApiSpecTable _apiSpecs; + private readonly IMethodSymbol _caller; + private readonly Compilation _compilation; private readonly DefiniteOperationFacts _completionFacts; private readonly Func _isProvenNull; private readonly Func _isProvenNonNull; private readonly Func _isImplicitLockEnterWithNullValue; + private readonly DefiniteOperationFacts _staticInitializationFacts; internal OperationCompletionEvaluator( EffectAnalysisSession session, + IMethodSymbol caller, Func isProvenNull, Func isProvenNonNull, Func isImplicitLockEnterWithNullValue) { + _apiSpecs = session.ApiSpecs; + _caller = caller; + _compilation = session.Compilation; _completionFacts = new DefiniteOperationFacts( session.Compilation, CancellationToken.None); + _staticInitializationFacts = new DefiniteOperationFacts( + session.Compilation, + CancellationToken.None); _isProvenNull = isProvenNull; _isProvenNonNull = isProvenNonNull; _isImplicitLockEnterWithNullValue = isImplicitLockEnterWithNullValue; @@ -31,49 +45,534 @@ internal bool CanCompleteNormally(IOperation? operation) return operation switch { IThrowOperation => false, + IInvocationOperation invocation when + invocation.TargetMethod.Name == "$" && + invocation.Syntax.ToString().IndexOf( + "with", + StringComparison.Ordinal) >= 0 && + GetRecordCopyConstructor(invocation.TargetMethod) is { } copyConstructor => + CanCompleteInvocation( + copyConstructor, + instance: null, + invocation), IInvocationOperation invocation => !_isImplicitLockEnterWithNullValue(invocation) && CanCompleteInvocation( invocation.TargetMethod, invocation.Instance, - invocation), + invocation, + invocation.Arguments), IPropertyReferenceOperation property => CanCompleteProperty(property), IFieldReferenceOperation field => CanCompleteField(field), IArrayElementReferenceOperation element => CanCompleteArrayElement(element), + IAnonymousObjectCreationOperation or + IDelegateCreationOperation => + ChildrenCanComplete(operation), IObjectCreationOperation creation => CanCompleteConstruction(creation), IArrayCreationOperation array => CanCompleteArrayCreation(array), IConditionalAccessOperation conditional => CanCompleteConditionalAccess(conditional), + IWithOperation withOperation => + CanCompleteWith(withOperation), ILockOperation @lock => CanCompleteLock(@lock), IFlowCaptureOperation capture => CanCompleteNormally(capture.Value), + IMethodReferenceOperation methodReference => + CanCompleteMethodReference(methodReference), IArgumentOperation argument => CanCompleteNormally(argument.Value), + ICoalesceAssignmentOperation assignment => + CanCompleteCoalesceAssignment(assignment), + IDeconstructionAssignmentOperation deconstruction => + CanCompleteDeconstruction(deconstruction), + ISimpleAssignmentOperation assignment => + CanCompleteWriteTarget(assignment.Target) && + CanCompleteNormally(assignment.Value), + ICompoundAssignmentOperation assignment => + CanCompleteCompoundValue(assignment) && + CanCompleteWriteTarget(assignment.Target), IParenthesizedOperation parenthesized => CanCompleteNormally(parenthesized.Operand), IConversionOperation conversion => CanCompleteConversion(conversion), IBinaryOperation binary => CanCompleteBinary(binary), IUnaryOperation unary => - ChildrenCanComplete(unary), + CanCompleteUnary(unary), IIncrementOrDecrementOperation increment => - CanCompleteNormally(increment.Target), + CanCompleteIncrementValue(increment) && + CanCompleteWriteTarget(increment.Target), IConditionalOperation conditional => CanCompleteConditional(conditional), + ITryOperation @try => + (@try.Finally == null || CanCompleteNormally(@try.Finally)) && + ChildrenCanComplete(@try), + ISwitchExpressionOperation switchExpression => + CanCompleteSwitchExpression(switchExpression), + ISwitchExpressionArmOperation arm => + CanCompletePatternEvaluation( + arm.Pattern, + IsPatternInputDefinitelyNonNull(arm.Pattern)) && + (arm.Guard == null || CanCompleteNormally(arm.Guard)) && + CanCompleteNormally(arm.Value), + IPropertySubpatternOperation propertySubpattern => + CanCompleteNormally(propertySubpattern.Member) && + CanCompletePatternEvaluation(propertySubpattern.Pattern), + IPatternOperation pattern => + CanCompletePatternEvaluation( + pattern, + IsPatternInputDefinitelyNonNull(pattern)), IBlockOperation or IExpressionStatementOperation or IReturnOperation or IVariableDeclarationGroupOperation or IVariableDeclarationOperation or IVariableDeclaratorOperation or IVariableInitializerOperation or IObjectOrCollectionInitializerOperation => ChildrenCanComplete(operation), + ILabeledOperation labeled => + ChildrenCanComplete(labeled), _ => true }; } + private bool CanCompleteSwitchExpression( + ISwitchExpressionOperation switchExpression) + { + if (!CanCompleteNormally(switchExpression.Value)) + { + return false; + } + + return SwitchExpressionFacts.GetReachableArms( + switchExpression, + CanCompleteNormally, + _isProvenNonNull( + switchExpression.Value, + switchExpression)) + .Any(CanCompleteNormally); + } + + private bool CanCompletePatternEvaluation( + IPatternOperation pattern, + bool inputDefinitelyNonNull = false) + { + if (pattern is INegatedPatternOperation negated) + { + return CanCompletePatternEvaluation( + negated.Pattern, + inputDefinitelyNonNull); + } + if (pattern is IBinaryPatternOperation binary) + { + if (!CanCompletePatternEvaluation( + binary.LeftPattern, + inputDefinitelyNonNull)) + { + return false; + } + var leftSelection = + SwitchExpressionFacts.GetPatternSelectionForUnknownValue( + binary.LeftPattern, + binary.LeftPattern.InputType, + inputDefinitelyNonNull); + var rightIsRequired = + binary.OperatorKind == BinaryOperatorKind.And && + leftSelection == SwitchExpressionSelection.Always || + binary.OperatorKind == BinaryOperatorKind.Or && + leftSelection == SwitchExpressionSelection.Never; + return !rightIsRequired || CanCompletePatternEvaluation( + binary.RightPattern, + inputDefinitelyNonNull); + } + if (pattern is IListPatternOperation listPattern && + !CanCompleteListPattern( + listPattern, + inputDefinitelyNonNull)) + { + return false; + } + if (pattern is not IRecursivePatternOperation recursive || + pattern.InputType?.IsValueType != true && + !inputDefinitelyNonNull || + !SymbolEqualityComparer.Default.Equals( + recursive.MatchedType, + pattern.InputType)) + { + return true; + } + if (recursive.DeconstructSymbol is IMethodSymbol deconstruct && + !CanMethodCompleteNormally(deconstruct)) + { + return false; + } + foreach (var subpattern in recursive.DeconstructionSubpatterns) + { + if (!CanCompletePatternEvaluation(subpattern) && + SwitchExpressionFacts.IsTotalPattern( + subpattern, + subpattern.InputType)) + { + return false; + } + if (!SwitchExpressionFacts.IsTotalPattern( + subpattern, + subpattern.InputType)) + { + return true; + } + } + foreach (var subpattern in recursive.PropertySubpatterns) + { + if (!CanCompleteNormally(subpattern.Member)) + { + return false; + } + if (!CanCompletePatternEvaluation(subpattern.Pattern) && + SwitchExpressionFacts.IsTotalPattern( + subpattern.Pattern, + subpattern.Pattern.InputType)) + { + return false; + } + if (!SwitchExpressionFacts.IsTotalPattern( + subpattern.Pattern, + subpattern.Pattern.InputType)) + { + return true; + } + } + return true; + } + + private bool CanCompleteListPattern( + IListPatternOperation pattern, + bool inputDefinitelyNonNull) + { + if (pattern.InputType?.IsValueType != true && + !inputDefinitelyNonNull) + { + return true; + } + if (!CanListPatternMemberCompleteNormally(pattern.LengthSymbol)) + { + return false; + } + var requiredLength = pattern.Patterns.Count( + static item => item is not ISlicePatternOperation); + var hasSlice = pattern.Patterns.Any( + static item => item is ISlicePatternOperation); + if (!TryGetGoverningListLength(pattern, out var length)) + { + if (requiredLength != 0 || !hasSlice || + pattern.Patterns.Length != 1 || + pattern.Patterns[0] is not ISlicePatternOperation + { Pattern: { } slicePattern } totalSlice) + { + return true; + } + return CanListPatternMemberCompleteNormally( + totalSlice.SliceSymbol) && + CanCompletePatternEvaluation( + slicePattern, + IsListPatternMemberResultDefinitelyNonNull( + totalSlice.SliceSymbol)); + } + + if (hasSlice ? length < requiredLength : length != requiredLength) + { + return true; + } + + foreach (var item in pattern.Patterns) + { + if (item is ISlicePatternOperation slice) + { + if (slice.Pattern == null) + { + continue; + } + if (!CanListPatternMemberCompleteNormally(slice.SliceSymbol) || + !CanCompletePatternEvaluation( + slice.Pattern, + IsListPatternMemberResultDefinitelyNonNull( + slice.SliceSymbol))) + { + return false; + } + if (!SwitchExpressionFacts.IsTotalPattern( + slice.Pattern, + slice.Pattern.InputType)) + { + return true; + } + continue; + } + + if (!CanListPatternMemberCompleteNormally(pattern.IndexerSymbol) || + !CanCompletePatternEvaluation( + item, + IsListPatternMemberResultDefinitelyNonNull( + pattern.IndexerSymbol))) + { + return false; + } + if (!SwitchExpressionFacts.IsTotalPattern( + item, + item.InputType)) + { + return true; + } + } + return true; + } + + private bool CanListPatternMemberCompleteNormally(ISymbol? symbol) + { + var method = SwitchExpressionFacts.GetCallableListPatternMember(symbol); + return method == null || + CanDirectListPatternMemberCompleteNormally(method); + } + + internal IReadOnlyList + GetReachableImplicitListPatternMembers(IListPatternOperation pattern) + { + var methods = new List(); + var governingValue = SwitchExpressionFacts.GetGoverningValue(pattern); + if (governingValue != null && + _isProvenNull(governingValue, pattern)) + { + return methods; + } + + var lengthMember = SwitchExpressionFacts + .GetCallableListPatternMember(pattern.LengthSymbol); + if (lengthMember != null) + { + methods.Add(lengthMember); + if (!CanDirectListPatternMemberCompleteNormally(lengthMember)) + { + return methods; + } + } + + var requiredLength = pattern.Patterns.Count( + static item => item is not ISlicePatternOperation); + var hasSlice = pattern.Patterns.Any( + static item => item is ISlicePatternOperation); + var hasKnownLength = TryGetGoverningListLength(pattern, out var length); + if (hasKnownLength && + (hasSlice ? length < requiredLength : length != requiredLength)) + { + return methods; + } + + foreach (var item in pattern.Patterns) + { + var member = item is ISlicePatternOperation slice + ? slice.Pattern == null + ? null + : SwitchExpressionFacts.GetCallableListPatternMember( + slice.SliceSymbol) + : SwitchExpressionFacts.GetCallableListPatternMember( + pattern.IndexerSymbol); + if (member != null) + { + methods.Add(member); + if (!CanDirectListPatternMemberCompleteNormally(member)) + { + return methods; + } + } + + var nestedPattern = item is ISlicePatternOperation nestedSlice + ? nestedSlice.Pattern + : item; + if (nestedPattern != null && + !CanCompletePatternEvaluation( + nestedPattern, + IsListPatternMemberResultDefinitelyNonNull( + item is ISlicePatternOperation nestedSliceMember + ? nestedSliceMember.SliceSymbol + : pattern.IndexerSymbol))) + { + return methods; + } + } + return methods; + } + + private bool CanDirectListPatternMemberCompleteNormally( + IMethodSymbol method) + { + return method.IsAbstract || method.IsVirtual && !method.IsSealed || + CanMethodCompleteNormally(method); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1508:Avoid dead conditional code", + Justification = "The analyzer misreads the multi-branch nullable " + + "assignment above the null check as unreachable.")] + private bool IsListPatternMemberResultDefinitelyNonNull(ISymbol? symbol) + { + var method = SwitchExpressionFacts.GetCallableListPatternMember(symbol); + if (method?.ReturnType.IsReferenceType != true || + method.DeclaringSyntaxReferences.Length != 1) + { + return false; + } + + var declaration = method.DeclaringSyntaxReferences[0].GetSyntax(); + var expression = declaration switch + { + MethodDeclarationSyntax + { ExpressionBody.Expression: { } body } => body, + PropertyDeclarationSyntax + { ExpressionBody.Expression: { } body } => body, + AccessorDeclarationSyntax + { ExpressionBody.Expression: { } body } => body, + MethodDeclarationSyntax + { Body.Statements.Count: 1 } methodDeclaration + when methodDeclaration.Body!.Statements[0] is + ReturnStatementSyntax { Expression: { } body } => body, + AccessorDeclarationSyntax + { Body.Statements.Count: 1 } accessor + when accessor.Body!.Statements[0] is + ReturnStatementSyntax { Expression: { } body } => body, + _ => null + }; + if (expression == null) + { + return false; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(_compilation, expression.SyntaxTree); + return model.GetOperation(expression) is { } operation && + DefiniteOperationFacts.IsDefinitelyNonNull(operation); + } + + private bool TryGetGoverningListLength( + IListPatternOperation pattern, + out long length) + { + var value = SwitchExpressionFacts.GetGoverningValue(pattern); + if (value != null) + { + value = DefiniteOperationFacts.UnwrapHarmlessValue(value); + } + if (value is IArrayCreationOperation + { DimensionSizes.Length: 1 } arrayCreation && + arrayCreation.DimensionSizes[0].ConstantValue is + { HasValue: true, Value: int arrayLength }) + { + length = arrayLength; + return true; + } + if (pattern.LengthSymbol is IPropertySymbol + { GetMethod: { } lengthGetter } && + (!lengthGetter.IsVirtual || lengthGetter.IsSealed) && + TryGetIntegralConstantReturn(lengthGetter, out length)) + { + return true; + } + + length = 0; + return false; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1508:Avoid dead conditional code", + Justification = "The analyzer misreads the multi-branch nullable " + + "assignment above the null check as unreachable.")] + private bool TryGetIntegralConstantReturn( + IMethodSymbol method, + out long value) + { + value = 0; + if (method.DeclaringSyntaxReferences.Length != 1) + { + return false; + } + var declaration = method.DeclaringSyntaxReferences[0].GetSyntax(); + ExpressionSyntax? expression = null; + if (declaration is PropertyDeclarationSyntax + { ExpressionBody.Expression: { } propertyBody }) + { + expression = propertyBody; + } + else if (declaration is AccessorDeclarationSyntax + { ExpressionBody.Expression: { } accessorBody }) + { + expression = accessorBody; + } + else if (declaration is AccessorDeclarationSyntax + { Body.Statements.Count: 1 } accessor && + accessor.Body!.Statements[0] is ReturnStatementSyntax + { Expression: { } returnBody }) + { + expression = returnBody; + } + else if (declaration.DescendantNodes() + .OfType() + .FirstOrDefault() is { Expression: { } arrowBody }) + { + expression = arrowBody; + } + else if (declaration is ArrowExpressionClauseSyntax arrow) + { + expression = arrow.Expression; + } + if (expression == null) + { + return false; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(_compilation, expression.SyntaxTree); + var constant = model.GetConstantValue(expression); + if (!constant.HasValue || constant.Value == null) + { + return false; + } + try + { + value = Convert.ToInt64( + constant.Value, + System.Globalization.CultureInfo.InvariantCulture); + return value >= 0; + } + catch (Exception exception) when (exception is + FormatException or InvalidCastException or OverflowException) + { + return false; + } + } + + private bool IsPatternInputDefinitelyNonNull(IPatternOperation pattern) + { + IOperation? switchValue = null; + IOperation? origin = null; + if (pattern.Parent is ISwitchExpressionArmOperation arm && + arm.Parent is ISwitchExpressionOperation switchExpression) + { + switchValue = switchExpression.Value; + origin = switchExpression; + } + else if (pattern.Parent is IPatternCaseClauseOperation clause && + clause.Parent is ISwitchCaseOperation @case && + @case.Parent is ISwitchOperation switchStatement) + { + switchValue = switchStatement.Value; + origin = switchStatement; + } + + return switchValue != null && origin != null && + (DefiniteOperationFacts.IsDefinitelyNonNull(switchValue) || + _isProvenNonNull(switchValue, origin)); + } + internal bool CanCompleteInvocation( IMethodSymbol method, IOperation? instance, @@ -93,8 +592,95 @@ internal bool CanCompleteInvocation( return false; } - return method.DeclaringSyntaxReferences.Length == 0 || - _completionFacts.MethodCanCompleteNormally(method); + return StaticInitializationMayComplete(method) && + (method.DeclaringSyntaxReferences.Length == 0 || + _completionFacts.MethodCanCompleteNormally(method)); + } + + internal bool CanMethodCompleteNormally(IMethodSymbol method) + { + return StaticInitializationMayComplete(method) && + (method.DeclaringSyntaxReferences.Length == 0 || + _completionFacts.MethodCanCompleteNormally(method)); + } + + internal static bool RequiresStaticInitializationCompletion( + ISymbol member) + { + member = NormalizeStaticInitializationMember(member); + if (!member.IsStatic && member is not IMethodSymbol + { MethodKind: MethodKind.Constructor }) + { + return false; + } + if (member is IMethodSymbol + { MethodKind: MethodKind.StaticConstructor }) + { + return false; + } + + return member is IFieldSymbol { IsStatic: true, IsConst: false } || + member.ContainingType?.StaticConstructors.Any( + static constructor => !constructor.IsImplicitlyDeclared) == + true; + } + + internal static bool CanAssumeStaticInitializationComplete( + IMethodSymbol caller, + ISymbol member) + { + member = NormalizeStaticInitializationMember(member); + return SymbolEqualityComparer.Default.Equals( + caller.ContainingType, + member.ContainingType) && + (caller.MethodKind == MethodKind.StaticConstructor || + caller.ContainingType.StaticConstructors.Any( + static constructor => !constructor.IsImplicitlyDeclared)); + } + + internal static ISymbol NormalizeStaticInitializationMember( + ISymbol member) + { + return member is IMethodSymbol { ReducedFrom: { } reduced } + ? reduced + : member; + } + + internal bool CanCompleteWithClone(IWithOperation withOperation) + { + if (!CanCompleteNormally(withOperation.Operand) || + withOperation.Operand.Type?.IsReferenceType == true && + _isProvenNull(withOperation.Operand, withOperation)) + { + return false; + } + + if (withOperation.CloneMethod is not { } clone) + { + return true; + } + + var copyConstructor = GetRecordCopyConstructor(clone); + return copyConstructor == null + ? CanCompleteInvocation( + clone, + withOperation.Operand, + withOperation) + : CanCompleteInvocation( + copyConstructor, + instance: null, + withOperation); + } + + internal static IMethodSymbol? GetRecordCopyConstructor( + IMethodSymbol clone) + { + var type = clone.ContainingType; + return type.InstanceConstructors.FirstOrDefault(constructor => + constructor.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals( + constructor.Parameters[0].Type, + type)); } private bool CanCompleteProperty(IPropertyReferenceOperation property) @@ -110,15 +696,26 @@ private bool CanCompleteProperty(IPropertyReferenceOperation property) return property.Arguments.All(argument => CanCompleteNormally(argument.Value)) && + StaticInitializationMayComplete(property.Property) && (accessor.DeclaringSyntaxReferences.Length == 0 || _completionFacts.MethodCanCompleteNormally(accessor)); } + private bool CanCompleteMethodReference( + IMethodReferenceOperation methodReference) + { + return ChildrenCanComplete(methodReference) && + (methodReference.Method.IsStatic || + methodReference.Instance == null || + !_isProvenNull(methodReference.Instance, methodReference)); + } + private bool CanCompleteField(IFieldReferenceOperation field) { return (field.Instance == null || CanCompleteNormally(field.Instance) && - !_isProvenNull(field.Instance, field)); + !_isProvenNull(field.Instance, field)) && + StaticInitializationMayComplete(field.Field); } private bool CanCompleteArrayElement(IArrayElementReferenceOperation element) @@ -128,11 +725,173 @@ private bool CanCompleteArrayElement(IArrayElementReferenceOperation element) element.Indices.All(CanCompleteNormally); } + private bool CanCompleteWriteTarget(IOperation target) + { + return target switch + { + IFieldReferenceOperation field => + CanCompleteField(field), + IArrayElementReferenceOperation element => + CanCompleteArrayElement(element), + IPropertyReferenceOperation property + when property.Property.SetMethod is { } setter => + CanCompleteInvocation( + setter, + property.Instance, + property, + property.Arguments), + ILocalReferenceOperation or + IParameterReferenceOperation or + IDiscardOperation => true, + _ => true + }; + } + + private bool CanCompleteCoalesceAssignment( + ICoalesceAssignmentOperation assignment) + { + if (!CanCompleteNormally(assignment.Target)) + { + return false; + } + + if (_isProvenNonNull(assignment.Target, assignment)) + { + return true; + } + + return !_isProvenNull(assignment.Target, assignment) || + CanCompleteNormally(assignment.Value) && + CanCompleteWriteTarget(assignment.Target); + } + + private bool CanCompleteDeconstruction( + IDeconstructionAssignmentOperation deconstruction) + { + if (!CanCompleteNormally(deconstruction.Value)) + { + return false; + } + + var phasesMayComplete = !TryGetDeconstructionInfo( + _compilation, + deconstruction, + out var info) || + DeconstructionPhasesMayComplete( + info, + deconstruction.Value, + isRoot: true, + origin: deconstruction); + return phasesMayComplete && + CanCompleteDeconstructionTarget(deconstruction.Target); + } + + private bool CanCompleteDeconstructionTarget(IOperation target) + { + if (target is ITupleOperation tuple) + { + return tuple.Elements.All(CanCompleteDeconstructionTarget); + } + + return CanCompleteWriteTarget(target); + } + + private bool DeconstructionPhasesMayComplete( + Microsoft.CodeAnalysis.CSharp.DeconstructionInfo info, + IOperation value, + bool isRoot, + IOperation origin) + { + if (info.Method is { } method) + { + var callable = method.ReducedFrom ?? method; + var completes = isRoot && + !method.IsStatic && + method.ReducedFrom == null + ? CanCompleteInvocation(method, value, origin) + : CanMethodCompleteNormally(callable); + if (!completes) + { + return false; + } + } + + foreach (var nested in info.Nested.IsDefault + ? ImmutableArray.Empty + : info.Nested) + { + if (!DeconstructionPhasesMayComplete( + nested, + value, + isRoot: false, + origin: origin)) + { + return false; + } + } + + return info.Conversion?.MethodSymbol is not { } conversion || + CanMethodCompleteNormally(conversion); + } + + private static bool TryGetDeconstructionInfo( + Compilation compilation, + IDeconstructionAssignmentOperation operation, + out DeconstructionInfo info) + { + info = default; + if (operation.Syntax is not AssignmentExpressionSyntax syntax) + { + return false; + } + + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(compilation, syntax.SyntaxTree); + + info = model.GetDeconstructionInfo(syntax); + return true; + } + + internal bool CanCompleteCompoundValue( + ICompoundAssignmentOperation assignment) + { + if (!CanCompleteNormally(assignment.Target) || + !CanCompleteNormally(assignment.Value)) + { + return false; + } + + if (assignment.OperatorKind is + BinaryOperatorKind.Divide or BinaryOperatorKind.Remainder && + assignment.Value.ConstantValue is { HasValue: true, Value: 0 }) + { + return false; + } + + return assignment.OperatorMethod == null || + CanCompleteInvocation( + assignment.OperatorMethod, + instance: null, + assignment); + } + + internal bool CanCompleteIncrementValue( + IIncrementOrDecrementOperation increment) + { + return CanCompleteNormally(increment.Target) && + (increment.OperatorMethod == null || + CanCompleteInvocation( + increment.OperatorMethod, + instance: null, + increment)); + } + internal bool CanCompleteConstruction(IObjectCreationOperation creation) { if (creation.Arguments.Any(argument => !CanCompleteNormally(argument.Value)) || creation.Constructor is not { } constructor || + !StaticInitializationMayComplete(constructor) || constructor.DeclaringSyntaxReferences.Length != 0 && !_completionFacts.MethodCanCompleteNormally(constructor)) { @@ -157,6 +916,56 @@ private bool CanCompleteArrayCreation(IArrayCreationOperation array) CanCompleteNormally(array.Initializer); } + private bool StaticInitializationMayComplete(ISymbol member) + { + member = NormalizeStaticInitializationMember(member); + if (!RequiresStaticInitializationCompletion(member) || + CanAssumeStaticInitializationComplete(_caller, member) || + member.ContainingType is not { } type || + !EffectMethodNodeBuilder.HasPotentialStaticInitialization( + type, + _apiSpecs)) + { + return true; + } + + foreach (var typeMember in type.GetMembers()) + { + var isStaticInitializable = typeMember switch + { + IFieldSymbol field => field.IsStatic && !field.IsConst, + IPropertySymbol property => property.IsStatic, + IEventSymbol @event => @event.IsStatic, + _ => false + }; + if (!isStaticInitializable) + { + continue; + } + foreach (var reference in typeMember.DeclaringSyntaxReferences) + { + var expression = EffectProjections.GetInitializerExpression( + reference.GetSyntax()); + if (expression == null) + { + continue; + } + var model = SharpProof.Frontend.Host.CompilationModelProvider + .GetSemanticModel(_compilation, expression.SyntaxTree); + var operation = model.GetOperation(expression); + if (operation != null && + !_staticInitializationFacts.MayCompleteNormally(operation)) + { + return false; + } + } + } + + return type.StaticConstructors.All(constructor => + constructor.DeclaringSyntaxReferences.Length == 0 || + _completionFacts.MethodCanCompleteNormally(constructor)); + } + private bool CanCompleteConditionalAccess( IConditionalAccessOperation conditional) { @@ -174,6 +983,12 @@ private bool CanCompleteConditionalAccess( CanCompleteNormally(conditional.WhenNotNull); } + private bool CanCompleteWith(IWithOperation withOperation) + { + return CanCompleteWithClone(withOperation) && + CanCompleteNormally(withOperation.Initializer); + } + private bool CanCompleteLock(ILockOperation @lock) { return CanCompleteNormally(@lock.LockedValue) && @@ -188,21 +1003,86 @@ private bool CanCompleteConversion(IConversionOperation conversion) return false; } - return !(conversion.Type?.IsValueType == true && - conversion.Operand.ConstantValue is - { HasValue: true, Value: null }); + if (conversion.Type?.IsValueType == true && + conversion.Operand.ConstantValue is + { HasValue: true, Value: null }) + { + return false; + } + + return conversion.OperatorMethod == null || + CanCompleteInvocation( + conversion.OperatorMethod, + instance: null, + conversion); } private bool CanCompleteBinary(IBinaryOperation binary) { + if (binary.OperatorKind is BinaryOperatorKind.ConditionalAnd or + BinaryOperatorKind.ConditionalOr) + { + if (!CanCompleteNormally(binary.LeftOperand)) + { + return false; + } + + if (binary.OperatorMethod == null && + binary.LeftOperand.ConstantValue is + { HasValue: true, Value: bool left }) + { + var shortCircuits = binary.OperatorKind == + BinaryOperatorKind.ConditionalAnd + ? !left + : left; + return shortCircuits || + CanCompleteNormally(binary.RightOperand); + } + + if (binary.OperatorMethod != null) + { + var truthOperatorName = binary.OperatorKind == + BinaryOperatorKind.ConditionalAnd + ? "op_False" + : "op_True"; + var truthOperator = binary.OperatorMethod.ContainingType + .GetMembers(truthOperatorName) + .OfType() + .FirstOrDefault(method => method.Parameters.Length == 1); + return truthOperator == null || + CanMethodCompleteNormally(truthOperator); + } + + return true; + } + if (!ChildrenCanComplete(binary)) { return false; } - return binary.OperatorKind is not ( - BinaryOperatorKind.Divide or BinaryOperatorKind.Remainder) || - binary.RightOperand.ConstantValue is not { HasValue: true, Value: 0 }; + if (binary.OperatorKind is + BinaryOperatorKind.Divide or BinaryOperatorKind.Remainder && + binary.RightOperand.ConstantValue is { HasValue: true, Value: 0 }) + { + return false; + } + + return binary.OperatorMethod == null || + CanCompleteInvocation( + binary.OperatorMethod, + instance: null, + binary); + } + + private bool CanCompleteUnary(IUnaryOperation unary) + { + return CanCompleteNormally(unary.Operand) && + (unary.OperatorMethod == null || + CanCompleteInvocation( + unary.OperatorMethod, + instance: null, + unary)); } private bool CanCompleteConditional(IConditionalOperation conditional) @@ -212,6 +1092,15 @@ private bool CanCompleteConditional(IConditionalOperation conditional) return false; } + if (conditional.Condition.ConstantValue is + { HasValue: true, Value: bool condition }) + { + return CanCompleteNormally( + condition + ? conditional.WhenTrue + : conditional.WhenFalse); + } + return CanCompleteNormally(conditional.WhenTrue) || CanCompleteNormally(conditional.WhenFalse); } diff --git a/SharpProof.Effects/OperationEffectScanner.Assignments.cs b/SharpProof.Effects/OperationEffectScanner.Assignments.cs new file mode 100644 index 000000000..1d3b19f13 --- /dev/null +++ b/SharpProof.Effects/OperationEffectScanner.Assignments.cs @@ -0,0 +1,162 @@ +namespace SharpProof.Effects; + +internal sealed partial class OperationEffectScanner +{ + private EffectSummary ScanWriteTarget( + IOperation target, + IOperation value, + bool valueIsStoredDirectly = true) + { + target = _coalesceCaptures.Resolve(target); + return target switch + { + IFieldReferenceOperation field => ScanField(field, EffectAccess.Write), + IArrayElementReferenceOperation element => + ScanArrayElement( + element, + EffectAccess.Write, + valueIsStoredDirectly ? value : null), + IPropertyReferenceOperation property => + ScanProperty( + property, + EffectAccess.Write, + valueIsStoredDirectly ? value : null), + IParameterReferenceOperation parameter + when parameter.Parameter.RefKind is RefKind.Ref or RefKind.Out => + EffectSummaryOperations.Write( + _conversionOwnership.ClassifyParameter(parameter.Parameter)), + ILocalReferenceOperation or IParameterReferenceOperation or IDiscardOperation => + EffectSummary.Empty, + _ => EffectSummaryOperations.Join( + Scan(target), + EffectSummaryOperations.Unsupported()) + }; + } + + private EffectSummary ScanSimpleAssignment( + ISimpleAssignmentOperation assignment) + { + var result = ScanWriteTargetEvaluation(assignment.Target); + if (!result.CompletesNormally) + { + return result.Summary; + } + + result = result.Then(ScanStep(assignment.Value)); + return !result.CompletesNormally + ? result.Summary + : result.Then(new EffectStep( + ScanWriteTarget(assignment.Target, assignment.Value), + true)).Summary; + } + + private EffectStep ScanWriteTargetEvaluation(IOperation target) + { + target = _coalesceCaptures.Resolve(target); + return target switch + { + IFieldReferenceOperation { Instance: { } instance } => + ScanStep(instance), + IArrayElementReferenceOperation element => + ScanStep(element.ArrayReference).Then( + ScanSequence(element.Indices)), + IPropertyReferenceOperation property => + ScanSequence( + property.Instance == null + ? property.Arguments.Select( + static argument => argument.Value) + : new[] { property.Instance }.Concat( + property.Arguments.Select( + static argument => argument.Value))), + IFieldReferenceOperation or + ILocalReferenceOperation or + IParameterReferenceOperation or + IDiscardOperation => EffectStep.Empty, + _ => ScanStep(target) + }; + } + + private EffectSummary ScanCompoundAssignment( + ICompoundAssignmentOperation assignment) + { + return ScanReadModifyWrite( + assignment.Target, + () => ScanStep(assignment.Value), + () => EffectSummaryOperations.Join( + ResolveOperatorEffects( + assignment.OperatorMethod, + [assignment.Target, assignment.Value], + assignment), + IntegralDivisionExceptions( + assignment.OperatorKind, + assignment.Type, + assignment.Target, + assignment.Value, + assignment), + _conversionEffects.CheckedOverflow( + assignment.IsChecked, + assignment)), + () => _completionEvaluator.CanCompleteCompoundValue(assignment), + assignment.Value); + } + + private EffectSummary ScanReadModifyWrite( + IOperation target, + Func scanValue, + Func scanOperation, + Func canCompleteOperation, + IOperation storedValue) + { + var result = new EffectStep( + Scan(target, EffectAccess.Read), + _completionEvaluator.CanCompleteNormally(target)); + if (!result.CompletesNormally) + { + return result.Summary; + } + + result = result.Then(scanValue()); + if (!result.CompletesNormally) + { + return result.Summary; + } + + result = result.Then(new EffectStep( + scanOperation(), + canCompleteOperation())); + return !result.CompletesNormally + ? result.Summary + : result.Then(new EffectStep( + ScanWriteTarget( + target, + storedValue, + valueIsStoredDirectly: false), + true)).Summary; + } + + private EffectSummary ScanCoalesceAssignment( + ICoalesceAssignmentOperation assignment) + { + var result = new EffectStep( + Scan(assignment.Target, EffectAccess.Read), + _completionEvaluator.CanCompleteNormally(assignment.Target)); + if (!result.CompletesNormally) + { + return result.Summary; + } + + if (_abstractFlow?.ProvesNonNull( + assignment, + assignment.Target) == true) + { + return result.Summary; + } + + result = result.Then(ScanStep(assignment.Value)); + return !result.CompletesNormally + ? result.Summary + : result.Then(new EffectStep( + ScanWriteTarget(assignment.Target, assignment.Value), + true)).Summary; + } +} diff --git a/SharpProof.Effects/OperationEffectScanner.Expressions.cs b/SharpProof.Effects/OperationEffectScanner.Expressions.cs new file mode 100644 index 000000000..7d9503fa3 --- /dev/null +++ b/SharpProof.Effects/OperationEffectScanner.Expressions.cs @@ -0,0 +1,385 @@ +namespace SharpProof.Effects; + +internal sealed partial class OperationEffectScanner +{ + private EffectSummary ScanPropertySubpattern( + IPropertySubpatternOperation propertySubpattern) + { + var member = ScanStep(propertySubpattern.Member); + return member.CompletesNormally + ? member.Then(ScanStep(propertySubpattern.Pattern)).Summary + : member.Summary; + } + + private EffectSummary ScanDeconstruction( + IDeconstructionAssignmentOperation deconstruction) + { + var value = ScanStep(deconstruction.Value); + if (!value.CompletesNormally) + { + return value.Summary; + } + + var completes = _completionEvaluator.CanCompleteNormally(deconstruction); + return value.Then(new EffectStep( + completes + ? EffectSummaryOperations.Unsupported() + : EffectSummaryOperations.MayDiverge(), + completes)).Summary; + } + + private EffectSummary ScanEventAssignment( + IEventAssignmentOperation eventAssignment) + { + if (eventAssignment.EventReference is not IEventReferenceOperation reference) + { + return EffectSummaryOperations.Unsupported(); + } + + var result = reference.Instance == null + ? EffectStep.Empty + : ScanStep(reference.Instance); + if (!result.CompletesNormally) + { + return result.Summary; + } + + var receiverCheck = new EffectStep( + PotentialNullReceiver(reference.Instance, eventAssignment), + _nullnessEvaluator.IsProvenNonNull( + reference.Instance, + eventAssignment)); + result = result.Then(receiverCheck); + if (!result.CompletesNormally) + { + return result.Summary; + } + + result = result.Then(ScanStep(eventAssignment.HandlerValue)); + if (!result.CompletesNormally) + { + return result.Summary; + } + + var accessor = eventAssignment.Adds + ? reference.Event.AddMethod + : reference.Event.RemoveMethod; + if (accessor == null) + { + return EffectSummaryOperations.Join( + result.Summary, + EffectSummaryOperations.Unsupported()); + } + + var handlerRegions = _conversionOwnership.ClassifyRegion( + eventAssignment.HandlerValue); + var call = _callResolver.Resolve( + accessor, + _conversionOwnership.ClassifyRegion(reference.Instance), + _conversionOwnership.ClassifyRegion(reference.Instance), + [handlerRegions], + [eventAssignment.HandlerValue], + accessor.IsVirtual || accessor.IsAbstract, + eventAssignment, + reference.Instance); + return result.Then(new EffectStep( + call, + _completionEvaluator.CanCompleteNormally(eventAssignment))).Summary; + } + + private EffectSummary ScanAwait(IAwaitOperation awaitOperation) + { + var operand = ScanStep(awaitOperation.Operation); + if (!operand.CompletesNormally) + { + return operand.Summary; + } + + var receiver = awaitOperation.Operation; + var nullCheck = new EffectStep( + PotentialNullReceiver(receiver, awaitOperation), + _nullnessEvaluator.IsProvenNonNull(receiver, awaitOperation)); + return operand.Then(nullCheck).Summary; + } + + private EffectSummary ScanWith(IWithOperation withOperation) + { + EffectStep clone; + if (withOperation.CloneMethod is { } cloneMethod) + { + var callMethod = OperationCompletionEvaluator + .GetRecordCopyConstructor(cloneMethod) ?? cloneMethod; + clone = ScanCallStep( + callMethod, + withOperation.Operand, + [], + [], + [], + dispatchUncertain: false, + withOperation); + } + else + { + clone = ScanStep(withOperation.Operand); + } + + clone = new EffectStep( + clone.Summary, + clone.CompletesNormally && + _completionEvaluator.CanCompleteWithClone(withOperation)); + return withOperation.Initializer != null && clone.CompletesNormally + ? clone.Then(ScanStep(withOperation.Initializer)).Summary + : clone.Summary; + } + + private EffectSummary ScanLock(ILockOperation @lock) + { + var receiver = ScanStep(@lock.LockedValue); + if (!receiver.CompletesNormally) + { + return receiver.Summary; + } + + var entry = new EffectStep( + EffectSummaryOperations.Join( + PotentialNullLock(@lock.LockedValue, @lock), + EffectSummaryOperations.Capability( + EffectCapabilityKind.Synchronization)), + !_nullnessEvaluator.IsProvenNull(@lock.LockedValue, @lock)); + var result = receiver.Then(entry); + if (@lock.Body != null && result.CompletesNormally) + { + result = result.Then(ScanStep(@lock.Body)); + } + return result.Summary; + } + + private EffectSummary ScanIncrementOrDecrement( + IIncrementOrDecrementOperation increment) + { + return ScanReadModifyWrite( + increment.Target, + () => EffectStep.Empty, + () => EffectSummaryOperations.Join( + _conversionEffects.CheckedOverflow( + increment.IsChecked, + increment), + ResolveOperatorEffects( + increment.OperatorMethod, + [increment.Target], + increment)), + () => _completionEvaluator.CanCompleteIncrementValue(increment), + increment.Target); + } + + private EffectSummary ScanBinary(IBinaryOperation binary) + { + var operands = ScanStep(binary.LeftOperand); + if (!operands.CompletesNormally) + { + return operands.Summary; + } + + operands = operands.Then(ScanStep(binary.RightOperand)); + if (!operands.CompletesNormally) + { + return operands.Summary; + } + + var operation = EffectSummaryOperations.Join( + StringConcatenationEffectResolver.Resolve( + binary, + _session.Compilation, + _callResolver, + _abstractFlow, + _conversionOwnership.ClassifyRegion), + IntegralDivisionExceptions(binary.OperatorKind, binary.Type, + binary.LeftOperand, binary.RightOperand, binary), + _conversionEffects.CheckedOverflow(binary.IsChecked, binary), + ResolveOperatorEffects( + binary.OperatorMethod, + [binary.LeftOperand, binary.RightOperand], + binary)); + return operands.Then(new EffectStep( + operation, + _completionEvaluator.CanCompleteNormally(binary))).Summary; + } + + private EffectSummary ScanInterpolatedString( + IInterpolatedStringOperation interpolation) + { + if (interpolation.ConstantValue.HasValue) + { + return EffectSummary.Empty; + } + + var result = EffectStep.Empty; + foreach (var part in interpolation.Parts) + { + if (part is not IInterpolationOperation value) + { + continue; + } + + result = result.Then(ScanStep(value.Expression)); + if (!result.CompletesNormally) + { + return result.Summary; + } + + if (value.Alignment != null || value.FormatString != null) + { + if (value.Alignment != null) + { + result = result.Then(ScanStep(value.Alignment)); + } + if (result.CompletesNormally && value.FormatString != null) + { + result = result.Then(ScanStep(value.FormatString)); + } + if (!result.CompletesNormally) + { + return result.Summary; + } + result = result.Then(new EffectStep( + EffectSummaryOperations.Unsupported(), + CompletesNormally: true)); + continue; + } + + var formattedValue = + StringConcatenationEffectResolver.ResolveFormattedValue( + value.Expression, + value, + _session.Compilation, + _callResolver, + _abstractFlow, + _conversionOwnership.ClassifyRegion); + result = result.Then(new EffectStep( + formattedValue, + StringConcatenationEffectResolver + .CanFormattedValueCompleteNormally( + value.Expression, + value, + _session.Compilation, + _abstractFlow, + _completionEvaluator))); + if (!result.CompletesNormally) + { + return result.Summary; + } + } + return result.Then(new EffectStep( + EffectSummaryOperations.Allocate(EffectAllocationKind.Managed), + CompletesNormally: true)).Summary; + } + + private EffectSummary ScanUnary(IUnaryOperation unary) + { + var operand = ScanStep(unary.Operand); + if (!operand.CompletesNormally) + { + return operand.Summary; + } + + var operation = EffectSummaryOperations.Join( + _conversionEffects.CheckedOverflow(unary.IsChecked, unary), + ResolveOperatorEffects(unary.OperatorMethod, [unary.Operand], unary)); + return operand.Then(new EffectStep( + operation, + _completionEvaluator.CanCompleteNormally(unary))).Summary; + } + + private EffectSummary ScanConversion(IConversionOperation operation) + { + if (!string.Equals(operation.Syntax.Language, LanguageNames.CSharp, StringComparison.Ordinal)) + { + return EffectSummaryOperations.Join( + Scan(operation.Operand), + EffectSummaryOperations.Unsupported()); + } + + var operand = ScanStep(operation.Operand); + if (!operand.CompletesNormally) + { + return operand.Summary; + } + + var conversion = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetConversion(operation); + var conversionEffect = EffectSummaryOperations.Join( + _conversionEffects.Classify(operation, conversion), + ResolveOperatorEffects(operation.OperatorMethod, [operation.Operand], operation)); + return operand.Then(new EffectStep( + conversionEffect, + _completionEvaluator.CanCompleteNormally(operation))).Summary; + } + + private EffectSummary ResolveOperatorEffects( + IMethodSymbol? method, + ImmutableArray operands, + IOperation origin) + { + return _callResolver.ResolveOperator( + method, + EffectRegionSet.Empty, + [.. operands.Select(operand => _conversionOwnership.ClassifyRegion(operand))], + operands, + origin); + } + + private EffectSummary Throw(params string[] exceptionMetadataNames) + { + return EffectSummaryOperations.Throw(_session.ResolveExceptionSet(exceptionMetadataNames)); + } + + private EffectSummary ScanDefault(IOperation operation) + { + var classification = OperationSubsetClassifier.Classify( + OperationSupportStage.EffectDiscovery, + operation.Kind); + var children = ScanChildren(operation); + return classification.IsExact + ? children + : EffectSummaryOperations.Join(children, EffectSummaryOperations.Unsupported()); + } + + private EffectSummary ScanCoreOperationTail(IOperation operation) + { + return operation switch + { + IDelegateCreationOperation allocation => + ScanManagedAllocation(allocation), + IAnonymousObjectCreationOperation allocation => + ScanManagedAllocation(allocation), + IThrowOperation thrown when IsSourceThrow(thrown) => + ScanThrow(thrown), + IInterpolatedStringOperation interpolation => + ScanInterpolatedString(interpolation), + IThrowOperation => EffectSummary.Empty, + IBinaryOperation binary => ScanBinary(binary), + IUnaryOperation unary => ScanUnary(unary), + IConversionOperation conversion => ScanConversion(conversion), + IConditionalAccessOperation conditional => + ScanConditionalAccess(conditional), + ISwitchExpressionOperation switchExpression => + ScanSwitchExpression(switchExpression), + IListPatternOperation listPattern => + ScanListPattern(listPattern), + IIsPatternOperation isPattern => ScanChildren(isPattern), + ITupleOperation tuple => ScanChildren(tuple), + IPropertySubpatternOperation propertySubpattern => + ScanPropertySubpattern(propertySubpattern), + IPatternOperation => ScanDefaultPattern(operation), + IWithOperation withOperation => ScanWith(withOperation), + ILockOperation @lock => ScanLock(@lock), + ILoopOperation loop => EffectSummaryOperations.Join( + ScanChildren(loop), EffectSummaryOperations.MayDiverge()), + IInvalidOperation or IDynamicInvocationOperation or + IDynamicIndexerAccessOperation or IFunctionPointerInvocationOperation => + EffectSummaryOperations.Join( + ScanChildren(operation), + EffectSummaryOperations.Unsupported()), + _ => ScanDefault(operation) + }; + } +} diff --git a/SharpProof.Effects/OperationEffectScanner.cs b/SharpProof.Effects/OperationEffectScanner.cs index a33986973..9bfac7a6e 100644 --- a/SharpProof.Effects/OperationEffectScanner.cs +++ b/SharpProof.Effects/OperationEffectScanner.cs @@ -2,7 +2,7 @@ namespace SharpProof.Effects; -internal sealed class OperationEffectScanner +internal sealed partial class OperationEffectScanner { private readonly ManagedFlowResult? _abstractFlow; private readonly bool _allowDirectWitnesses; @@ -52,14 +52,12 @@ internal OperationEffectScanner( _conversionEffects = new ConversionEffectClassifier(session, abstractFlow); _conversionOwnership = new ConversionOwnershipClassifier( _method, + session.Compilation, _coalesceCaptures, _creationCaptures); _allowDirectWitnesses = allowDirectWitnesses; _directSyntax = GetDirectSyntax(root.Syntax); _exceptionType = session.Compilation.GetTypeByMetadataName(FrameworkTypeMetadataNames.Exception); - _handlerReachability = new ExceptionHandlerReachability( - session.Compilation, - abstractFlow); _monitorType = session.Compilation.GetTypeByMetadataName(FrameworkTypeMetadataNames.Monitor); _nullnessEvaluator = new OperationNullnessEvaluator( session, @@ -68,9 +66,22 @@ internal OperationEffectScanner( _monitorType); _completionEvaluator = new OperationCompletionEvaluator( session, + method, _nullnessEvaluator.IsProvenNull, _nullnessEvaluator.IsProvenNonNull, _nullnessEvaluator.IsImplicitLockEnterWithNullValue); + _handlerReachability = new ExceptionHandlerReachability( + session.Compilation, + _method, + abstractFlow, + _completionEvaluator.CanCompleteNormally, + _completionEvaluator.CanMethodCompleteNormally, + _completionEvaluator.CanCompleteCompoundValue, + _completionEvaluator.CanCompleteIncrementValue, + _completionEvaluator.CanCompleteWithClone, + _completionEvaluator.GetReachableImplicitListPatternMembers, + session.ApiSpecs, + HasNonThrowingMethodSpec); // ManagedAbstractFlow currently follows regular CFG edges. Its facts // remain useful in a try body, but absence of a fact cannot prove an // operation unreachable after a normally completing handler. The @@ -85,12 +96,13 @@ internal OperationEffectScanner( _freshArrayTypes[creation.Syntax.SpanStart] = type; } } - _conversionOwnership.BuildLocalRegions(root); + _conversionOwnership.BuildLocalRegions(root, IsReachable); } internal ImmutableArray DirectWitnesses => _directWitnesses.ToImmutable(); + internal EffectSummary Scan(IOperation operation) { operation = ArgumentNullGuard.NotNull(operation, nameof(operation)); @@ -133,19 +145,28 @@ operation is ILockOperation or IThrowOperation && { RecordDirectLock(directLock); } - else if (operation is IThrowOperation) + else if (operation is IThrowOperation thrown && + CanReachThrow(thrown)) { RecordDirect(operation); } } var lexical = operation switch { - ILockOperation @lock => EffectSummaryOperations.Join( - PotentialNullLock(@lock.LockedValue, @lock), - EffectSummaryOperations.Capability(EffectCapabilityKind.Synchronization)), - IThrowOperation thrown when IsSourceThrow(thrown) => EffectExceptionFlow.KeepEscaping( - EffectSummaryOperations.Throw( - ResolveThrownException(thrown)), + ILockOperation @lock + when _completionEvaluator.CanCompleteNormally( + @lock.LockedValue) => EffectSummaryOperations.Join( + PotentialNullLock(@lock.LockedValue, @lock), + EffectSummaryOperations.Capability( + EffectCapabilityKind.Synchronization)), + IThrowOperation thrown when IsSourceThrow(thrown) && + CanReachThrow(thrown) => EffectExceptionFlow.KeepEscaping( + IsUnmodeledExternalExceptionConstruction(thrown.Exception) + ? EffectSummaryOperations.ExceptionConstructionThrow( + EffectSummary.Empty, + ResolveThrownException(thrown)) + : EffectSummaryOperations.Throw( + ResolveThrownException(thrown)), thrown, _session.Compilation), _ => EffectSummary.Empty }; @@ -160,7 +181,13 @@ internal EffectSummary ScanUsingDisposalEffects(IOperation root) _session.Compilation, _method, _callResolver, - _abstractFlow).Scan(root, _conversionOwnership.ClassifyRegion); + _abstractFlow).Scan( + root, + _conversionOwnership.ClassifyRegion, + _completionEvaluator.CanCompleteNormally, + _completionEvaluator.CanMethodCompleteNormally, + _handlerReachability.CanMethodThrow, + _handlerReachability.CanExitAbruptly); } private EffectSummary Scan(IOperation operation, EffectAccess access) @@ -192,12 +219,23 @@ private EffectSummary ScanCore(IOperation operation, EffectAccess access) RecordDirect(operation); } - var summary = operation switch + return EffectExceptionFlow.KeepEscaping( + ScanCoreOperation(operation, access), + operation, + _session.Compilation); + } + + private EffectSummary ScanCoreOperation( + IOperation operation, + EffectAccess access) + { + return operation switch { IAnonymousFunctionOperation or ILocalFunctionOperation or ILiteralOperation or ILocalReferenceOperation or IInstanceReferenceOperation or IDefaultValueOperation or ITypeOfOperation or INameOfOperation or ISizeOfOperation => EffectSummary.Empty, IFlowCaptureOperation capture => ScanFlowCapture(capture), + IFlowCaptureReferenceOperation => EffectSummary.Empty, IParameterReferenceOperation parameter => parameter.Parameter.RefKind is RefKind.Ref or RefKind.Out || PrimaryConstructorParameterOwnership.IsReceiverBacked( @@ -211,46 +249,23 @@ parameter.Parameter.RefKind is RefKind.Ref or RefKind.Out || IArrayElementReferenceOperation element => ScanArrayElement(element, access), ICoalesceAssignmentOperation assignment => ScanCoalesceAssignment(assignment), - ISimpleAssignmentOperation assignment => EffectSummaryOperations.Join( - Scan(assignment.Value), - ScanWriteTarget(assignment.Target, assignment.Value)), + IDeconstructionAssignmentOperation deconstruction => + ScanDeconstruction(deconstruction), + IEventAssignmentOperation eventAssignment => + ScanEventAssignment(eventAssignment), + IAwaitOperation awaitOperation => ScanAwait(awaitOperation), + ISimpleAssignmentOperation assignment => + ScanSimpleAssignment(assignment), ICompoundAssignmentOperation assignment => ScanCompoundAssignment(assignment), - IIncrementOrDecrementOperation increment => EffectSummaryOperations.Join( - Scan(increment.Target, EffectAccess.Read), - ScanWriteTarget(increment.Target, increment.Target, valueIsStoredDirectly: false), - _conversionEffects.CheckedOverflow(increment.IsChecked, increment), - ResolveOperatorEffects(increment.OperatorMethod, [increment.Target], increment)), + IIncrementOrDecrementOperation increment => + ScanIncrementOrDecrement(increment), + IMethodReferenceOperation methodReference => + ScanMethodReference(methodReference), IInvocationOperation invocation => ScanInvocation(invocation), IObjectCreationOperation creation => ScanObjectCreation(creation), IArrayCreationOperation array => ScanArrayCreation(array), - IOperation allocation when allocation is - IDelegateCreationOperation or IAnonymousObjectCreationOperation => - EffectSummaryOperations.Join( - ScanChildren(allocation), - EffectSummaryOperations.Allocate(EffectAllocationKind.Managed)), - IThrowOperation thrown when IsSourceThrow(thrown) => EffectSummaryOperations.Join( - ScanChildren(thrown), - EffectSummaryOperations.Throw( - ResolveThrownException(thrown))), - IInterpolatedStringOperation interpolation => - ScanInterpolatedString(interpolation), - IThrowOperation => EffectSummary.Empty, - IBinaryOperation binary => ScanBinary(binary), - IUnaryOperation unary => ScanUnary(unary), - IConversionOperation conversion => ScanConversion(conversion), - IConditionalAccessOperation conditional => - ScanConditionalAccess(conditional), - ILockOperation @lock => ScanLock(@lock), - ILoopOperation loop => EffectSummaryOperations.Join( - ScanChildren(loop), EffectSummaryOperations.MayDiverge()), - IInvalidOperation or IDynamicInvocationOperation or - IDynamicIndexerAccessOperation or IFunctionPointerInvocationOperation => - EffectSummaryOperations.Join( - ScanChildren(operation), - EffectSummaryOperations.Unsupported()), - _ => ScanDefault(operation) + _ => ScanCoreOperationTail(operation) }; - return EffectExceptionFlow.KeepEscaping(summary, operation, _session.Compilation); } private EffectSummary ScanField(IFieldReferenceOperation field, EffectAccess access) @@ -301,25 +316,13 @@ private EffectSummary ScanProperty( if (PrimaryConstructorParameterOwnership .IsPositionalRecordProperty(property.Property)) { - var region = _conversionOwnership.ClassifyRegion( - property.Instance, - aliasSource: true); - return EffectSummaryOperations.Join( - ScanInstance(property.Instance), - PotentialNullReceiver(property.Instance, property), - access == EffectAccess.Read - ? EffectSummaryOperations.Read(region) - : EffectSummaryOperations.Write(region)); + return ScanIntrinsicProperty(property, access); } if (access == EffectAccess.Read && IsIntrinsicArrayCardinalityProperty(property)) { - return EffectSummaryOperations.Join( - ScanInstance(property.Instance), - PotentialNullReceiver(property.Instance, property), - EffectSummaryOperations.Read( - _conversionOwnership.ClassifyRegion(property.Instance, aliasSource: true))); + return ScanIntrinsicProperty(property, access); } var accessor = access == EffectAccess.Read @@ -363,6 +366,60 @@ private EffectSummary ScanProperty( property); } + private EffectSummary ScanIntrinsicProperty( + IPropertyReferenceOperation property, + EffectAccess access) + { + var instance = property.Instance == null + ? EffectStep.Empty + : ScanStep(property.Instance); + if (!instance.CompletesNormally) + { + return instance.Summary; + } + + var receiverCheck = property.Instance == null + ? EffectStep.Empty + : new EffectStep( + PotentialNullReceiver(property.Instance, property), + !_nullnessEvaluator.IsProvenNull( + property.Instance, + property)); + var evaluation = instance.Then(receiverCheck); + if (!evaluation.CompletesNormally) + { + return evaluation.Summary; + } + + var region = _conversionOwnership.ClassifyRegion( + property.Instance, + aliasSource: true); + return EffectSummaryDomain.Instance.Join( + evaluation.Summary, + access == EffectAccess.Read + ? EffectSummaryOperations.Read(region) + : EffectSummaryOperations.Write(region)); + } + + private EffectSummary ScanMethodReference( + IMethodReferenceOperation methodReference) + { + var instance = methodReference.Instance == null + ? EffectStep.Empty + : ScanStep(methodReference.Instance); + if (!instance.CompletesNormally || + methodReference.Method.IsStatic || + methodReference.Instance == null) + { + return instance.Summary; + } + return EffectSummaryOperations.Join( + instance.Summary, + PotentialNullReceiver( + methodReference.Instance, + methodReference)); + } + private EffectSummary ScanArrayElement( IArrayElementReferenceOperation element, EffectAccess access, @@ -418,36 +475,6 @@ element.ArrayReference.Type is IArrayTypeSymbol arrayType && exceptions); } - private EffectSummary ScanWriteTarget( - IOperation target, - IOperation value, - bool valueIsStoredDirectly = true) - { - target = _coalesceCaptures.Resolve(target); - return target switch - { - IFieldReferenceOperation field => ScanField(field, EffectAccess.Write), - IArrayElementReferenceOperation element => - ScanArrayElement( - element, - EffectAccess.Write, - valueIsStoredDirectly ? value : null), - IPropertyReferenceOperation property => - ScanProperty( - property, - EffectAccess.Write, - valueIsStoredDirectly ? value : null), - IParameterReferenceOperation parameter - when parameter.Parameter.RefKind is RefKind.Ref or RefKind.Out => - EffectSummaryOperations.Write( - _conversionOwnership.ClassifyParameter(parameter.Parameter)), - ILocalReferenceOperation or IParameterReferenceOperation or IDiscardOperation => EffectSummary.Empty, - _ => EffectSummaryOperations.Join( - Scan(target), - EffectSummaryOperations.Unsupported()) - }; - } - private EffectSummary ScanFlowCapture(IFlowCaptureOperation capture) { _coalesceCaptures.Record(capture); @@ -455,40 +482,6 @@ private EffectSummary ScanFlowCapture(IFlowCaptureOperation capture) return Scan(capture.Value); } - private EffectSummary ScanCompoundAssignment(ICompoundAssignmentOperation assignment) - { - var operatorCall = ResolveOperatorEffects( - assignment.OperatorMethod, - [assignment.Target, assignment.Value], - assignment); - var exceptions = IntegralDivisionExceptions(assignment.OperatorKind, assignment.Type, - assignment.Target, assignment.Value, assignment); - return EffectSummaryOperations.Join( - Scan(assignment.Target, EffectAccess.Read), - Scan(assignment.Value), - ScanWriteTarget(assignment.Target, assignment.Value, valueIsStoredDirectly: false), - operatorCall, - exceptions, - _conversionEffects.CheckedOverflow(assignment.IsChecked, assignment)); - } - - private EffectSummary ScanCoalesceAssignment( - ICoalesceAssignmentOperation assignment) - { - var targetRead = Scan(assignment.Target, EffectAccess.Read); - if (_abstractFlow?.ProvesNonNull( - assignment, - assignment.Target) == true) - { - return targetRead; - } - - return EffectSummaryOperations.Join( - targetRead, - Scan(assignment.Value), - ScanWriteTarget(assignment.Target, assignment.Value)); - } - private bool ArrayStoreIsDefinitelyCompatible( IArrayElementReferenceOperation element, IArrayTypeSymbol arrayType, @@ -548,6 +541,21 @@ operatorKind is not (BinaryOperatorKind.Divide or BinaryOperatorKind.Remainder) private EffectSummary ScanInvocation(IInvocationOperation invocation) { + if (invocation.TargetMethod.Name == "$" && + invocation.Syntax.ToString().IndexOf("with", StringComparison.Ordinal) >= 0 && + OperationCompletionEvaluator.GetRecordCopyConstructor( + invocation.TargetMethod) is { } copyConstructor) + { + var cloneCallStep = ScanCallStep( + copyConstructor, + invocation.Instance, + [], + [], + [], + dispatchUncertain: false, + invocation); + return cloneCallStep.Summary; + } if (UsingDisposalEffectResolver .IsSynthesizedSynchronousDispose(invocation)) { @@ -639,9 +647,15 @@ private EffectStep ScanCallStep( } } + var receiverRegion = receiver ?? + _conversionOwnership.ClassifyRegion(instance); + var writeReceiver = UsesDefensiveReceiverCopy(method, instance) + ? EffectRegionSet.Empty + : receiverRegion; var call = _callResolver.Resolve( method, - receiver ?? _conversionOwnership.ClassifyRegion(instance), + receiverRegion, + writeReceiver, argumentRegions, actualArguments, dispatchUncertain, @@ -653,9 +667,33 @@ private EffectStep ScanCallStep( _completionEvaluator.CanCompleteInvocation(method, instance, origin))); } - private EffectSummary ScanInstance(IOperation? instance) + private bool UsesDefensiveReceiverCopy( + IMethodSymbol method, + IOperation? instance) { - return instance == null ? EffectSummary.Empty : Scan(instance); + if (instance == null || method.IsStatic || method.IsReadOnly || + method.ContainingType?.IsRefLikeType == true || + method.ContainingType?.IsValueType != true) + { + return false; + } + instance = DefiniteOperationFacts.UnwrapHarmlessValue(instance); + return instance switch + { + IInstanceReferenceOperation => _method.IsReadOnly, + IParameterReferenceOperation parameter => + parameter.Parameter.RefKind is RefKind.In or + RefKind.RefReadOnlyParameter, + ILocalReferenceOperation local => + local.Local.RefKind is RefKind.RefReadOnly or + RefKind.RefReadOnlyParameter, + IFieldReferenceOperation field => field.Field.IsReadOnly, + IPropertyReferenceOperation property => + property.Property.ReturnsByRefReadonly, + IInvocationOperation invocation => + invocation.TargetMethod.ReturnsByRefReadonly, + _ => false + }; } private EffectSummary ScanArgumentValues( @@ -667,6 +705,10 @@ private EffectSummary ScanArgumentValues( private EffectSummary ScanObjectCreation(IObjectCreationOperation creation) { + if (creation.IsImplicit) + { + return EffectSummary.Empty; + } var receiver = EffectRegionSet.Create(EffectRegionId.Fresh(creation.Syntax.SpanStart)); var arguments = ScanSequence( creation.Arguments.Select(static argument => argument.Value)); @@ -675,17 +717,19 @@ private EffectSummary ScanObjectCreation(IObjectCreationOperation creation) : EffectSummaryOperations.Allocate(EffectAllocationKind.Managed); if (!arguments.CompletesNormally) { - return EffectSummaryDomain.Instance.Join( - arguments.Summary, - allocation); + return arguments.Summary; } - var construction = _callResolver.ResolveConstruction( - creation, - receiver, - ClassifyArguments( - creation.Arguments, - creation.Constructor?.Parameters.Length ?? 0)); + var construction = IsUnmodeledExternalExceptionConstruction(creation) && + creation.Syntax.AncestorsAndSelf().Any(static syntax => + syntax is ThrowExpressionSyntax or ThrowStatementSyntax) + ? EffectSummary.Empty + : _callResolver.ResolveConstruction( + creation, + receiver, + ClassifyArguments( + creation.Arguments, + creation.Constructor?.Parameters.Length ?? 0)); var constructor = new EffectStep( EffectSummaryDomain.Instance.Join(allocation, construction), _completionEvaluator.CanCompleteConstruction(creation)); @@ -698,6 +742,78 @@ private EffectSummary ScanObjectCreation(IObjectCreationOperation creation) return result.Summary; } + private EffectSummary ScanManagedAllocation(IOperation allocation) + { + var children = ScanSequence(allocation.ChildOperations); + return children.CompletesNormally + ? children.Then(new EffectStep( + EffectSummaryOperations.Allocate( + EffectAllocationKind.Managed), + true)).Summary + : children.Summary; + } + + private EffectSummary ScanThrow(IThrowOperation thrown) + { + if (thrown.Exception is { } exception && + DefiniteOperationFacts.UnwrapHarmlessValue(exception) + is IObjectCreationOperation creation && + IsExternalExceptionConstruction(creation) && + !HasNonThrowingConstructorSpec(creation)) + { + var arguments = ScanSequence( + creation.Arguments.Select(static argument => argument.Value)); + if (!arguments.CompletesNormally) + { + return arguments.Summary; + } + + var receiver = EffectRegionSet.Create( + EffectRegionId.Fresh(creation.Syntax.SpanStart)); + var construction = _callResolver.ResolveConstruction( + creation, + receiver, + ClassifyArguments( + creation.Arguments, + creation.Constructor?.Parameters.Length ?? 0)); + return EffectSummaryOperations.ExceptionConstructionThrow( + construction, + ResolveThrownException(thrown)); + } + var expression = thrown.Exception == null + ? EffectStep.Empty + : ScanStep(thrown.Exception); + return expression.CompletesNormally + ? expression.Then(new EffectStep( + EffectSummaryOperations.Throw( + ResolveThrownException(thrown)), + false)).Summary + : expression.Summary; + } + + private bool IsUnmodeledExternalExceptionConstruction(IOperation? operation) + { + if (operation == null) + { + return false; + } + operation = DefiniteOperationFacts.UnwrapHarmlessValue(operation); + return operation is IObjectCreationOperation creation && + IsExternalExceptionConstruction(creation) && + !HasNonThrowingConstructorSpec(creation); + } + + private bool IsExternalExceptionConstruction( + IObjectCreationOperation creation) + { + return + creation.Type is INamedTypeSymbol type && + _exceptionType is { } exceptionType && + EffectTypeFacts.IsDerivedFrom(type, exceptionType) && + creation.Constructor is + { DeclaringSyntaxReferences.Length: 0 }; + } + private EffectSummary ScanArrayCreation(IArrayCreationOperation array) { var dimensions = ScanSequence(array.DimensionSizes); @@ -749,138 +865,77 @@ private EffectSummary ScanConditionalAccess( whenNotNullStep.Summary); } - private EffectSummary ScanLock(ILockOperation @lock) + private EffectSummary ScanSwitchExpression( + ISwitchExpressionOperation switchExpression) { - var receiver = ScanStep(@lock.LockedValue); - if (!receiver.CompletesNormally) + var value = ScanStep(switchExpression.Value); + if (!value.CompletesNormally) { - return receiver.Summary; + return value.Summary; } - var entry = new EffectStep( - EffectSummaryOperations.Join( - PotentialNullLock(@lock.LockedValue, @lock), - EffectSummaryOperations.Capability( - EffectCapabilityKind.Synchronization)), - !_nullnessEvaluator.IsProvenNull(@lock.LockedValue, @lock)); - var result = receiver.Then(entry); - if (@lock.Body != null && result.CompletesNormally) + var arms = EffectSummary.Empty; + foreach (var arm in SwitchExpressionFacts.GetReachableArms( + switchExpression, + _completionEvaluator.CanCompleteNormally, + _nullnessEvaluator.IsProvenNonNull( + switchExpression.Value, + switchExpression))) { - result = result.Then(ScanStep(@lock.Body)); + arms = EffectSummaryDomain.Instance.Join(arms, Scan(arm)); } - return result.Summary; - } - private EffectSummary ScanBinary(IBinaryOperation binary) - { - return EffectSummaryOperations.Join( - Scan(binary.LeftOperand), - Scan(binary.RightOperand), - StringConcatenationEffectResolver.Resolve( - binary, - _session.Compilation, - _callResolver, - _abstractFlow, - _conversionOwnership.ClassifyRegion), - IntegralDivisionExceptions(binary.OperatorKind, binary.Type, - binary.LeftOperand, binary.RightOperand, binary), - _conversionEffects.CheckedOverflow(binary.IsChecked, binary), - ResolveOperatorEffects( - binary.OperatorMethod, - [binary.LeftOperand, binary.RightOperand], - binary)); + var unmatched = SwitchExpressionFacts.HasReachableUnmatchedPath( + switchExpression, + _completionEvaluator.CanCompleteNormally, + _nullnessEvaluator.IsProvenNonNull( + switchExpression.Value, + switchExpression)) + ? Throw(FrameworkTypeMetadataNames.SwitchExpressionException) + : EffectSummary.Empty; + return EffectSummaryOperations.Join(value.Summary, arms, unmatched); } - private EffectSummary ScanInterpolatedString( - IInterpolatedStringOperation interpolation) + private EffectSummary ScanListPattern(IListPatternOperation pattern) { - if (interpolation.ConstantValue.HasValue) - { - return EffectSummary.Empty; - } - - var summary = EffectSummaryOperations.Allocate( - EffectAllocationKind.Managed); - foreach (var part in interpolation.Parts) + var summary = ScanMany(pattern.Patterns); + var instance = SwitchExpressionFacts.GetGoverningValue(pattern); + var receiver = _conversionOwnership.ClassifyRegion( + instance, + aliasSource: true); + foreach (var method in _completionEvaluator + .GetReachableImplicitListPatternMembers(pattern)) { - if (part is not IInterpolationOperation value) + if (method.DeclaringSyntaxReferences.Length == 0) { continue; } - if (value.Alignment != null || value.FormatString != null) - { - summary = EffectSummaryOperations.Join( - summary, - ScanChildren(value), - EffectSummaryOperations.Unsupported()); - continue; - } - - summary = EffectSummaryOperations.Join( - summary, - Scan(value.Expression), - StringConcatenationEffectResolver.ResolveFormattedValue( - value.Expression, - value, - _session.Compilation, - _callResolver, - _abstractFlow, - _conversionOwnership.ClassifyRegion)); + var argumentRegions = Enumerable.Repeat( + EffectRegionSet.Empty, + method.Parameters.Length) + .ToImmutableArray(); + var actualArguments = Enumerable.Repeat( + null, + method.Parameters.Length) + .ToImmutableArray(); + var call = _callResolver.Resolve( + method, + receiver, + receiver, + argumentRegions, + actualArguments, + method.IsVirtual || method.IsAbstract, + pattern, + instance, + ImmutableArray.Empty); + summary = EffectSummaryDomain.Instance.Join(summary, call); } return summary; } - private EffectSummary ScanUnary(IUnaryOperation unary) - { - return EffectSummaryOperations.Join( - Scan(unary.Operand), - _conversionEffects.CheckedOverflow(unary.IsChecked, unary), - ResolveOperatorEffects(unary.OperatorMethod, [unary.Operand], unary)); - } - - private EffectSummary ScanConversion(IConversionOperation operation) - { - if (!string.Equals(operation.Syntax.Language, LanguageNames.CSharp, StringComparison.Ordinal)) - { - return EffectSummaryOperations.Join( - Scan(operation.Operand), - EffectSummaryOperations.Unsupported()); - } - - var conversion = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetConversion(operation); - return EffectSummaryOperations.Join( - Scan(operation.Operand), - _conversionEffects.Classify(operation, conversion), - ResolveOperatorEffects(operation.OperatorMethod, [operation.Operand], operation)); - } - - private EffectSummary ResolveOperatorEffects( - IMethodSymbol? method, - ImmutableArray operands, - IOperation origin) - { - return _callResolver.ResolveOperator( - method, - EffectRegionSet.Empty, - [.. operands.Select(operand => _conversionOwnership.ClassifyRegion(operand))], - operands, - origin); - } - - private EffectSummary Throw(params string[] exceptionMetadataNames) - { - return EffectSummaryOperations.Throw(_session.ResolveExceptionSet(exceptionMetadataNames)); - } - - private EffectSummary ScanDefault(IOperation operation) + private EffectSummary ScanDefaultPattern(IOperation pattern) { - var classification = OperationSubsetClassifier.Classify( - OperationSupportStage.EffectDiscovery, - operation.Kind); - var children = ScanChildren(operation); - return classification.IsExact - ? children - : EffectSummaryOperations.Join(children, EffectSummaryOperations.Unsupported()); + return ScanChildren(pattern); } private EffectSummary ScanChildren(IOperation operation) @@ -907,6 +962,11 @@ internal EffectStep ScanSequence(IEnumerable operations) return result; } + internal bool CanCompleteNormally(IOperation operation) + { + return _completionEvaluator.CanCompleteNormally(operation); + } + private EffectStep ScanStep(IOperation operation) { return new(Scan(operation), _completionEvaluator.CanCompleteNormally(operation)); @@ -1123,7 +1183,12 @@ private bool IsFrameworkException(INamedTypeSymbol type) private bool HasNonThrowingConstructorSpec(IObjectCreationOperation creation) { return creation.Constructor != null && - _session.ApiSpecs.TryGet(creation.Constructor, out var spec) && + HasNonThrowingMethodSpec(creation.Constructor); + } + + private bool HasNonThrowingMethodSpec(IMethodSymbol method) + { + return _session.ApiSpecs.TryGet(method, out var spec) && spec.Template.Facets.Throws.Behavior == SpecThrowBehavior.DoesNotThrow && spec.Template.Facets.Termination?.Behavior == @@ -1215,6 +1280,12 @@ private static bool IsSourceThrow(IThrowOperation operation) return operation.Syntax is ThrowStatementSyntax or ThrowExpressionSyntax; } + private bool CanReachThrow(IThrowOperation thrown) + { + return thrown.Exception == null || + _completionEvaluator.CanCompleteNormally(thrown.Exception); + } + private ImmutableArray ClassifyArguments( IEnumerable arguments, int parameterCount) { diff --git a/SharpProof.Effects/StringConcatenationEffectResolver.cs b/SharpProof.Effects/StringConcatenationEffectResolver.cs index 0a9836103..a39434034 100644 --- a/SharpProof.Effects/StringConcatenationEffectResolver.cs +++ b/SharpProof.Effects/StringConcatenationEffectResolver.cs @@ -55,30 +55,80 @@ internal static EffectSummary ResolveFormattedValue( ManagedFlowResult? flow, Func classifyRegion) { - operand = UnwrapImplicitConversion(operand); - if (operand.Type?.SpecialType == SpecialType.System_String || - operand.ConstantValue is { HasValue: true, Value: null } || - flow?.TryEvaluate(origin, operand, out var value) == true && - value.IsDefinitelyNull) + var formatted = ResolveFormattedValueCall( + operand, + origin, + compilation, + flow); + if (!formatted.IsRequired) { return EffectSummary.Empty; } - - var receiverType = UnwrapNullable(operand.Type); - var target = ResolveToString(receiverType, compilation); - if (target == null) + if (formatted.Target == null) { return EffectSummaryOperations.Unsupported(); } return calls.Resolve( - target, - classifyRegion(operand, false), + formatted.Target, + classifyRegion(formatted.Operand, false), ImmutableArray.Empty, ImmutableArray.Empty, - IsDispatchUncertain(target, receiverType), + IsDispatchUncertain( + formatted.Target, + formatted.ReceiverType), + origin, + formatted.Operand); + } + + internal static bool CanFormattedValueCompleteNormally( + IOperation operand, + IOperation origin, + Compilation compilation, + ManagedFlowResult? flow, + OperationCompletionEvaluator completionEvaluator) + { + var formatted = ResolveFormattedValueCall( + operand, origin, - operand); + compilation, + flow); + return !formatted.IsRequired || + formatted.Target == null || + IsDispatchUncertain( + formatted.Target, + formatted.ReceiverType) || + completionEvaluator.CanCompleteInvocation( + formatted.Target, + formatted.Operand, + origin); + } + + private static FormattedValueCall ResolveFormattedValueCall( + IOperation operand, + IOperation origin, + Compilation compilation, + ManagedFlowResult? flow) + { + operand = UnwrapImplicitConversion(operand); + if (operand.Type?.SpecialType == SpecialType.System_String || + operand.ConstantValue is { HasValue: true, Value: null } || + flow?.TryEvaluate(origin, operand, out var value) == true && + value.IsDefinitelyNull) + { + return new( + operand, + Target: null, + ReceiverType: null, + IsRequired: false); + } + + var receiverType = UnwrapNullable(operand.Type); + return new( + operand, + ResolveToString(receiverType, compilation), + receiverType, + IsRequired: true); } private static IOperation UnwrapImplicitConversion(IOperation operation) @@ -165,4 +215,10 @@ private static bool IsDispatchUncertain( method.ContainingType?.TypeKind == TypeKind.Interface) && !method.IsSealed; } + + private readonly record struct FormattedValueCall( + IOperation Operand, + IMethodSymbol? Target, + ITypeSymbol? ReceiverType, + bool IsRequired); } diff --git a/SharpProof.Effects/SwitchExpressionFacts.cs b/SharpProof.Effects/SwitchExpressionFacts.cs new file mode 100644 index 000000000..9f59c4b32 --- /dev/null +++ b/SharpProof.Effects/SwitchExpressionFacts.cs @@ -0,0 +1,506 @@ +namespace SharpProof.Effects; + +internal enum SwitchExpressionSelection +{ + Never, + Maybe, + Always +} + +internal static class SwitchExpressionFacts +{ + internal static IOperation? GetGoverningValue(IPatternOperation pattern) + { + var current = (IOperation)pattern; + while (current.Parent is INegatedPatternOperation or + IBinaryPatternOperation) + { + current = current.Parent; + } + return current.Parent switch + { + IIsPatternOperation isPattern + when ReferenceEquals(isPattern.Pattern, current) => + isPattern.Value, + ISwitchExpressionArmOperation + { Parent: ISwitchExpressionOperation expression } => + expression.Value, + IPatternCaseClauseOperation + { + Parent: ISwitchCaseOperation + { Parent: ISwitchOperation statement } + } => statement.Value, + _ => null + }; + } + + internal static IMethodSymbol? GetCallableListPatternMember(ISymbol? symbol) + { + return symbol switch + { + IPropertySymbol property => property.GetMethod, + IMethodSymbol method => method, + _ => null + }; + } + + internal static IReadOnlyList GetReachableArms( + ISwitchExpressionOperation operation, + Func canCompleteNormally, + bool inputDefinitelyNonNull = false) + { + if (!canCompleteNormally(operation.Value)) + { + return []; + } + + inputDefinitelyNonNull |= + DefiniteOperationFacts.IsDefinitelyNonNull(operation.Value); + + if (operation.Value.ConstantValue is not { HasValue: true } constant) + { + return GetReachableArmsForUnknownValue( + operation, + canCompleteNormally, + inputDefinitelyNonNull); + } + + var reachable = new List(); + foreach (var arm in operation.Arms) + { + var pattern = GetPatternSelection(arm.Pattern, constant.Value); + var selection = GetArmSelection(arm, constant.Value); + if (selection != SwitchExpressionSelection.Never) + { + reachable.Add(arm); + } + if (selection == SwitchExpressionSelection.Always || + IsPatternEvaluationUnavoidable( + arm.Pattern, + operation.Value.Type, + inputDefinitelyNonNull) && + !canCompleteNormally(arm.Pattern) || + pattern == SwitchExpressionSelection.Always && + arm.Guard != null && + !canCompleteNormally(arm.Guard)) + { + break; + } + } + return reachable; + } + + internal static bool HasReachableUnmatchedPath( + ISwitchExpressionOperation operation, + Func canCompleteNormally, + bool inputDefinitelyNonNull = false) + { + if (!canCompleteNormally(operation.Value)) + { + return false; + } + if (operation.IsExhaustive) + { + return false; + } + inputDefinitelyNonNull |= + DefiniteOperationFacts.IsDefinitelyNonNull(operation.Value); + if (operation.Value.ConstantValue is not { HasValue: true } constant) + { + foreach (var arm in operation.Arms) + { + var pattern = GetPatternSelectionForUnknownValue( + arm.Pattern, + operation.Value.Type, + inputDefinitelyNonNull); + var selection = ApplyGuard(pattern, arm.Guard); + if (selection == SwitchExpressionSelection.Always) + { + return false; + } + if (IsPatternEvaluationUnavoidable( + arm.Pattern, + operation.Value.Type, + inputDefinitelyNonNull) && + !canCompleteNormally(arm.Pattern)) + { + return false; + } + if (pattern == SwitchExpressionSelection.Always && + arm.Guard != null && + !canCompleteNormally(arm.Guard)) + { + return false; + } + } + return true; + } + + foreach (var arm in operation.Arms) + { + var pattern = GetPatternSelection(arm.Pattern, constant.Value); + var selection = GetArmSelection(arm, constant.Value); + if (selection == SwitchExpressionSelection.Always) + { + return false; + } + if (IsPatternEvaluationUnavoidable( + arm.Pattern, + operation.Value.Type, + inputDefinitelyNonNull) && + !canCompleteNormally(arm.Pattern)) + { + return false; + } + if (pattern == SwitchExpressionSelection.Always && + arm.Guard != null && + !canCompleteNormally(arm.Guard)) + { + return false; + } + } + return true; + } + + internal static SwitchExpressionSelection GetArmSelection( + ISwitchExpressionArmOperation arm, + object? value) + { + var pattern = GetPatternSelection(arm.Pattern, value); + return ApplyGuard(pattern, arm.Guard); + } + + private static List + GetReachableArmsForUnknownValue( + ISwitchExpressionOperation operation, + Func canCompleteNormally, + bool inputDefinitelyNonNull) + { + var reachable = new List(); + foreach (var arm in operation.Arms) + { + var pattern = GetPatternSelectionForUnknownValue( + arm.Pattern, + operation.Value.Type, + inputDefinitelyNonNull); + var selection = ApplyGuard(pattern, arm.Guard); + if (selection != SwitchExpressionSelection.Never) + { + reachable.Add(arm); + } + if (selection == SwitchExpressionSelection.Always || + IsPatternEvaluationUnavoidable( + arm.Pattern, + operation.Value.Type, + inputDefinitelyNonNull) && + !canCompleteNormally(arm.Pattern) || + pattern == SwitchExpressionSelection.Always && + arm.Guard != null && + !canCompleteNormally(arm.Guard)) + { + break; + } + } + return reachable; + } + + internal static SwitchExpressionSelection GetPatternSelectionForUnknownValue( + IPatternOperation pattern, + ITypeSymbol? inputType, + bool inputDefinitelyNonNull = false) + { + return pattern switch + { + IConstantPatternOperation + { Value.ConstantValue: { HasValue: true, Value: null } } + when inputDefinitelyNonNull => SwitchExpressionSelection.Never, + INegatedPatternOperation negated => Negate( + GetPatternSelectionForUnknownValue( + negated.Pattern, + inputType, + inputDefinitelyNonNull)), + IBinaryPatternOperation binary + when binary.OperatorKind == BinaryOperatorKind.And => And( + GetPatternSelectionForUnknownValue( + binary.LeftPattern, + inputType, + inputDefinitelyNonNull), + GetPatternSelectionForUnknownValue( + binary.RightPattern, + inputType, + inputDefinitelyNonNull)), + IBinaryPatternOperation binary + when binary.OperatorKind == BinaryOperatorKind.Or => Or( + GetPatternSelectionForUnknownValue( + binary.LeftPattern, + inputType, + inputDefinitelyNonNull), + GetPatternSelectionForUnknownValue( + binary.RightPattern, + inputType, + inputDefinitelyNonNull)), + _ when IsTotalPattern( + pattern, + inputType, + inputDefinitelyNonNull) => SwitchExpressionSelection.Always, + _ => SwitchExpressionSelection.Maybe + }; + } + + internal static bool IsTotalPattern( + IPatternOperation pattern, + ITypeSymbol? inputType, + bool inputDefinitelyNonNull = false) + { + if (pattern is IDiscardPatternOperation or + IDeclarationPatternOperation { MatchesNull: true }) + { + return true; + } + if (pattern is IListPatternOperation listPattern) + { + return (inputType?.IsValueType == true || + inputDefinitelyNonNull) && + listPattern.Patterns.Length == 1 && + listPattern.Patterns[0] is + ISlicePatternOperation slicePattern && + (slicePattern.Pattern == null || + IsTotalPattern( + slicePattern.Pattern, + slicePattern.Pattern.InputType)); + } + var matchedType = pattern switch + { + ITypePatternOperation typePattern => typePattern.MatchedType, + IDeclarationPatternOperation declarationPattern => + declarationPattern.MatchedType, + IRecursivePatternOperation recursive => recursive.MatchedType, + _ => null + }; + if (inputType?.IsValueType != true && !inputDefinitelyNonNull || + !SymbolEqualityComparer.Default.Equals(matchedType, inputType)) + { + return false; + } + return pattern is not IRecursivePatternOperation recursivePattern || + recursivePattern.DeconstructionSubpatterns.All( + subpattern => IsTotalPattern( + subpattern, + subpattern.InputType)) && + recursivePattern.PropertySubpatterns.All( + subpattern => IsTotalPattern( + subpattern.Pattern, + subpattern.Pattern.InputType)); + } + + internal static bool IsPatternEvaluationUnavoidable( + IPatternOperation pattern, + ITypeSymbol? inputType, + bool inputDefinitelyNonNull = false) + { + if (pattern is IDiscardPatternOperation or + IDeclarationPatternOperation { MatchesNull: true }) + { + return true; + } + if (pattern is IListPatternOperation) + { + return inputType?.IsValueType == true || inputDefinitelyNonNull; + } + if (pattern is INegatedPatternOperation negated) + { + return IsPatternEvaluationUnavoidable( + negated.Pattern, + inputType, + inputDefinitelyNonNull); + } + if (pattern is IBinaryPatternOperation binary) + { + var leftIsUnavoidable = IsPatternEvaluationUnavoidable( + binary.LeftPattern, + inputType, + inputDefinitelyNonNull); + var leftSelection = GetPatternSelectionForUnknownValue( + binary.LeftPattern, + inputType, + inputDefinitelyNonNull); + return leftIsUnavoidable || + binary.OperatorKind == BinaryOperatorKind.And && + leftSelection == SwitchExpressionSelection.Always && + IsPatternEvaluationUnavoidable( + binary.RightPattern, + inputType, + inputDefinitelyNonNull) || + binary.OperatorKind == BinaryOperatorKind.Or && + leftSelection == SwitchExpressionSelection.Never && + IsPatternEvaluationUnavoidable( + binary.RightPattern, + inputType, + inputDefinitelyNonNull); + } + var matchedType = pattern switch + { + ITypePatternOperation typePattern => typePattern.MatchedType, + IDeclarationPatternOperation declarationPattern => + declarationPattern.MatchedType, + IRecursivePatternOperation recursive => recursive.MatchedType, + _ => null + }; + return (inputType?.IsValueType == true || inputDefinitelyNonNull) && + SymbolEqualityComparer.Default.Equals(matchedType, inputType); + } + + private static SwitchExpressionSelection ApplyGuard( + SwitchExpressionSelection pattern, + IOperation? guard) + { + if (pattern == SwitchExpressionSelection.Never || guard == null) + { + return pattern; + } + return guard.ConstantValue is { HasValue: true, Value: bool value } + ? value ? pattern : SwitchExpressionSelection.Never + : SwitchExpressionSelection.Maybe; + } + + internal static SwitchExpressionSelection GetPatternSelection( + IPatternOperation pattern, + object? value) + { + return pattern switch + { + IDiscardPatternOperation => SwitchExpressionSelection.Always, + IConstantPatternOperation constant + when constant.Value.ConstantValue is { HasValue: true } item => + Equals(value, item.Value) + ? SwitchExpressionSelection.Always + : SwitchExpressionSelection.Never, + IRelationalPatternOperation relational + when relational.Value.ConstantValue is { HasValue: true } item && + TryMatchRelationalConstants( + value, + item.Value, + relational.OperatorKind, + out var matches) => + matches + ? SwitchExpressionSelection.Always + : SwitchExpressionSelection.Never, + INegatedPatternOperation negated => + Negate(GetPatternSelection(negated.Pattern, value)), + IBinaryPatternOperation binary + when binary.OperatorKind == BinaryOperatorKind.And => + And( + GetPatternSelection(binary.LeftPattern, value), + GetPatternSelection(binary.RightPattern, value)), + IBinaryPatternOperation binary + when binary.OperatorKind == BinaryOperatorKind.Or => + Or( + GetPatternSelection(binary.LeftPattern, value), + GetPatternSelection(binary.RightPattern, value)), + _ when IsTotalPattern(pattern, pattern.InputType) => + SwitchExpressionSelection.Always, + _ => SwitchExpressionSelection.Maybe + }; + } + + private static SwitchExpressionSelection Negate( + SwitchExpressionSelection selection) + { + return selection switch + { + SwitchExpressionSelection.Never => SwitchExpressionSelection.Always, + SwitchExpressionSelection.Always => SwitchExpressionSelection.Never, + _ => SwitchExpressionSelection.Maybe + }; + } + + private static SwitchExpressionSelection And( + SwitchExpressionSelection left, + SwitchExpressionSelection right) + { + if (left == SwitchExpressionSelection.Never || + right == SwitchExpressionSelection.Never) + { + return SwitchExpressionSelection.Never; + } + return left == SwitchExpressionSelection.Always && + right == SwitchExpressionSelection.Always + ? SwitchExpressionSelection.Always + : SwitchExpressionSelection.Maybe; + } + + private static SwitchExpressionSelection Or( + SwitchExpressionSelection left, + SwitchExpressionSelection right) + { + if (left == SwitchExpressionSelection.Always || + right == SwitchExpressionSelection.Always) + { + return SwitchExpressionSelection.Always; + } + return left == SwitchExpressionSelection.Never && + right == SwitchExpressionSelection.Never + ? SwitchExpressionSelection.Never + : SwitchExpressionSelection.Maybe; + } + + private static bool TryMatchRelationalConstants( + object? left, + object? right, + BinaryOperatorKind operatorKind, + out bool matches) + { + matches = false; + if (left == null || right == null || left.GetType() != right.GetType() || + left is not IComparable comparable) + { + return false; + } + if (left is float leftSingle && right is float rightSingle) + { + matches = MatchesFloating( + operatorKind, + leftSingle, + rightSingle); + return true; + } + if (left is double leftDouble && right is double rightDouble) + { + matches = MatchesFloating( + operatorKind, + leftDouble, + rightDouble); + return true; + } + + matches = Matches(operatorKind, comparable.CompareTo(right)); + return true; + } + + private static bool MatchesFloating( + BinaryOperatorKind operatorKind, + double left, + double right) + { + return operatorKind switch + { + BinaryOperatorKind.LessThan => left < right, + BinaryOperatorKind.LessThanOrEqual => left <= right, + BinaryOperatorKind.GreaterThan => left > right, + BinaryOperatorKind.GreaterThanOrEqual => left >= right, + _ => false + }; + } + + private static bool Matches(BinaryOperatorKind operatorKind, int comparison) + { + return operatorKind switch + { + BinaryOperatorKind.LessThan => comparison < 0, + BinaryOperatorKind.LessThanOrEqual => comparison <= 0, + BinaryOperatorKind.GreaterThan => comparison > 0, + BinaryOperatorKind.GreaterThanOrEqual => comparison >= 0, + _ => false + }; + } +} diff --git a/SharpProof.Effects/UsingDisposalEffectResolver.cs b/SharpProof.Effects/UsingDisposalEffectResolver.cs index cc30427d6..dc57ea88b 100644 --- a/SharpProof.Effects/UsingDisposalEffectResolver.cs +++ b/SharpProof.Effects/UsingDisposalEffectResolver.cs @@ -13,8 +13,6 @@ internal sealed class UsingDisposalEffectResolver private readonly IMethodSymbol _caller; private readonly EffectCallSiteResolver _calls; private readonly Compilation _compilation; - private readonly IMethodSymbol? _dispose; - private readonly INamedTypeSymbol? _disposable; private readonly ManagedFlowResult? _flow; internal UsingDisposalEffectResolver( @@ -29,20 +27,15 @@ internal UsingDisposalEffectResolver( _caller = ArgumentNullGuard.NotNull(caller, nameof(caller)); _calls = ArgumentNullGuard.NotNull(calls, nameof(calls)); _flow = flow; - _disposable = compilation.GetTypeByMetadataName( - FrameworkTypeMetadataNames.IDisposable); - _dispose = _disposable?.GetMembers("Dispose") - .OfType() - .SingleOrDefault(static method => - !method.IsStatic && - method.Arity == 0 && - method.Parameters.IsEmpty && - method.ReturnsVoid); } internal EffectSummary Scan( IOperation root, - Func classifyRegion) + Func classifyRegion, + Func canCompleteNormally, + Func canMethodCompleteNormally, + Func canMethodThrow, + Func canExitAbruptly) { var summary = EffectSummary.Empty; foreach (var operation in root.DescendantsAndSelf() @@ -61,14 +54,29 @@ operation is IUsingOperation or IUsingOperation { IsAsynchronous: true } or IUsingDeclarationOperation { IsAsynchronous: true } => EffectSummaryOperations.Unsupported(), - IUsingOperation @using => ResolveResources( - @using.Resources, - @using, - classifyRegion), - IUsingDeclarationOperation declaration => ResolveResources( - declaration.DeclarationGroup, - declaration, - classifyRegion), + IUsingOperation @using => + ResolveResources( + @using.Resources, + @using, + classifyRegion, + canCompleteNormally, + canMethodCompleteNormally, + canMethodThrow, + canCompleteNormally(@using.Body) || + canExitAbruptly(@using.Body, @using.Body)), + IUsingDeclarationOperation declaration => + ResolveResources( + declaration.DeclarationGroup, + declaration, + classifyRegion, + canCompleteNormally, + canMethodCompleteNormally, + canMethodThrow, + CanReachDeclarationDisposal( + declaration, + canCompleteNormally, + canMethodCompleteNormally, + canExitAbruptly)), _ => EffectSummary.Empty }; summary = EffectSummaryDomain.Instance.Join(summary, disposal); @@ -77,6 +85,129 @@ operation is IUsingOperation or return summary; } + private bool CanReachDeclarationDisposal( + IUsingDeclarationOperation declaration, + Func canCompleteNormally, + Func canMethodCompleteNormally, + Func canExitAbruptly) + { + if (declaration.Parent is not IBlockOperation block) + { + return true; + } + var index = block.Operations.IndexOf(declaration); + if (index < 0) + { + return true; + } + var pending = new Queue(); + var visited = new HashSet(); + pending.Enqueue(index + 1); + while (pending.Count != 0) + { + var operationIndex = pending.Dequeue(); + if (operationIndex >= block.Operations.Length) + { + return true; + } + if (!visited.Add(operationIndex)) + { + continue; + } + var operation = block.Operations[operationIndex]; + var internalBranches = GetInternalGotoTargets( + operation, + block, + index + 1); + if (internalBranches.LeavesActiveLifetime) + { + return true; + } + foreach (var target in internalBranches.Targets) + { + pending.Enqueue(target); + } + if (canExitAbruptly(operation, block)) + { + return true; + } + if (operation is IUsingDeclarationOperation laterUsing && + !CanDisposalsCompleteNormally( + laterUsing, + canMethodCompleteNormally)) + { + continue; + } + if ((canCompleteNormally(operation) || + operation is ILabeledOperation labeled && + labeled.ChildOperations.All(canCompleteNormally)) && + !internalBranches.HasUnconditionalGoto) + { + pending.Enqueue(operationIndex + 1); + } + } + return false; + } + + + private static InternalGotoTargets GetInternalGotoTargets( + IOperation operation, + IBlockOperation scope, + int firstActiveOperation) + { + var branches = operation.DescendantsAndSelf() + .OfType() + .Where(branch => + branch.Syntax is GotoStatementSyntax) + .ToArray(); + var allTargets = branches + .SelectMany(static branch => + branch.Target.DeclaringSyntaxReferences) + .Select(static reference => reference.GetSyntax()) + .Where(target => + target.SyntaxTree == scope.Syntax.SyntaxTree && + scope.Syntax.Span.Contains(target.Span)) + .Select(target => scope.Operations.IndexOf( + scope.Operations.FirstOrDefault(candidate => + candidate.Syntax.Span.Contains(target.Span) || + candidate.Syntax.Span.IntersectsWith(target.Span) || + target.Span.Contains(candidate.Syntax.Span)) ?? + scope.Operations.First(candidate => + candidate.Syntax.Span.Start >= target.Span.Start))) + .Distinct() + .ToArray(); + return new InternalGotoTargets( + allTargets.Where(target => + target >= firstActiveOperation).ToArray(), + branches.Any(branch => + IsUnconditionalAtOperationLevel(branch, operation)), + allTargets.Any(target => target < firstActiveOperation)); + } + + private static bool IsUnconditionalAtOperationLevel( + IBranchOperation branch, + IOperation operation) + { + if (ReferenceEquals(branch, operation)) + { + return true; + } + for (var parent = branch.Parent; + parent != null; + parent = parent.Parent) + { + if (ReferenceEquals(parent, operation)) + { + return true; + } + if (parent is not ILabeledOperation) + { + return false; + } + } + return false; + } + internal static bool IsSynthesizedSynchronousDispose( IInvocationOperation invocation) { @@ -100,38 +231,172 @@ syntax is LocalDeclarationStatementSyntax private EffectSummary ResolveResources( IOperation resources, IOperation origin, - Func classifyRegion) + Func classifyRegion, + Func canCompleteNormally, + Func canMethodCompleteNormally, + Func canMethodThrow, + bool scopeExitReachable) { if (resources is not IVariableDeclarationGroupOperation group) { + if (!canCompleteNormally(resources)) + { + return EffectSummary.Empty; + } + if (!scopeExitReachable) + { + return EffectSummary.Empty; + } return ResolveResource( resources.Type, resources, origin, - classifyRegion); + classifyRegion, + canMethodCompleteNormally, + canMethodThrow); } - var summary = EffectSummary.Empty; + var acquired = new List<( + ITypeSymbol Type, + IOperation Resource, + IOperation Origin)>(); + var acquisitionFailed = false; foreach (var declarator in group.Declarations .SelectMany(static declaration => declaration.Declarators)) { - summary = EffectSummaryDomain.Instance.Join( - summary, - ResolveResource( + var resource = declarator.Initializer?.Value; + if (!canCompleteNormally(resource)) + { + acquisitionFailed = true; + break; + } + if (resource != null) + { + acquired.Add(( declarator.Symbol.Type, - declarator.Initializer?.Value, - declarator, - classifyRegion)); + resource, + declarator)); + } + } + if (!scopeExitReachable && !acquisitionFailed) + { + return EffectSummary.Empty; + } + var summary = EffectSummary.Empty; + foreach (var item in acquired.AsEnumerable().Reverse()) + { + var disposal = ResolveResource( + item.Type, + item.Resource, + item.Origin, + classifyRegion, + canMethodCompleteNormally, + canMethodThrow); + summary = EffectSummaryDomain.Instance.Join(summary, disposal); + if (!CanDisposalUnwind( + item.Type, + item.Resource, + item.Origin, + canMethodCompleteNormally, + canMethodThrow)) + { + break; + } } - return summary; } + private bool CanDisposalsCompleteNormally( + IUsingDeclarationOperation declaration, + Func canMethodCompleteNormally) + { + return declaration.DeclarationGroup.Declarations + .SelectMany(static item => item.Declarators) + .Reverse() + .All(declarator => CanDisposalCompleteNormally( + declarator.Symbol.Type, + declarator.Initializer?.Value, + declarator, + canMethodCompleteNormally)); + } + + private bool CanDisposalCompleteNormally( + ITypeSymbol? resourceType, + IOperation? resource, + IOperation origin, + Func canMethodCompleteNormally) + { + if (resourceType == null || resource == null || + IsDefinitelyNull(resource, origin)) + { + return true; + } + var dispose = ResolveDispose( + _compilation, + _caller, + GetConcreteResourceType(resourceType, resource)); + return dispose == null || IsDispatchUncertain(dispose) || + canMethodCompleteNormally(dispose); + } + + private bool CanDisposalUnwind( + ITypeSymbol? resourceType, + IOperation resource, + IOperation origin, + Func canMethodCompleteNormally, + Func canMethodThrow) + { + if (IsDefinitelyNull(resource, origin)) + { + return true; + } + var dispose = resourceType == null + ? null + : ResolveDispose( + _compilation, + _caller, + GetConcreteResourceType(resourceType, resource)); + var complete = dispose != null && canMethodCompleteNormally(dispose); + var throws = dispose != null && canMethodThrow(dispose); + return dispose == null || IsDispatchUncertain(dispose) || complete || throws; + } + + private bool IsDefinitelyNull(IOperation resource, IOperation origin) + { + return resource.ConstantValue is { HasValue: true, Value: null } || + _flow?.TryEvaluate(origin, resource, out var value) == true && + value.IsDefinitelyNull; + } + + private static ITypeSymbol GetConcreteResourceType( + ITypeSymbol declaredType, + IOperation resource) + { + resource = DefiniteOperationFacts.UnwrapHarmlessValue(resource); + return declaredType is INamedTypeSymbol + { + TypeKind: TypeKind.Interface + } && + resource.Type is INamedTypeSymbol + { + TypeKind: not TypeKind.Interface + } concrete + ? concrete + : declaredType; + } + + private sealed record InternalGotoTargets( + IReadOnlyList Targets, + bool HasUnconditionalGoto, + bool LeavesActiveLifetime); + private EffectSummary ResolveResource( ITypeSymbol? resourceType, IOperation? resource, IOperation origin, - Func classifyRegion) + Func classifyRegion, + Func canMethodCompleteNormally, + Func canMethodThrow) { if (resourceType == null || resource == null) { @@ -145,15 +410,28 @@ private EffectSummary ResolveResource( return EffectSummary.Empty; } - var dispose = ResolveDispose(resourceType); + var dispose = ResolveDispose( + _compilation, + _caller, + GetConcreteResourceType(resourceType, resource)); if (dispose == null) { return EffectSummaryOperations.Unsupported(); } + if (!IsDispatchUncertain(dispose) && + !canMethodCompleteNormally(dispose) && + !canMethodThrow(dispose)) + { + return EffectSummary.Empty; + } + var receiver = dispose.ContainingType?.IsValueType == true && + !dispose.ContainingType.IsRefLikeType + ? EffectRegionSet.Empty + : classifyRegion(resource, true); return _calls.Resolve( dispose, - classifyRegion(resource, true), + receiver, ImmutableArray.Empty, ImmutableArray.Empty, IsDispatchUncertain(dispose), @@ -161,7 +439,10 @@ private EffectSummary ResolveResource( resource); } - private IMethodSymbol? ResolveDispose(ITypeSymbol resourceType) + internal static IMethodSymbol? ResolveDispose( + Compilation compilation, + IMethodSymbol caller, + ITypeSymbol resourceType) { if (resourceType is INamedTypeSymbol { @@ -178,18 +459,27 @@ private EffectSummary ResolveResource( return null; } - if (_disposable != null && _dispose != null && + var disposable = compilation.GetTypeByMetadataName( + FrameworkTypeMetadataNames.IDisposable); + var dispose = disposable?.GetMembers("Dispose") + .OfType() + .SingleOrDefault(static method => + !method.IsStatic && + method.Arity == 0 && + method.Parameters.IsEmpty && + method.ReturnsVoid); + if (disposable != null && dispose != null && (SymbolEqualityComparer.Default.Equals( named.OriginalDefinition, - _disposable) || + disposable) || named.AllInterfaces.Any(@interface => SymbolEqualityComparer.Default.Equals( @interface.OriginalDefinition, - _disposable)))) + disposable)))) { return named.TypeKind == TypeKind.Interface - ? _dispose - : named.FindImplementationForInterfaceMember(_dispose) as + ? dispose + : named.FindImplementationForInterfaceMember(dispose) as IMethodSymbol; } @@ -202,13 +492,13 @@ private EffectSummary ResolveResource( method.Arity == 0 && method.Parameters.IsEmpty && method.ReturnsVoid && - _compilation.IsSymbolAccessibleWithin( + compilation.IsSymbolAccessibleWithin( method, - _caller.ContainingType)) + caller.ContainingType)) : null; } - private static bool IsDispatchUncertain(IMethodSymbol method) + internal static bool IsDispatchUncertain(IMethodSymbol method) { return !method.IsStatic && (method.IsVirtual || diff --git a/SharpProof.Frontend.Test/FrontendLoweringTests.cs b/SharpProof.Frontend.Test/FrontendLoweringTests.cs index 2e58d88cd..72f97fdae 100644 --- a/SharpProof.Frontend.Test/FrontendLoweringTests.cs +++ b/SharpProof.Frontend.Test/FrontendLoweringTests.cs @@ -268,6 +268,13 @@ public void UnsupportedIntegralDomainsCannotMasqueradeAsReferenceEquality() [Test] public void UnsupportedValueDomainsCannotMasqueradeAsReferenceEquality() { + AssertClassification( + """ + public static bool Target(T left, T right) + where T : class => left == right; + """, + FrontendSubsetDecision.ClosedAbstention, + FrontendAbstention.UnsupportedType); AssertClassification( """ public static bool Target(double left, double right) => left == right; @@ -333,6 +340,25 @@ public void BoxingConversionsOfConstantsCannotLowerToNull() } } + [Test] + public void AbstractAndInterfaceReferenceEqualityLowersExactly() + { + AssertClassification( + """ + public abstract class Base {} + public static bool Target(Base left, Base right) => left == right; + """, + FrontendSubsetDecision.Exact, + FrontendAbstention.None); + AssertClassification( + """ + public interface IItem {} + public static bool Target(IItem left, IItem right) => left == right; + """, + FrontendSubsetDecision.Exact, + FrontendAbstention.None); + } + [Test] public void DefaultAndUnknownSubsetDecisionsCannotBecomeExact() { @@ -950,7 +976,10 @@ private static void AssertClassification( { using var compiled = CompiledMethod.Create(members); var result = compiled.Lower(); - Assert.That(result.Classification.Decision, Is.EqualTo(decision)); + Assert.That( + result.Classification.Decision, + Is.EqualTo(decision), + result.Classification.Abstention.ToString()); Assert.That(result.Classification.Abstention, Is.EqualTo(abstention)); } diff --git a/SharpProof.Frontend.Test/ProgramLoweringTests.cs b/SharpProof.Frontend.Test/ProgramLoweringTests.cs index 19ee886c2..43a6a1407 100644 --- a/SharpProof.Frontend.Test/ProgramLoweringTests.cs +++ b/SharpProof.Frontend.Test/ProgramLoweringTests.cs @@ -93,7 +93,6 @@ public static long Target(Box box, long value) { var instructions = lowered.Result.Program.Blocks .SelectMany(static block => block.Instructions) .ToArray(); - Assert.That( instructions.OfType(), Has.Exactly(1).Items); @@ -195,6 +194,14 @@ public static long Target(long value) { .SelectMany(static block => block.Instructions) .OfType(), Is.Not.Empty); + var parameter = result.Variables.Single(static binding => + binding.Symbol is IParameterSymbol { Name: "value" }).Variable; + Assert.That( + result.Program.Blocks + .SelectMany(static block => block.Instructions) + .OfType() + .SelectMany(static havoc => havoc.Variables), + Does.Contain(parameter)); } [Test] @@ -271,6 +278,119 @@ public static long Target(long value) => Is.EqualTo(7L)); } + [Test] + public void AssignmentLocationsAreEvaluatedBeforeValues() + { + var lowered = Lower( + """ + private static long Probe(long marker) => marker; + public static long Target(long[] values) { + values[Probe(1L)] = Probe(2L); + return values[1]; + } + """); + var calls = lowered.Result.Program.Blocks + .SelectMany(static block => block.Instructions) + .OfType() + .ToArray(); + long[] expectedMarkers = [1L, 2L]; + + Assert.That(calls, Has.Length.EqualTo(2)); + Assert.That( + calls.Select(static call => + ((IrIntegerTerm)call.Arguments[0]).Value), + Is.EqualTo(expectedMarkers)); + } + + [Test] + public void OrdinaryPropertyAccessAbstainsInsteadOfModelingPassiveMemory() + { + var lowered = Lower( + """ + public sealed class Box { + private long _value; + public long Value { + get { _value++; return _value; } + set { _value += value; } + } + } + public static long Target(Box box) { + box.Value = 1L; + return box.Value; + } + """); + var instructions = lowered.Result.Program.Blocks + .SelectMany(static block => block.Instructions) + .ToArray(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(lowered.Result.IsExact, Is.False); + Assert.That( + lowered.Result.Abstentions.Select(static value => value.Reason), + Does.Contain(FrontendAbstention.UnsupportedMemberAccess)); + Assert.That(instructions.OfType(), Is.Empty); + Assert.That(instructions.OfType(), Is.Empty); + } + } + + [Test] + public void RejectedPropertyAssignmentStillEvaluatesTheValue() + { + var lowered = Lower( + """ + public sealed class Box { + public long Value { get; set; } + } + private static long Mutate(ref long value) => ++value; + public static long Target(Box box, long value) { + box.Value = Mutate(ref value); + return value; + } + """); + var instructions = lowered.Result.Program.Blocks + .SelectMany(static block => block.Instructions) + .ToArray(); + var calls = instructions.OfType() + .Select(call => lowered.Factory.GetString( + lowered.Factory.GetMemberInfo(call.Member).Name)) + .ToArray(); + + Assert.That(calls, Has.Length.EqualTo(1)); + Assert.That(calls[0], Does.Contain("Mutate")); + Assert.That( + instructions.OfType() + .Select(static havoc => havoc.HavocKind), + Does.Contain(IrHavocKind.VariablesAndMemory)); + } + + [Test] + public void UnsupportedCompoundAssignmentEvaluatesLocationBeforeValue() + { + var lowered = Lower( + """ + private static long Probe(long marker) => marker; + public static long Target(long[] values) { + values[Probe(1L)] += Probe(2L); + return values[0]; + } + """); + var calls = lowered.Result.Program.Blocks + .SelectMany(static block => block.Instructions) + .OfType() + .ToArray(); + long[] expectedMarkers = [1L, 2L]; + + Assert.That( + calls.Select(static call => + ((IrIntegerTerm)call.Arguments[0]).Value), + Is.EqualTo(expectedMarkers)); + Assert.That(lowered.Result.IsExact, Is.False); + Assert.That( + lowered.Result.Abstentions.Select(static value => value.Reason), + Does.Contain(FrontendAbstention.UnsupportedMutation)); + } + [Test] public void InvocationLoweringOrdersArgumentsByRoslynParameterOrdinal() { @@ -296,6 +416,36 @@ public static long Target(long first, long second) => Is.EqualTo(new[] { first, second })); } + [Test] + public void PointerValuesAbstainInsteadOfBecomingReferences() + { + var lowered = Lower( + """ + public static unsafe bool Target(int* value) => value == null; + """); + + Assert.That(lowered.Result.IsExact, Is.False); + Assert.That( + lowered.Result.Abstentions.Select(static value => value.Reason), + Does.Contain(FrontendAbstention.UnsupportedType)); + } + + [Test] + public void UnsupportedInvocationResultsAbstain() + { + var lowered = Lower( + """ + private struct Token { public long Value; } + private static Token Make() => default; + private static Token Target() => Make(); + """); + + Assert.That(lowered.Result.IsExact, Is.False); + Assert.That( + lowered.Result.Abstentions.Select(static value => value.Reason), + Does.Contain(FrontendAbstention.UnsupportedType)); + } + [Test] public void ProgramLoweringOrderAndIdentifiersAreDeterministic() { @@ -398,7 +548,8 @@ public static class Subject { new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, checkOverflow: false, - nullableContextOptions: NullableContextOptions.Enable)); + nullableContextOptions: NullableContextOptions.Enable, + allowUnsafe: true)); var diagnostics = compilation.GetDiagnostics() .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) diff --git a/SharpProof.Frontend.Test/UnaryAndDefaultLoweringCoverageTests.cs b/SharpProof.Frontend.Test/UnaryAndDefaultLoweringCoverageTests.cs index 2a46f2e4e..b9ad59201 100644 --- a/SharpProof.Frontend.Test/UnaryAndDefaultLoweringCoverageTests.cs +++ b/SharpProof.Frontend.Test/UnaryAndDefaultLoweringCoverageTests.cs @@ -117,6 +117,40 @@ private enum State { None } } } + [Test] + public void SpecializedTypeParameterDefaultsUseTheConstructedDomain() + { + var text = Lower( + "private static T Target() => default(T);", + SpecialType.System_String); + var integer = Lower( + "private static T Target() => default(T);", + SpecialType.System_Int64); + + using (Assert.EnterMultipleScope()) + { + Assert.That(text.IsExact, Is.True); + Assert.That(text.Term, Is.TypeOf()); + Assert.That(integer.IsExact, Is.True); + Assert.That( + ((IrIntegerTerm)integer.Term).Value, + Is.Zero); + } + } + + [Test] + public void SpecializedStringTypeParameterEqualityDoesNotChangeToValueEquality() + { + var result = Lower( + """ + private static bool Target(T left, T right) + where T : class => left == right; + """, + SpecialType.System_String); + + AssertAbstention(result, FrontendAbstention.UnsupportedType); + } + [Test] public void UnaryScalarPoliciesDistinguishExactAndFailClosedCases() { @@ -239,7 +273,9 @@ private static void AssertAbstention( Is.EqualTo(abstention)); } - private static FrontendLoweringResult Lower(string members) + private static FrontendLoweringResult Lower( + string members, + SpecialType? specializedType = null) { var tree = CSharpSyntaxTree.ParseText( "public static class Subject {" + @@ -277,8 +313,15 @@ private static FrontendLoweringResult Lower(string members) var operation = GetExpressionOperation( compilation.GetSemanticModel(tree), expression); - return new RoslynOperationLowerer(new IrFactory()) - .Lower(operation); + var lowerer = new RoslynOperationLowerer(new IrFactory()); + if (specializedType.HasValue) + { + var replacement = compilation.GetSpecialType( + specializedType.Value); + lowerer.TypeSpecializer = type => + type is ITypeParameterSymbol ? replacement : type; + } + return lowerer.Lower(operation); } private static IOperation GetExpressionOperation( diff --git a/SharpProof.Frontend/CSharpScalarSemantics.generated.cs b/SharpProof.Frontend/CSharpScalarSemantics.generated.cs index d5ca7104b..8e4839d61 100644 --- a/SharpProof.Frontend/CSharpScalarSemantics.generated.cs +++ b/SharpProof.Frontend/CSharpScalarSemantics.generated.cs @@ -247,7 +247,7 @@ private static bool TryGetBinary( private static bool SupportsBuiltInEquality(ITypeSymbol? type) => - type is null or ({ IsReferenceType: true, TypeKind: not TypeKind.Delegate } and not INamedTypeSymbol { IsAbstract: true }) || + type is null or { IsReferenceType: true, TypeKind: not TypeKind.Delegate, SpecialType: not (SpecialType.System_Delegate or SpecialType.System_MulticastDelegate) } || type.SpecialType == SpecialType.System_Boolean || IsSupportedInteger(type.SpecialType); } diff --git a/SharpProof.Frontend/CSharpScalarSemantics.json b/SharpProof.Frontend/CSharpScalarSemantics.json index 77f022884..95251abd3 100644 --- a/SharpProof.Frontend/CSharpScalarSemantics.json +++ b/SharpProof.Frontend/CSharpScalarSemantics.json @@ -372,7 +372,11 @@ "excludedReferenceTypeKinds": [ "Delegate" ], - "excludeAbstractReferenceTypes": true, + "excludedSpecialTypes": [ + "System_Delegate", + "System_MulticastDelegate" + ], + "excludeAbstractReferenceTypes": false, "specialTypes": [ "System_Boolean" ] diff --git a/SharpProof.Frontend/CompilerIdentityBridge.cs b/SharpProof.Frontend/CompilerIdentityBridge.cs index 4ef4908ab..7c56d088d 100644 --- a/SharpProof.Frontend/CompilerIdentityBridge.cs +++ b/SharpProof.Frontend/CompilerIdentityBridge.cs @@ -94,6 +94,29 @@ internal static bool IsIntrinsicSequenceLength( definition.Type.SpecialType == SpecialType.System_Int64); } + internal static bool IsSupportedValueDomain(ITypeSymbol? type) + { + if (type is IArrayTypeSymbol array) + { + return IsSupportedValueDomain(array.ElementType); + } + if (type == null || type.TypeKind == TypeKind.Error) + { + return false; + } + if (type.TypeKind is TypeKind.Pointer or + TypeKind.FunctionPointer or TypeKind.TypeParameter) + { + return false; + } + if (type.IsReferenceType) + { + return true; + } + return type.SpecialType == SpecialType.System_Boolean || + CSharpScalarSemantics.IsSupportedInteger(type.SpecialType); + } + private static readonly IEqualityComparer OperationSemanticIdentityComparer = EqualityComparer.Default; diff --git a/SharpProof.Frontend/RoslynOperationLowerer.cs b/SharpProof.Frontend/RoslynOperationLowerer.cs index 3258e2d40..b465be16b 100644 --- a/SharpProof.Frontend/RoslynOperationLowerer.cs +++ b/SharpProof.Frontend/RoslynOperationLowerer.cs @@ -109,6 +109,12 @@ internal IrTypeId GetTypeId(ITypeSymbol? type) CompilerIdentityBridge.CreateTypeDisplay(type)); } + private bool IsSupportedValueDomain(ITypeSymbol? type) + { + return CompilerIdentityBridge.IsSupportedValueDomain( + TypeSpecializer(type)); + } + internal IrVariableTerm GetVariable(ISymbol symbol, ITypeSymbol? type) { if (!_variables.TryGetValue(symbol, out var variable)) @@ -208,6 +214,21 @@ private static IOperation UnwrapImplicitConversions(IOperation operation) return operation; } + private static IOperation UnwrapImplicitReferenceConversions( + IOperation operation) + { + while (operation is IConversionOperation + { + IsImplicit: true, + OperatorMethod: null + } conversion && conversion.Conversion.IsReference) + { + operation = conversion.Operand; + } + + return operation; + } + private static bool IsNullConstant(IOperation operation) { return operation.ConstantValue is { HasValue: true, Value: null }; @@ -448,13 +469,23 @@ public override LoweredExpression VisitFieldReference( public override LoweredExpression VisitLocalReference( ILocalReferenceOperation operation, LoweringContext argument) { - return LoweredExpression.Exact(_owner.GetVariable(operation.Local, operation.Type)); + return _owner.IsSupportedValueDomain(operation.Type) + ? LoweredExpression.Exact( + _owner.GetVariable(operation.Local, operation.Type)) + : _owner.Opaque( + operation, + FrontendAbstention.UnsupportedType); } public override LoweredExpression VisitParameterReference( IParameterReferenceOperation operation, LoweringContext argument) { - return LoweredExpression.Exact(_owner.GetVariable(operation.Parameter, operation.Type)); + return _owner.IsSupportedValueDomain(operation.Type) + ? LoweredExpression.Exact( + _owner.GetVariable(operation.Parameter, operation.Type)) + : _owner.Opaque( + operation, + FrontendAbstention.UnsupportedType); } public override LoweredExpression VisitFlowCapture( @@ -467,7 +498,12 @@ public override LoweredExpression VisitFlowCapture( public override LoweredExpression VisitFlowCaptureReference( IFlowCaptureReferenceOperation operation, LoweringContext argument) { - return LoweredExpression.Exact(_owner.GetCapture(operation.Id, operation.Type)); + return _owner.IsSupportedValueDomain(operation.Type) + ? LoweredExpression.Exact( + _owner.GetCapture(operation.Id, operation.Type)) + : _owner.Opaque( + operation, + FrontendAbstention.UnsupportedType); } public override LoweredExpression VisitInstanceReference( @@ -479,7 +515,8 @@ public override LoweredExpression VisitInstanceReference( public override LoweredExpression VisitDefaultValue( IDefaultValueOperation operation, LoweringContext argument) { - var specialType = operation.Type?.SpecialType ?? SpecialType.None; + var type = _owner.TypeSpecializer(operation.Type); + var specialType = type?.SpecialType ?? SpecialType.None; if (specialType == SpecialType.System_Boolean) { return LoweredExpression.Exact( @@ -492,16 +529,16 @@ public override LoweredExpression VisitDefaultValue( _owner._factory.Integer(0)); } - if (operation.Type?.IsReferenceType != true) + if (type?.IsReferenceType != true) { return _owner.Opaque( operation, FrontendAbstention.UnsupportedType); } - var type = _owner.GetTypeId(operation.Type); + var typeId = _owner.GetTypeId(type); return LoweredExpression.Exact( - _owner._factory.Null(type)); + _owner._factory.Null(typeId)); } public override LoweredExpression VisitUnaryOperator( @@ -622,15 +659,30 @@ public override LoweredExpression VisitBinaryOperator( return OpaqueBinary(operation, FrontendAbstention.UnsupportedType); } } + var leftOperand = operation.LeftOperand; + var rightOperand = operation.RightOperand; + if (operation.OperatorKind is + BinaryOperatorKind.Equals or BinaryOperatorKind.NotEquals) + { + leftOperand = UnwrapImplicitReferenceConversions(leftOperand); + rightOperand = UnwrapImplicitReferenceConversions(rightOperand); + if (ChangesReferenceEqualityToString(leftOperand) || + ChangesReferenceEqualityToString(rightOperand)) + { + return OpaqueBinary( + operation, + FrontendAbstention.UnsupportedType); + } + } if (!CSharpScalarSemantics.SupportsBuiltInOperands( operation.OperatorKind, - operation.LeftOperand.Type, - operation.RightOperand.Type)) + leftOperand.Type, + rightOperand.Type)) { return OpaqueBinary(operation, FrontendAbstention.UnsupportedType); } - var left = _owner.LowerCore(operation.LeftOperand); + var left = _owner.LowerCore(leftOperand); if (operation.OperatorKind == BinaryOperatorKind.ConditionalAnd && left.Term is IrBooleanTerm { Value: false }) { @@ -643,7 +695,7 @@ public override LoweredExpression VisitBinaryOperator( return LoweredExpression.Exact(left.Term); } - var right = _owner.LowerCore(operation.RightOperand); + var right = _owner.LowerCore(rightOperand); if (!left.Classification.IsExact || !right.Classification.IsExact) { return OpaqueBinary(operation, FirstAbstention(left, right)); @@ -688,6 +740,13 @@ public override LoweredExpression VisitBinaryOperator( } } + private bool ChangesReferenceEqualityToString(IOperation operand) + { + return operand.Type is ITypeParameterSymbol && + _owner.TypeSpecializer(operand.Type)?.SpecialType == + SpecialType.System_String; + } + public override LoweredExpression VisitConditional( IConditionalOperation operation, LoweringContext argument) { diff --git a/SharpProof.Frontend/RoslynProgramLowerer.cs b/SharpProof.Frontend/RoslynProgramLowerer.cs index d8496ab6e..c52e24b82 100644 --- a/SharpProof.Frontend/RoslynProgramLowerer.cs +++ b/SharpProof.Frontend/RoslynProgramLowerer.cs @@ -159,7 +159,11 @@ private bool LowerStatement( LowerUnsupportedMutation(block, operation, mutation.Target); return false; case ICompoundAssignmentOperation mutation: - LowerUnsupportedMutation(block, operation, mutation.Target); + LowerUnsupportedMutation( + block, + operation, + mutation.Target, + mutation.Value); return false; default: Abstain(operation, FrontendAbstention.UnsupportedStatement); @@ -201,21 +205,30 @@ private void LowerCapture( private void LowerAssignment( IrBlockId block, OperationId operation, ISimpleAssignmentOperation assignment) { - var value = LowerValue(block, operation, assignment.Value); var variable = _expressions.GetReferencedVariable(assignment.Target, unwrapConversions: false); if (variable.HasValue) { - AssignOrHavoc(block, operation, variable.Value, value); + var directValue = LowerValue( + block, + operation, + assignment.Value); + AssignOrHavoc( + block, + operation, + variable.Value, + directValue); return; } var location = LowerLocation(block, operation, assignment.Target); if (location.Location == null) { + _ = LowerValue(block, operation, assignment.Value); Abstain(operation, location.Abstention); - Havoc(block, operation, IrHavocKind.Memory); + HavocKnownState(block, operation); return; } + var value = LowerValue(block, operation, assignment.Value); if (location.Location.Type != value.Type) { Abstain(operation, FrontendAbstention.UnsupportedType); @@ -232,8 +245,6 @@ private IrTerm LowerValue(IrBlockId block, OperationId operation, IOperation val case IInvocationOperation invocation: return LowerInvocation(block, operation, invocation, wantsResult: true)!; case IFieldReferenceOperation: - case IPropertyReferenceOperation property - when !RoslynOperationLowerer.IsIntrinsicLength(property): case IArrayElementReferenceOperation: var location = LowerLocation(block, operation, value); if (location.Location != null) @@ -258,6 +269,8 @@ private IrTerm LowerValue(IrBlockId block, OperationId operation, IOperation val var receiver = LowerOptionalValue(block, operation, invocation.Instance); var arguments = LowerInvocationArguments(block, operation, invocation); var resultType = _expressions.GetTypeId(invocation.Type); + var hasSupportedResult = invocation.TargetMethod.ReturnsVoid || + CompilerIdentityBridge.IsSupportedValueDomain(invocation.Type); var member = _expressions.GetMember(invocation.TargetMethod, receiver, "call:", invocation.Type, arguments); var isDirect = IsDirectInvocation(invocation); if (!isDirect) @@ -266,7 +279,8 @@ private IrTerm LowerValue(IrBlockId block, OperationId operation, IOperation val } IrVarId? target = null; - if (wantsResult && !invocation.TargetMethod.ReturnsVoid) + if (wantsResult && !invocation.TargetMethod.ReturnsVoid && + hasSupportedResult) { target = CreateTemporary("call", resultType); } @@ -328,20 +342,24 @@ private LocationLowering LowerLocation( var fieldMember = _expressions.GetMember(field.Field, fieldReceiver, "field:", field.Type); return LocationLowering.FromLocation(_builder.MemberLocation(fieldMember, fieldReceiver)); case IPropertyReferenceOperation property: - var propertyReceiver = LowerOptionalValue(block, operation, property.Instance); - var propertyArguments = LowerValues(block, operation, - property.Arguments.Select(static argument => argument.Value)); - var propertyMember = _expressions.GetMember( - property.Property, propertyReceiver, "property:", - property.Type, propertyArguments); - return LocationLowering.FromLocation( - _builder.MemberLocation(propertyMember, propertyReceiver, propertyArguments)); + _ = LowerOptionalValue(block, operation, property.Instance); + foreach (var argument in property.Arguments) + { + _ = LowerValue(block, operation, argument.Value); + } + return LocationLowering.Abstain( + FrontendAbstention.UnsupportedMemberAccess); case IArrayElementReferenceOperation element when element.Indices.Length == 1: return LocationLowering.FromLocation(_builder.SequenceLocation( LowerValue(block, operation, element.ArrayReference), LowerValue(block, operation, element.Indices[0]))); - case IArrayElementReferenceOperation: + case IArrayElementReferenceOperation element: + _ = LowerValue(block, operation, element.ArrayReference); + foreach (var index in element.Indices) + { + _ = LowerValue(block, operation, index); + } return LocationLowering.Abstain(FrontendAbstention.UnsupportedMemberAccess); default: return LocationLowering.Abstain(FrontendAbstention.UnsupportedMutation); @@ -425,22 +443,22 @@ private void AssignOrHavoc( } private void LowerUnsupportedMutation( - IrBlockId block, OperationId operation, IOperation target) - { - Abstain(operation, FrontendAbstention.UnsupportedMutation); - HavocTarget(block, operation, target); - } - - private void HavocTarget( - IrBlockId block, OperationId operation, IOperation target) + IrBlockId block, + OperationId operation, + IOperation target, + IOperation? value = null) { var variable = _expressions.GetReferencedVariable(target); - if (variable.HasValue) + if (!variable.HasValue) { - Havoc(block, operation, IrHavocKind.Variables, variable.Value); - return; + _ = LowerLocation(block, operation, target); + } + if (value != null) + { + _ = LowerValue(block, operation, value); } - Havoc(block, operation, IrHavocKind.Memory); + Abstain(operation, FrontendAbstention.UnsupportedMutation); + HavocKnownState(block, operation); } private void HavocKnownState(IrBlockId block, OperationId operation) @@ -467,12 +485,6 @@ private void LowerReturn( return value == null ? null : LowerValue(block, operation, value); } - private IrTerm[] LowerValues( - IrBlockId block, OperationId operation, IEnumerable values) - { - return [.. values.Select(value => LowerValue(block, operation, value))]; - } - private void Havoc(IrBlockId block, OperationId operation, IrHavocKind kind, params IrVarId[] variables) { _builder.Havoc(block, operation, kind, variables); diff --git a/SharpProof.Fuzz.Test/FrontendSemanticEdgeCaseTests.cs b/SharpProof.Fuzz.Test/FrontendSemanticEdgeCaseTests.cs index 555c9458c..ece464dbe 100644 --- a/SharpProof.Fuzz.Test/FrontendSemanticEdgeCaseTests.cs +++ b/SharpProof.Fuzz.Test/FrontendSemanticEdgeCaseTests.cs @@ -8,17 +8,42 @@ namespace SharpProof.Fuzz.Test; [TestFixture] public sealed class FrontendSemanticEdgeCaseTests { + private static readonly long[] SequenceValue = [1L, 2L]; + private static readonly ulong[] UnsupportedSequenceValue = [1UL]; + private static readonly Array MultidimensionalSequenceValue = + Array.CreateInstance(typeof(long), 2, 3); + [Test] public void FixedSemanticEdgesMatchRuntimeOrAbstainExactly() { var cases = new[] { + Exact("sbyte", "", "-3"), + Exact("byte", "", "3"), + Exact("short", "", "-3"), + Exact("ushort", "", "3"), + Exact("int", "", "-3"), + Exact("uint", "", "3"), + Exact("char", "", "'A'"), Exact("long", "short value", "(long)value", short.MinValue), Exact("long", "int value", "(long)value", int.MaxValue), Exact("long", "uint value", "(long)value", uint.MaxValue), Exact("long", "", "checked((int)3L)"), + Exact("object?", "object? value", "value", new object()), + Exact("long[]", "long[] value", "value", SequenceValue), + Closed( + "ulong", + "ulong[] value", + "value[0]", + FrontendAbstention.UnsupportedType, + UnsupportedSequenceValue), Exact("string?", "object? value", "(string)value", (object?)null), Exact("string?", "object? value", "(string)value", "proof"), Exact("string?", "object? value", "(string)value", new object()), + Exact( + "long", + "long[,] value", + "value.LongLength", + MultidimensionalSequenceValue), Closed( "long", "long value", @@ -91,7 +116,7 @@ public void FixedSemanticEdgesMatchRuntimeOrAbstainExactly() results.Select((result, index) => index + ": " + result.Detail))); Assert.That( - results[6].ExceptionKind, + results[16].ExceptionKind, Is.EqualTo(IrExceptionKind.InvalidCast)); for (var index = 0; index < cases.Length; index++) { @@ -104,6 +129,103 @@ public void FixedSemanticEdgesMatchRuntimeOrAbstainExactly() } } + [Test] + public void CompileInvalidSemanticEdgeDoesNotPoisonValidPeer() + { + var results = new FrontendDifferentialOracle().CompareSemanticEdges( + [ + Exact("long", "", "0L"), + Exact("long", "", "long.MaxValue + 1L") + ]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].Status, Is.EqualTo(FuzzOracleStatus.Agreement)); + Assert.That(results[1].Status, Is.EqualTo(FuzzOracleStatus.Mismatch)); + } + } + + [Test] + public void CompileSuccessfulSemanticEdgeInjectionDoesNotPoisonValidPeer() + { + var results = new FrontendDifferentialOracle().CompareSemanticEdges( + [ + Exact("long", "", "0L"), + Exact( + "long", + "", + "0L; public static long EdgeTarget999() => 0L") + ]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].Status, Is.EqualTo(FuzzOracleStatus.Agreement)); + Assert.That(results[1].Status, Is.EqualTo(FuzzOracleStatus.Mismatch)); + } + } + + [Test] + public void NonnumericSemanticEdgeInjectionDoesNotEscapeBatchIsolation() + { + var results = new FrontendDifferentialOracle().CompareSemanticEdges( + [ + Exact("long", "", "0L"), + Exact( + "long", + "", + "0L; public static long EdgeTargetOops() => 0L") + ]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].Status, Is.EqualTo(FuzzOracleStatus.Agreement)); + Assert.That(results[1].Status, Is.EqualTo(FuzzOracleStatus.Mismatch)); + } + } + + [Test] + public void StaticInitializerInjectionDoesNotPoisonValidPeer() + { + var results = new FrontendDifferentialOracle().CompareSemanticEdges( + [ + Exact("long", "", "0L"), + Exact( + "long", + "", + "0L; static readonly long Poison = Throw(); " + + "static long Throw() => throw new System.Exception()") + ]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].Status, Is.EqualTo(FuzzOracleStatus.Agreement)); + Assert.That(results[1].Status, Is.EqualTo(FuzzOracleStatus.Mismatch)); + } + } + + [Test] + public void TopLevelInitializerInjectionDoesNotPoisonValidPeer() + { + var results = new FrontendDifferentialOracle().CompareSemanticEdges( + [ + Exact("long", "", "0L"), + Exact( + "long", + "", + "0L; } public static class Injected { " + + "[System.Runtime.CompilerServices.ModuleInitializer] " + + "public static void Initialize() => " + + "throw new System.Exception(); } public static class Tail { " + + "public static long Value => 0L") + ]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].Status, Is.EqualTo(FuzzOracleStatus.Agreement)); + Assert.That(results[1].Status, Is.EqualTo(FuzzOracleStatus.Mismatch)); + } + } + private static FrontendSemanticEdgeCase Exact( string returnType, string parameters, diff --git a/SharpProof.Fuzz.Test/FuzzRunnerTests.cs b/SharpProof.Fuzz.Test/FuzzRunnerTests.cs index 01fd491d4..8881e39ef 100644 --- a/SharpProof.Fuzz.Test/FuzzRunnerTests.cs +++ b/SharpProof.Fuzz.Test/FuzzRunnerTests.cs @@ -7,6 +7,59 @@ namespace SharpProof.Fuzz.Test; [TestFixture] public sealed class FuzzRunnerTests { + [Test] + public void FailureEvidenceRetentionUsesDeterministicBoundedKeys() + { + var statuses = Enumerable.Repeat( + FuzzOracleStatus.Mismatch, + FuzzRunner.MaximumRetainedFailures) + .ToArray(); + var keys = FuzzRunner.SelectFailureKeys( + statuses, + statuses, + statuses); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + keys.Length, + Is.EqualTo(FuzzRunner.MaximumRetainedFailures)); + Assert.That( + keys.Take(3), + Is.EqualTo(new[] + { + new FuzzFailureKey(0, "finite-domain-smt"), + new FuzzFailureKey(0, "frontend"), + new FuzzFailureKey(0, "partial-term-smt") + })); + Assert.That( + keys[^1], + Is.EqualTo(new FuzzFailureKey( + 21, + "finite-domain-smt"))); + } + } + + [Test] + public void PartialAbstentionIsNotClassifiedAsMismatchEvidence() + { + var classification = FuzzRunner.ClassifyCase( + FuzzOracleStatus.Agreement, + FuzzOracleStatus.Agreement, + FuzzOracleStatus.Abstained); + var keys = FuzzRunner.SelectFailureKeys( + new[] { FuzzOracleStatus.Agreement }, + new[] { FuzzOracleStatus.Agreement }, + new[] { FuzzOracleStatus.Abstained }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(classification.HasMismatch, Is.False); + Assert.That(classification.HasAbstention, Is.True); + Assert.That(keys, Is.Empty); + } + } + [Test] public async Task FixedSeedIsDeterministicAndSound() { @@ -75,6 +128,99 @@ public void SupportedDomainAbstentionFailsTheCampaign() Assert.That(summary.Passed, Is.False); } + [TestCase(0, 1)] + [TestCase(1, 0)] + [TestCase(1, 5)] + public void InvalidSummaryOptionsDoNotPass( + int cases, + int maximumParallelism) + { + var coverage = new FrontendFuzzCoverage( + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); + var summary = new FuzzSummary( + SchemaVersion: 4, + Cases: cases, + Seed: 7, + MaximumParallelism: maximumParallelism, + Agreements: cases, + Abstentions: 0, + FrontendAgreements: cases, + SmtAgreements: cases, + PartialSmtAgreements: cases, + FrontendCoverage: coverage, + CoverageSatisfied: true, + Failures: []); + + Assert.That(summary.Passed, Is.False); + } + + [Test] + public void MalformedSummaryEvidenceDoesNotPass() + { + var complete = new FrontendFuzzCoverage( + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); + var empty = new FrontendFuzzCoverage( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + var negative = empty with { TextParameters = -1 }; + var impossibleExceptions = empty with + { + DivideByZeroExceptions = 1, + OverflowExceptions = 1 + }; + var valid = new FuzzSummary( + SchemaVersion: 4, + Cases: FuzzOptions.DefaultCases, + Seed: 7, + MaximumParallelism: 1, + Agreements: FuzzOptions.DefaultCases, + Abstentions: 0, + FrontendAgreements: FuzzOptions.DefaultCases, + SmtAgreements: FuzzOptions.DefaultCases, + PartialSmtAgreements: FuzzOptions.DefaultCases, + FrontendCoverage: complete, + CoverageSatisfied: true, + Failures: []); + + using (Assert.EnterMultipleScope()) + { + Assert.That(valid.Passed, Is.True); + Assert.That( + (valid with { SchemaVersion = 999 }).Passed, + Is.False); + Assert.That( + (valid with { Failures = default }).Passed, + Is.False); + Assert.That( + (valid with { FrontendCoverage = null! }).Passed, + Is.False); + Assert.That( + (valid with { FrontendCoverage = empty }).Passed, + Is.False); + Assert.That( + (valid with + { + Cases = 1, + Agreements = 1, + FrontendAgreements = 1, + SmtAgreements = 1, + PartialSmtAgreements = 1, + FrontendCoverage = negative + }).Passed, + Is.False); + Assert.That( + (valid with + { + Cases = 1, + Agreements = 1, + FrontendAgreements = 1, + SmtAgreements = 1, + PartialSmtAgreements = 1, + FrontendCoverage = impossibleExceptions + }).Passed, + Is.False); + } + } + [Test] public async Task CancellationPropagates() { @@ -154,6 +300,33 @@ public void ExpandedFrontendShapesMatchRuntime() Assert.That(results[2].ExceptionKind, Is.Null); } + [Test] + public void FrontendBatchCompileFailureIsIsolatedToInvalidCase() + { + var valid = new GeneratedCSharpCase( + GeneratedCSharpExpression.Integer(0), + Left: 0, + Right: 0, + Condition: false); + var invalid = new GeneratedCSharpCase( + GeneratedCSharpExpression.Binary( + GeneratedExpressionKind.Add, + GeneratedCSharpExpression.Integer(long.MaxValue), + GeneratedCSharpExpression.Integer(1)), + Left: 0, + Right: 0, + Condition: false); + + var results = new FrontendDifferentialOracle() + .CompareBatch([valid, invalid]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].Status, Is.EqualTo(FuzzOracleStatus.Agreement)); + Assert.That(results[1].Status, Is.EqualTo(FuzzOracleStatus.Mismatch)); + } + } + [Test] public void ExpandedCoverageRequirementFailsClosed() { @@ -303,6 +476,7 @@ Task Preserves(IrTerm candidate, CancellationToken _) } [TestCase("--cases", "0")] + [TestCase("--cases", "1000001")] [TestCase("--max-parallelism", "5")] [TestCase("--unknown", "1")] public void InvalidOptionsFailClosed(string option, string value) @@ -318,6 +492,22 @@ public void InvalidOptionsFailClosed(string option, string value) } } + [TestCase(0, 1)] + [TestCase(1000001, 1)] + [TestCase(1, 0)] + [TestCase(1, 5)] + public void DirectRunnerRejectsInvalidOptions( + int cases, + int maximumParallelism) + { + Func run = () => FuzzRunner.RunAsync(new FuzzOptions( + cases, + Seed: 1, + maximumParallelism)); + + Assert.ThrowsAsync(run); + } + private static bool Contains(IrTerm term, IrVarId variable) { return term switch diff --git a/SharpProof.Gates.Test/PerformanceGateTests.cs b/SharpProof.Gates.Test/PerformanceGateTests.cs index ab454b4f9..ec8098462 100644 --- a/SharpProof.Gates.Test/PerformanceGateTests.cs +++ b/SharpProof.Gates.Test/PerformanceGateTests.cs @@ -407,6 +407,48 @@ public void AdvisoryPackagePolicyRunsAnalyzerAndOmitsVerifierWork() RepositoryLayout.FindRoot()); } + [Test] + public void AdvisoryPolicyRejectsSubstitutedAnalyzerEntryPoint() + { + var root = RepositoryLayout.FindRoot(); + var portableProps = XDocument.Load(Path.Combine( + root, + "SharpProof.Package", + "buildTransitive", + "SharpProof.props")); + var portableTargets = XDocument.Load(Path.Combine( + root, + "SharpProof.Package", + "buildTransitive", + "SharpProof.targets")); + var verifierProps = XDocument.Load(Path.Combine( + root, + "SharpProof.Verifier", + "buildTransitive", + "SharpProof.Verifier.props")); + var verifierTargets = XDocument.Load(Path.Combine( + root, + "SharpProof.Verifier", + "buildTransitive", + "SharpProof.Verifier.targets")); + var entryPoint = portableTargets.Descendants("Analyzer") + .Single(analyzer => string.Equals( + analyzer.Element("SharpProofAnalyzerRole")?.Value, + "EntryPoint", + StringComparison.Ordinal)); + entryPoint.SetAttributeValue( + "Include", + "$(_SharpProofContractForGeneratorPath)"); + + Assert.Throws( + (Action)(() => + PerformanceGate.ValidateAdvisoryPackagePolicy( + portableProps, + portableTargets, + verifierProps, + verifierTargets))); + } + [Test] public void AdvisoryPolicyRejectsAWidenedVerifierCondition() { diff --git a/SharpProof.Gates/Performance/PerformanceGate.cs b/SharpProof.Gates/Performance/PerformanceGate.cs index 7fe90107a..ec827b003 100644 --- a/SharpProof.Gates/Performance/PerformanceGate.cs +++ b/SharpProof.Gates/Performance/PerformanceGate.cs @@ -1258,6 +1258,56 @@ internal static void ValidateAdvisoryPackagePolicy( "'$(_SharpProofProfileNormalized)'!='off'AND" + "'$(DesignTimeBuild)'!='true'", StringComparison.Ordinal)); + const string analyzerDependencies = + "$(_SharpProofSharedDirectory)/SharpProof.Analyzer.Core.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.Contracts.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.Dataflow.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.Effects.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.Frontend.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.Ir.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.Specs.dll;" + + "$(_SharpProofSharedDirectory)/System.Buffers.dll;" + + "$(_SharpProofSharedDirectory)/System.Collections.Immutable.dll;" + + "$(_SharpProofSharedDirectory)/System.Memory.dll;" + + "$(_SharpProofSharedDirectory)/System.Numerics.Vectors.dll;" + + "$(_SharpProofSharedDirectory)/System.Reflection.Metadata.dll;" + + "$(_SharpProofSharedDirectory)/System.Runtime.CompilerServices.Unsafe.dll;" + + "$(_SharpProofSharedDirectory)/System.Text.Encoding.CodePages.dll;" + + "$(_SharpProofSharedDirectory)/System.Threading.Tasks.Extensions.dll"; + const string collectorDependencies = + "$(_SharpProofSharedDirectory)/Microsoft.Bcl.AsyncInterfaces.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.CompilerArtifact.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.Summaries.dll;" + + "$(_SharpProofSharedDirectory)/SharpProof.Worker.Protocol.dll;" + + "$(_SharpProofSharedDirectory)/System.IO.Pipelines.dll;" + + "$(_SharpProofSharedDirectory)/System.Text.Encodings.Web.dll;" + + "$(_SharpProofSharedDirectory)/System.Text.Json.dll"; + var analyzerItemsValid = + analyzerGroup?.Elements("Analyzer").Count() == 3 && + HasAnalyzerItem( + analyzerGroup, + "$(_SharpProofAnalyzerPath)", + "EntryPoint") && + HasAnalyzerItem( + analyzerGroup, + "$(_SharpProofContractForGeneratorPath)", + "Generator") && + HasAnalyzerItem( + analyzerGroup, + analyzerDependencies, + "Dependency", + "false"); + var collectorItemsValid = + collectorGroup?.Elements("Analyzer").Count() == 2 && + HasAnalyzerItem( + collectorGroup, + "$(SharpProofCompilerCollectorPath)", + "Collector") && + HasAnalyzerItem( + collectorGroup, + collectorDependencies, + "CollectorDependency", + "false"); var verifierMarker = verifierProps .Descendants("_SharpProofVerifierPackagePresent") .SingleOrDefault(); @@ -1344,6 +1394,8 @@ internal static void ValidateAdvisoryPackagePolicy( portableContainsVerifierWork || analyzerGroups.Length != 2 || collectorGroup == null || + !analyzerItemsValid || + !collectorItemsValid || !string.Equals( normalizedCondition, "'$(_SharpProofProfileNormalized)'!='off'", @@ -1405,6 +1457,37 @@ private static string NormalizeMsBuildCondition(string? condition) .Where(static character => !char.IsWhiteSpace(character))); } + private static bool HasAnalyzerItem( + XElement? group, + string expectedInclude, + string expectedRole, + string? expectedVisibility = null) + { + var expectedPaths = SplitMsBuildList(expectedInclude); + return group?.Elements("Analyzer").Count(analyzer => + SplitMsBuildList((string?)analyzer.Attribute("Include")) + .SequenceEqual(expectedPaths, StringComparer.Ordinal) && + string.Equals( + analyzer.Element("SharpProofAnalyzerRole")?.Value, + expectedRole, + StringComparison.Ordinal) && + string.Equals( + analyzer.Element("Visible")?.Value, + expectedVisibility, + StringComparison.Ordinal)) == 1; + } + + private static ImmutableArray SplitMsBuildList(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? [] + : [.. value.Split( + [';'], + StringSplitOptions.RemoveEmptyEntries) + .Select(static item => item.Trim()) + .Where(static item => item.Length != 0)]; + } + private static ImmutableArray SplitTargetList(string? value) { return string.IsNullOrWhiteSpace(value) diff --git a/SharpProof.Gates/README.md b/SharpProof.Gates/README.md index b65560126..1dc34dea7 100644 --- a/SharpProof.Gates/README.md +++ b/SharpProof.Gates/README.md @@ -124,7 +124,7 @@ IDE analyzer performance paths reference neither SMT nor Z3. Worker/package tests also exercise protocol version 11 manifest equality, stable claim IDs, policy-controlled SP0047/SP0048 output, cache validation against the current manifest, fatal run handling, and compiler artifact schema -version 14, including generated contracts, portable whole-body CFG/IR, +version 15, including generated contracts, portable whole-body CFG/IR, schema-2 relational source/implementation-IL/audited-pack summaries, compiler diagnostics, exact lowered-callable hydration, and independent whole-body counterexample replay. Package tests also cover deterministic, diff --git a/SharpProof.Ir/IrSemanticTerms.cs b/SharpProof.Ir/IrSemanticTerms.cs index a89b22885..408520b3d 100644 --- a/SharpProof.Ir/IrSemanticTerms.cs +++ b/SharpProof.Ir/IrSemanticTerms.cs @@ -117,8 +117,16 @@ public static ImmutableHashSet CollectVariables(IrTerm root) public static int GetDepth(IrTerm root) { ArgumentNullGuard.NotNull(root, nameof(root)); - var memo = new Dictionary(); + return GetDepth(root, memo); + } + + internal static int GetDepth( + IrTerm root, + Dictionary memo) + { + ArgumentNullGuard.NotNull(root, nameof(root)); + ArgumentNullGuard.NotNull(memo, nameof(memo)); var pending = new Stack<(IrTerm Term, bool ChildrenReady)>(); pending.Push((root, false)); while (pending.Count != 0) diff --git a/SharpProof.Package.Test/BuildTaskTests.cs b/SharpProof.Package.Test/BuildTaskTests.cs index 52562cef4..8ba4916e1 100644 --- a/SharpProof.Package.Test/BuildTaskTests.cs +++ b/SharpProof.Package.Test/BuildTaskTests.cs @@ -3,6 +3,8 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using NUnit.Framework; +using System.Diagnostics; +using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using SharpProof.BuildTasks; @@ -15,6 +17,503 @@ namespace SharpProof.Package.Test; [TestFixture] public sealed class BuildTaskTests { + [Test] + public void GeneratedSupervisorNoncePassesSupervisorGateValidation() + { + var nonce = RunVerifier.CreateSupervisorNonce(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(nonce, Has.Length.EqualTo(64)); + Assert.That(nonce, Is.EqualTo(nonce.ToUpperInvariant())); + Assert.That(VerifierProcessSupervisor.IsValidNonce(nonce), Is.True); + } + } + + [Test] + public void SupervisorCleanupReceiptsRequireAnExactNonceAndRecord() + { + const string nonce = + "0123456789abcdef0123456789abcdef" + + "0123456789abcdef0123456789abcdef"; + var output = "verifier output\nSharpProof.Armed/1 " + nonce + + "\nSharpProof.Cleanup/1 " + nonce + "\n"; + + using (Assert.EnterMultipleScope()) + { + Assert.That( + RunVerifier.HasSupervisorProtocolRecord( + output, + "SharpProof.Armed/1", + nonce), + Is.True); + Assert.That( + RunVerifier.HasSupervisorProtocolRecord( + output, + "SharpProof.Cleanup/1", + "f" + nonce[1..]), + Is.False); + Assert.That( + RunVerifier.HasSupervisorProtocolRecord( + output, + "SharpProof.Cleanup/1 trailing", + nonce), + Is.False); + } + } + + [Test] + public void MissingCleanupReceiptInvokesContainmentFailureDecision() + { + var failure = string.Empty; + using var task = new RunVerifier + { + ContainmentAuthenticationFailureOverride = message => + failure = message + }; + + var authenticated = task.RequireSupervisorCleanupReceipt( + cleanupAuthenticated: false, + authenticationRequired: true); + + using (Assert.EnterMultipleScope()) + { + Assert.That(authenticated, Is.False); + Assert.That(failure, Does.Contain("cleanup receipt")); + } + } + + [Test] + public async System.Threading.Tasks.Task + VerifierOutputDrainIsBoundedAndStillAuthenticatesCleanup() + { + const string nonce = + "0123456789abcdef0123456789abcdef" + + "0123456789abcdef0123456789abcdef"; + var input = "SharpProof.Armed/1 " + nonce + "\n" + + new string( + 'x', + RunVerifier.MaximumCapturedOutputCharacters + 1) + + "\n\nSharpProof.Cleanup/1 " + nonce + "\n"; + using var signal = new ManualResetEventSlim(); + + var result = await RunVerifier.ReadBoundedOutputAsync( + new StringReader(input), + nonce, + signal); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + result.Text.Length, + Is.EqualTo( + RunVerifier.MaximumCapturedOutputCharacters)); + Assert.That(result.LimitExceeded, Is.True); + Assert.That(signal.IsSet, Is.True); + Assert.That(result.SupervisorArmed, Is.True); + Assert.That(result.CleanupAuthenticated, Is.True); + } + } + + [Test] + public async System.Threading.Tasks.Task + VerifierArmedStateIsPublishedIndependentlyOfOutputCompletion() + { + const string nonce = + "0123456789abcdef0123456789abcdef" + + "0123456789abcdef0123456789abcdef"; + using var signal = new ManualResetEventSlim(); + var armed = new System.Threading.Tasks.TaskCompletionSource( + System.Threading.Tasks.TaskCreationOptions + .RunContinuationsAsynchronously); + using var reader = new GatedTextReader( + "SharpProof.Armed/1 " + nonce + "\n"); + + var read = RunVerifier.ReadBoundedOutputAsync( + reader, + nonce, + signal, + armed); + try + { + Assert.That( + await armed.Task.WaitAsync(TimeSpan.FromSeconds(1)), + Is.True); + Assert.That(read.IsCompleted, Is.False); + } + finally + { + reader.Complete(); + await read; + } + } + + [Test] + public async System.Threading.Tasks.Task + VerifierCleanupStateIsPublishedIndependentlyOfOutputCompletion() + { + const string nonce = + "0123456789abcdef0123456789abcdef" + + "0123456789abcdef0123456789abcdef"; + using var signal = new ManualResetEventSlim(); + var cleanup = new System.Threading.Tasks.TaskCompletionSource( + System.Threading.Tasks.TaskCreationOptions + .RunContinuationsAsynchronously); + using var reader = new GatedTextReader( + "SharpProof.Armed/1 " + nonce + "\n" + + "SharpProof.Cleanup/1 " + nonce + "\n"); + + var read = RunVerifier.ReadBoundedOutputAsync( + reader, + nonce, + signal, + supervisorCleanupSignal: cleanup); + try + { + Assert.That( + await cleanup.Task.WaitAsync(TimeSpan.FromSeconds(1)), + Is.True); + Assert.That(read.IsCompleted, Is.False); + } + finally + { + reader.Complete(); + await read; + } + } + + [Test] + public void InterruptedAuthenticationWaitDefersIncompleteProtocolDrain() + { + using (Assert.EnterMultipleScope()) + { + Assert.That( + RunVerifier.ShouldDeferSupervisorAuthentication( + authenticationRequired: true, + interrupted: true, + outputCompleted: false), + Is.True); + Assert.That( + RunVerifier.ShouldDeferSupervisorAuthentication( + authenticationRequired: true, + interrupted: false, + outputCompleted: false), + Is.True); + Assert.That( + RunVerifier.ShouldDeferSupervisorAuthentication( + authenticationRequired: true, + interrupted: true, + outputCompleted: true), + Is.False); + } + } + + [Test] + public void OutputDrainWaitRechecksInterruptionsBetweenBoundedSlices() + { + var interrupted = false; + var waits = 0; + var incomplete = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var completed = RunVerifier.WaitForOutputCompletion( + incomplete.Task, + timeoutMilliseconds: 1000, + () => interrupted, + milliseconds => + { + Assert.That( + milliseconds, + Is.InRange( + 1, + RunVerifier.OutputDrainPollingMilliseconds)); + waits++; + interrupted = true; + return false; + }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(completed, Is.False); + Assert.That(waits, Is.EqualTo(1)); + } + } + + [Test] + public void OutputDrainWaitReturnsImmediatelyForCompletedOutput() + { + Assert.That( + RunVerifier.WaitForOutputCompletion( + System.Threading.Tasks.Task.CompletedTask, + timeoutMilliseconds: 1000, + static () => false, + _ => throw new AssertionException( + "Completed output must not enter the polling wait.")), + Is.True); + } + + [Test] + public void SupervisorReadinessWaitObservesArmedSignal() + { + var armed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var waits = 0; + + var result = RunVerifier.WaitForSupervisorReadiness( + armed.Task, + System.Threading.Tasks.Task.CompletedTask, + static () => false, + timeoutMilliseconds: 1000, + _ => + { + waits++; + armed.TrySetResult(true); + return true; + }); + + Assert.That(result, Is.EqualTo(RunVerifier.SupervisorReadiness.Armed)); + Assert.That(waits, Is.EqualTo(1)); + } + + [Test] + public void SupervisorReadinessWaitObservesPreArmedExit() + { + var exited = false; + var armed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var result = RunVerifier.WaitForSupervisorReadiness( + armed.Task, + System.Threading.Tasks.Task.CompletedTask, + () => exited, + timeoutMilliseconds: 1000, + _ => + { + exited = true; + return false; + }); + + Assert.That( + result, + Is.EqualTo(RunVerifier.SupervisorReadiness.ExitedBeforeArmed)); + } + + [Test] + public void SupervisorReadinessDoesNotInferPreArmedExitBeforeOutputDrain() + { + var armed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var output = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var result = RunVerifier.WaitForSupervisorReadiness( + armed.Task, + output.Task, + static () => true, + timeoutMilliseconds: 1, + _ => false); + + Assert.That( + result, + Is.EqualTo(RunVerifier.SupervisorReadiness.NotReady)); + } + + [Test] + public void SupervisorReadinessRechecksArmedAfterExitObservation() + { + var armed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var result = RunVerifier.WaitForSupervisorReadiness( + armed.Task, + System.Threading.Tasks.Task.CompletedTask, + () => + { + armed.TrySetResult(true); + return true; + }, + timeoutMilliseconds: 1000); + + Assert.That(result, Is.EqualTo(RunVerifier.SupervisorReadiness.Armed)); + } + + [Test] + public void SupervisorReadinessWaitFailsClosedAtBound() + { + var armed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var result = RunVerifier.WaitForSupervisorReadiness( + armed.Task, + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously).Task, + static () => false, + timeoutMilliseconds: 1, + _ => false); + + Assert.That( + result, + Is.EqualTo(RunVerifier.SupervisorReadiness.NotReady)); + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public async System.Threading.Tasks.Task + RetainedCleanupAnchorRejectsMissingEventualReceipt() + { + const string nonce = + "0123456789abcdef0123456789abcdef" + + "0123456789abcdef0123456789abcdef"; + var failure = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var process = Process.Start("/bin/true"); + Assert.That(process, Is.Not.Null); + + RunVerifier.RetainCleanupAnchorForTest( + process!, + System.Threading.Tasks.Task.FromResult( + "SharpProof.Armed/1 " + nonce + "\n"), + nonce, + message => failure.TrySetResult(message)); + + Assert.That( + await failure.Task.WaitAsync(TimeSpan.FromSeconds(2)), + Does.Contain("cleanup receipt")); + Assert.That( + SpinWait.SpinUntil( + () => RunVerifier.RetainedCleanupAnchorCount == 0, + TimeSpan.FromSeconds(2)), + Is.True); + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void UnterminatedVerifierOutputDoesNotCorruptCleanupReceipt() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-receipt-framing-"); + try + { + var helper = CreateTimedProcessAssembly( + directory.FullName, + "System.Console.Out.Write(\"partial\");"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + ProjectWallTimeMilliseconds = 2000, + TerminationGraceMilliseconds = 1 + }; + + Assert.That(task.Execute(), Is.True); + Assert.That(task.ExitCode, Is.EqualTo(0)); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void OversizedVerifierOutputTriggersPromptBoundedContainment() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-output-limit-"); + try + { + var helper = CreateTimedProcessAssembly( + directory.FullName, + "System.Console.Out.Write(new string('x', " + + (RunVerifier.MaximumCapturedOutputCharacters + 1) + .ToString(CultureInfo.InvariantCulture) + + ")); System.Threading.Thread.Sleep(5000);"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + ProjectWallTimeMilliseconds = 5000, + TerminationGraceMilliseconds = 1000 + }; + var stopwatch = Stopwatch.StartNew(); + + Assert.That(task.Execute(), Is.True); + + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(124)); + Assert.That( + stopwatch.Elapsed, + Is.LessThan(TimeSpan.FromSeconds(3))); + } + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void OversizedOutputWithIncompleteCleanupReturnsPromptly() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-output-limit-retained-"); + try + { + var helper = CreateTimedProcessAssembly( + directory.FullName, + "System.Console.Out.Write(new string('x', " + + (RunVerifier.MaximumCapturedOutputCharacters + 1) + .ToString(CultureInfo.InvariantCulture) + + ")); System.Threading.Thread.Sleep(1500);"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + ProjectWallTimeMilliseconds = 5000, + TerminationGraceMilliseconds = 1, + TryTerminateOverride = static (_, _, _) => false + }; + var stopwatch = Stopwatch.StartNew(); + + Assert.That(task.Execute(), Is.True); + + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(-1)); + Assert.That( + stopwatch.Elapsed, + Is.LessThan(TimeSpan.FromSeconds(1))); + Assert.That( + RunVerifier.RetainedCleanupAnchorCount, + Is.GreaterThan(0)); + } + Assert.That( + SpinWait.SpinUntil( + () => RunVerifier.RetainedCleanupAnchorCount == 0, + TimeSpan.FromSeconds(3)), + Is.True); + } + finally + { + directory.Delete(recursive: true); + } + } + [TestCase("missing")] [TestCase("malformed")] [TestCase("stale-request")] @@ -77,7 +576,7 @@ public void PublishedResultValidatorRejectsAbsentOrStaleEvidence(string kind) public void CanceledVerifierTaskDoesNotLaunchAProcess() { var engine = new RecordingBuildEngine(); - var task = new RunVerifier + using var task = new RunVerifier { BuildEngine = engine, Executable = "dotnet", @@ -100,7 +599,7 @@ public void CanceledVerifierTaskDoesNotLaunchAProcess() public void VerifierWarningsReachTheMsBuildWarningChannel() { var engine = new RecordingBuildEngine(); - var task = new RunVerifier { BuildEngine = engine }; + using var task = new RunVerifier { BuildEngine = engine }; task.LogStandardError( "source.cs(12,3): warning SP0047: incomplete" + Environment.NewLine + @@ -129,7 +628,7 @@ public void VerifierWarningsReachTheMsBuildWarningChannel() public void VerifierDiagnosticGrammarPreservesMarkerLikePathsAndSeverity() { var engine = new RecordingBuildEngine(); - var task = new RunVerifier { BuildEngine = engine }; + using var task = new RunVerifier { BuildEngine = engine }; task.LogStandardError( "/tmp/source: warning SP0047: detail.cs(4,5): warning SP0048: assumptions" + @@ -166,7 +665,7 @@ public void VerifierDiagnosticGrammarPreservesMarkerLikePathsAndSeverity() public void StructuredVerifierDiagnosticsPreserveArbitraryPathText() { var engine = new RecordingBuildEngine(); - var task = new RunVerifier { BuildEngine = engine }; + using var task = new RunVerifier { BuildEngine = engine }; var path = "/tmp/line\nbreak: warning SP0047: (draft), \u03c0.cs"; var warning = VerifierDiagnosticTransport.Serialize( new VerifierDiagnostic( @@ -282,10 +781,11 @@ public void DotNetHostValidationRejectsUntrustedForms() [Test] [Platform("Linux")] + [NonParallelizable] public void VerifierTaskCapturesDotNetOutputAndErrors() { var outputEngine = new RecordingBuildEngine(); - var outputTask = new RunVerifier + using var outputTask = new RunVerifier { BuildEngine = outputEngine, Executable = "dotnet", @@ -293,7 +793,7 @@ public void VerifierTaskCapturesDotNetOutputAndErrors() Arguments = [new TaskItem("--info")] }; var errorEngine = new RecordingBuildEngine(); - var errorTask = new RunVerifier + using var errorTask = new RunVerifier { BuildEngine = errorEngine, Executable = "dotnet", @@ -312,6 +812,448 @@ public void VerifierTaskCapturesDotNetOutputAndErrors() } } + [Test] + [Platform("Linux")] + [NonParallelizable] + public void VerifierTaskBoundsTheWholeLauncherProcess() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-launcher-timeout-"); + try + { + var helper = CreateTimedProcessAssembly(directory.FullName); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + // Let the instrumented supervisor and child finish managed + // startup before exercising the whole-process deadline. + ProjectWallTimeMilliseconds = 2000, + TerminationGraceMilliseconds = 50 + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + Assert.That(task.Execute(), Is.True); + stopwatch.Stop(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(124)); + Assert.That( + stopwatch.Elapsed, + Is.LessThan(TimeSpan.FromSeconds(4))); + } + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void VerifierTaskRejectsOverflowingTimeoutBeforeLaunch() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-launcher-overflow-"); + try + { + var marker = Path.Combine(directory.FullName, "started.txt"); + var helper = CreateTimedProcessAssembly( + directory.FullName, + "System.IO.File.WriteAllText(\"started.txt\", \"started\"); " + + "System.Threading.Thread.Sleep(3000);"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + ProjectWallTimeMilliseconds = int.MaxValue, + TerminationGraceMilliseconds = 1 + }; + + Assert.That(task.Execute(), Is.True); + Thread.Sleep(250); + + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(-1)); + Assert.That(File.Exists(marker), Is.False); + } + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void VerifierTaskUsesOneDeadlineAndStopsOutputHoldingDescendants() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-launcher-descendant-"); + int? descendantId = null; + try + { + var pidPath = Path.Combine(directory.FullName, "descendant.pid"); + var helper = CreateTimedProcessAssembly( + directory.FullName, + "using System.Diagnostics; using System.IO; using System.Threading; " + + "var start = new ProcessStartInfo(\"/bin/sleep\"); " + + "start.ArgumentList.Add(\"10\"); start.UseShellExecute = false; " + + "var child = Process.Start(start)!; " + + "File.WriteAllText(\"descendant.pid\", child.Id.ToString()); " + + "Thread.Sleep(800);"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + // Let the instrumented supervisor and child finish managed + // startup before asserting descendant cleanup behavior. + ProjectWallTimeMilliseconds = 2000, + TerminationGraceMilliseconds = 50 + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + Assert.That(task.Execute(), Is.True); + stopwatch.Stop(); + Assert.That(File.Exists(pidPath), Is.True); + descendantId = int.Parse( + File.ReadAllText(pidPath), + CultureInfo.InvariantCulture); + + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(124)); + Assert.That(stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(4))); + Assert.That( + SpinWait.SpinUntil( + () => !IsProcessRunning(descendantId.Value), + TimeSpan.FromSeconds(1)), + Is.True); + } + } + finally + { + if (descendantId.HasValue && IsProcessRunning(descendantId.Value)) + { + Process.GetProcessById(descendantId.Value).Kill(entireProcessTree: true); + } + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void VerifierSupervisorStopsSessionEscapingDescendants() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-launcher-daemon-"); + int? descendantId = null; + try + { + var pidPath = Path.Combine(directory.FullName, "daemon.pid"); + var helper = CreateTimedProcessAssembly( + directory.FullName, + "using System.Diagnostics; using System.Threading; " + + "var start = new ProcessStartInfo(\"/usr/bin/setsid\"); " + + "start.ArgumentList.Add(\"/bin/sh\"); " + + "start.ArgumentList.Add(\"-c\"); " + + "start.ArgumentList.Add(\"exec >/dev/null 2>&1; echo $$ > daemon.pid; exec sleep 10\"); " + + "start.UseShellExecute = false; Process.Start(start); " + + "var wait = Stopwatch.StartNew(); " + + "while (!System.IO.File.Exists(\"daemon.pid\") && wait.ElapsedMilliseconds < 500) Thread.Sleep(1);"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + ProjectWallTimeMilliseconds = 1000, + TerminationGraceMilliseconds = 1 + }; + + Assert.That(task.Execute(), Is.True); + Assert.That(File.Exists(pidPath), Is.True); + descendantId = int.Parse( + File.ReadAllText(pidPath), + CultureInfo.InvariantCulture); + + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(124)); + Assert.That( + SpinWait.SpinUntil( + () => !IsProcessRunning(descendantId.Value), + TimeSpan.FromSeconds(1)), + Is.True); + } + } + finally + { + if (descendantId.HasValue && IsProcessRunning(descendantId.Value)) + { + Process.GetProcessById(descendantId.Value) + .Kill(entireProcessTree: true); + } + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void VerifierSupervisorReportsBoundedCleanupFailure() + { + using var descendant = Process.Start("/bin/sleep", "10"); + Assert.That(descendant, Is.Not.Null); + try + { + var stopwatch = Stopwatch.StartNew(); + var cleanup = VerifierProcessSupervisor.StopDescendants( + Environment.ProcessId, + 25, + static _ => -1); + stopwatch.Stop(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(cleanup.HadDescendants, Is.True); + Assert.That(cleanup.Complete, Is.False); + Assert.That( + stopwatch.Elapsed, + Is.LessThan(TimeSpan.FromSeconds(1))); + } + } + finally + { + if (descendant is { HasExited: false }) + { + descendant.Kill(entireProcessTree: true); + descendant.WaitForExit(); + } + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void RetainedCleanupAnchorRemainsOwnedUntilExit() + { + var process = Process.Start("/bin/sleep", "0.2"); + Assert.That(process, Is.Not.Null); + RunVerifier.RetainCleanupAnchorForTest(process!); + + Assert.That(RunVerifier.RetainedCleanupAnchorCount, Is.GreaterThan(0)); + Assert.That( + SpinWait.SpinUntil( + () => RunVerifier.RetainedCleanupAnchorCount == 0, + TimeSpan.FromSeconds(2)), + Is.True); + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void VerifierExecutionRetainsLiveIncompleteCleanupAnchor() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-retained-cleanup-"); + try + { + var helper = CreateTimedProcessAssembly( + directory.FullName, + "using System.Threading; Thread.Sleep(1500);"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + ProjectWallTimeMilliseconds = 10, + TerminationGraceMilliseconds = 1, + TryTerminateOverride = static (_, _, _) => false + }; + + Assert.That(task.Execute(), Is.True); + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(-1)); + Assert.That( + RunVerifier.RetainedCleanupAnchorCount, + Is.GreaterThan(0)); + } + Assert.That( + SpinWait.SpinUntil( + () => RunVerifier.RetainedCleanupAnchorCount == 0, + TimeSpan.FromSeconds(3)), + Is.True); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public async System.Threading.Tasks.Task CancellationInterruptsForegroundWait() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-cancel-wait-"); + try + { + var helper = CreateTimedProcessAssembly( + directory.FullName, + "using System.Threading; Thread.Sleep(1500);"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + ProjectWallTimeMilliseconds = 300000, + TerminationGraceMilliseconds = 1, + TryTerminateOverride = static (_, _, _) => false + }; + var execution = System.Threading.Tasks.Task.Run(task.Execute); + Assert.That( + SpinWait.SpinUntil( + () => task.HasActiveProcess, + TimeSpan.FromSeconds(2)), + Is.True); + + task.Cancel(); + + Assert.That( + await execution.WaitAsync(TimeSpan.FromSeconds(2)), + Is.True); + Assert.That(task.ExitCode, Is.EqualTo(-1)); + Assert.That( + SpinWait.SpinUntil( + () => RunVerifier.RetainedCleanupAnchorCount == 0, + TimeSpan.FromSeconds(3)), + Is.True); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void SupervisorContainsVerifierThatKillsItsImmediateParent() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-supervisor-anchor-"); + int? descendantId = null; + try + { + var pidPath = Path.Combine(directory.FullName, "daemon.pid"); + var helper = CreateTimedProcessAssembly( + directory.FullName, + "using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; " + + "var start = new ProcessStartInfo(\"/usr/bin/setsid\"); " + + "start.ArgumentList.Add(\"/bin/sh\"); start.ArgumentList.Add(\"-c\"); " + + "start.ArgumentList.Add(\"exec >/dev/null 2>&1; echo $$ > daemon.pid; exec sleep 10\"); " + + "start.UseShellExecute = false; Process.Start(start); " + + "var wait = Stopwatch.StartNew(); while (!System.IO.File.Exists(\"daemon.pid\") && wait.ElapsedMilliseconds < 500) Thread.Sleep(1); " + + "Native.Kill(Native.GetParent(), 9); Thread.Sleep(1000); " + + "internal static class Native { [DllImport(\"libc\", EntryPoint=\"getppid\")] internal static extern int GetParent(); [DllImport(\"libc\", EntryPoint=\"kill\")] internal static extern int Kill(int processId, int signal); }"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + ProjectWallTimeMilliseconds = 2000, + TerminationGraceMilliseconds = 1 + }; + + Assert.That(task.Execute(), Is.True); + Assert.That(File.Exists(pidPath), Is.True); + descendantId = int.Parse( + File.ReadAllText(pidPath), + CultureInfo.InvariantCulture); + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(124)); + Assert.That( + SpinWait.SpinUntil( + () => !IsProcessRunning(descendantId.Value), + TimeSpan.FromSeconds(1)), + Is.True); + } + } + finally + { + if (descendantId.HasValue && IsProcessRunning(descendantId.Value)) + { + Process.GetProcessById(descendantId.Value) + .Kill(entireProcessTree: true); + } + directory.Delete(recursive: true); + } + } + + [Test] + [Platform("Linux")] + [NonParallelizable] + public void VerifierTaskDoesNotReleaseCommandBeforePidFdAcquisition() + { + var directory = Directory.CreateTempSubdirectory( + "sharpproof-launcher-gate-"); + try + { + var marker = Path.Combine(directory.FullName, "started.txt"); + var helper = CreateTimedProcessAssembly( + directory.FullName, + "using System.IO; File.WriteAllText(\"started.txt\", \"started\");"); + using var task = new RunVerifier + { + BuildEngine = new RecordingBuildEngine(), + Executable = Environment.GetEnvironmentVariable( + "DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = directory.FullName, + Arguments = [new TaskItem(helper)], + OpenPidFdOverride = static _ => + throw new InvalidOperationException("forced pidfd failure") + }; + + Assert.That(task.Execute(), Is.True); + + using (Assert.EnterMultipleScope()) + { + Assert.That(task.ExitCode, Is.EqualTo(-1)); + Assert.That(File.Exists(marker), Is.False); + Assert.That(task.HasActiveProcess, Is.False); + } + } + finally + { + directory.Delete(recursive: true); + } + } + [Test] public void CanceledInvalidationDoesNotMutate() { @@ -327,13 +1269,15 @@ public void CanceledInvalidationDoesNotMutate() [Test] [Platform("Linux")] + [NonParallelizable] public async System.Threading.Tasks.Task ActiveVerifierTaskCancellationStopsTheProcess() { var directory = Directory.CreateTempSubdirectory("sharpproof-cancel-"); try { var helper = CreateTimedProcessAssembly(directory.FullName); - var task = new RunVerifier + var containmentFailure = string.Empty; + using var task = new RunVerifier { BuildEngine = new RecordingBuildEngine(), Executable = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet", @@ -341,7 +1285,9 @@ public async System.Threading.Tasks.Task ActiveVerifierTaskCancellationStopsTheP Arguments = [ new TaskItem(helper) - ] + ], + ContainmentAuthenticationFailureOverride = message => + containmentFailure = message }; var execution = System.Threading.Tasks.Task.Run(task.Execute); @@ -364,6 +1310,7 @@ public async System.Threading.Tasks.Task ActiveVerifierTaskCancellationStopsTheP Assert.That(canceledPromptly, Is.True); Assert.That(await execution, Is.True); Assert.That(task.ExitCode, Is.Not.Zero); + Assert.That(containmentFailure, Is.Empty); } } finally @@ -372,11 +1319,12 @@ public async System.Threading.Tasks.Task ActiveVerifierTaskCancellationStopsTheP } } - private static string CreateTimedProcessAssembly(string directory) + private static string CreateTimedProcessAssembly( + string directory, + string source = "using System.Threading; Thread.Sleep(3000);") { var assemblyPath = Path.Combine(directory, "TimedProcess.dll"); - var syntaxTree = CSharpSyntaxTree.ParseText( - "using System.Threading; Thread.Sleep(3000);"); + var syntaxTree = CSharpSyntaxTree.ParseText(source); var trustedPlatformAssemblies = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? throw new InvalidOperationException( @@ -413,6 +1361,36 @@ private static string CreateTimedProcessAssembly(string directory) return assemblyPath; } + private static bool IsProcessRunning(int processId) + { + try + { + if (OperatingSystem.IsLinux()) + { + var stat = File.ReadAllText( + $"/proc/{processId.ToString(CultureInfo.InvariantCulture)}/stat"); + var commandEnd = stat.LastIndexOf(')'); + return commandEnd < 0 || + commandEnd + 2 >= stat.Length || + stat[commandEnd + 2] != 'Z'; + } + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (FileNotFoundException) + { + return false; + } + catch (DirectoryNotFoundException) + { + return false; + } + } + [Platform("Linux")] [TestCase("cache-below-output")] [TestCase("output-below-cache")] @@ -917,4 +1895,34 @@ public bool BuildProjectFile( return false; } } + + private sealed class GatedTextReader(string initialText) : TextReader + { + private readonly System.Threading.Tasks.TaskCompletionSource + completion = new( + System.Threading.Tasks.TaskCreationOptions + .RunContinuationsAsynchronously); + private int position; + + public void Complete() + { + completion.TrySetResult(true); + } + + public override async System.Threading.Tasks.Task ReadAsync( + char[] buffer, + int index, + int count) + { + if (position < initialText.Length) + { + var copied = Math.Min(count, initialText.Length - position); + initialText.CopyTo(position, buffer, index, copied); + position += copied; + return copied; + } + await completion.Task.ConfigureAwait(false); + return 0; + } + } } diff --git a/SharpProof.Package.Test/FinalCompilationProbeTests.cs b/SharpProof.Package.Test/FinalCompilationProbeTests.cs index 9e831c81e..d4c090a8f 100644 --- a/SharpProof.Package.Test/FinalCompilationProbeTests.cs +++ b/SharpProof.Package.Test/FinalCompilationProbeTests.cs @@ -717,6 +717,7 @@ internal Task RebuildAsync( internal Task VerifyPackedArtifactAsync() { + var invocationId = Guid.NewGuid().ToString("N"); var runDirectory = Path.Combine(_root, "verify-run"); var publishDirectory = Path.Combine(_root, "published"); Directory.CreateDirectory(runDirectory); @@ -738,11 +739,7 @@ internal Task VerifyPackedArtifactAsync() "-p:SharpProofVerify=true", "-p:_SharpProofCompilerManifestPath=" + invocationManifestPath, - "-p:_SharpProofInvocationDirectory=" + runDirectory, - "-p:_SharpProofInvocationRequestFile=" + - Path.Combine(runDirectory, "request.json"), - "-p:_SharpProofInvocationResultFile=" + - Path.Combine(runDirectory, "result.json"), + "-p:_SharpProofInvocationId=" + invocationId, "-p:SharpProofVerifyRequestFile=" + Path.Combine(publishDirectory, "request.json"), "-p:SharpProofVerifyResultFile=" + diff --git a/SharpProof.Package.Test/LauncherArgumentTests.cs b/SharpProof.Package.Test/LauncherArgumentTests.cs index efba6be2f..b63414f9c 100644 --- a/SharpProof.Package.Test/LauncherArgumentTests.cs +++ b/SharpProof.Package.Test/LauncherArgumentTests.cs @@ -7,6 +7,7 @@ using SharpProof.Worker; using SharpProof.Worker.Launcher; using SharpProof.Worker.Protocol; +using Program = SharpProof.Worker.Launcher.Program; namespace SharpProof.Package.Test; @@ -961,6 +962,40 @@ public void CompilerManifestByteLimitIsEnforcedBeforeAllocation() } } + [Test] + [Platform("Linux")] + public void CompilerManifestFifoIsRejectedBeforeBlockingOpen() + { + var path = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + Guid.NewGuid().ToString("N") + ".fifo"); + try + { + using var process = System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo + { + FileName = "mkfifo", + UseShellExecute = false, + ArgumentList = { path } + })!; + process.WaitForExit(); + Assert.That(process.ExitCode, Is.Zero); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + Assert.That( + (Action)(() => LauncherArguments.ReadCompilerManifest(path)), + Throws.TypeOf()); + stopwatch.Stop(); + Assert.That( + stopwatch.Elapsed, + Is.LessThan(TimeSpan.FromSeconds(1))); + } + finally + { + File.Delete(path); + } + } + [Test] public void WorkerResultByteLimitIsEnforcedBeforeDeserialization() { diff --git a/SharpProof.Package.Test/PackageLayoutSmokeTests.cs b/SharpProof.Package.Test/PackageLayoutSmokeTests.cs index 47ba5486c..126eea06f 100644 --- a/SharpProof.Package.Test/PackageLayoutSmokeTests.cs +++ b/SharpProof.Package.Test/PackageLayoutSmokeTests.cs @@ -105,7 +105,9 @@ public sealed class PackageLayoutSmokeTests private static readonly string[] ExpectedToolEntries = [ "tools/net9/Microsoft.Z3.dll", + "tools/net9/SharpProof.BuildTasks.deps.json", "tools/net9/SharpProof.BuildTasks.dll", + "tools/net9/SharpProof.BuildTasks.runtimeconfig.json", "tools/net9/SharpProof.CompilerArtifact.dll", "tools/net9/SharpProof.Dataflow.dll", "tools/net9/SharpProof.Host.dll", @@ -761,6 +763,33 @@ await AssertSourceConsumerAnalyzerItemsAsync( ("_SharpProofVerifierHostSupported", "false")); } + [TestCase( + "SharpProofMode", + "contracts", + "SharpProofMode was removed before preview.1")] + [TestCase( + "SharpProofPortableAnalyzerPath", + "legacy.dll", + "SharpProofPortableAnalyzerPath was removed before preview.1")] + public async Task SourceConsumerRejectsRetiredConfiguration( + string property, + string value, + string expectedMessage) + { + using var workspace = PackageWorkspace.Create(); + workspace.WriteSourceConsumerEvaluationProject((property, value)); + + var validation = await RunDotNetAsync( + workspace.ConsumerDirectory, + "msbuild", + workspace.ConsumerProject, + "-target:_SharpProofValidateSourceTreeConfiguration", + "--nologo"); + + Assert.That(validation.ExitCode, Is.Not.Zero, validation.Output); + Assert.That(validation.Output, Does.Contain(expectedMessage)); + } + [TestCase("netstandard2.0")] [TestCase("net472")] public async Task PortablePackageBuildsFrameworkConsumerFromIsolatedFeed( @@ -913,7 +942,7 @@ await File.ReadAllTextAsync( manifest.RootElement .GetProperty("schemaVersion") .GetInt32(), - Is.EqualTo(14)); + Is.EqualTo(15)); var effectClaims = manifest.RootElement .GetProperty("callables") .EnumerateArray() diff --git a/SharpProof.Package.Test/WorkerMsBuildIntegrationTests.cs b/SharpProof.Package.Test/WorkerMsBuildIntegrationTests.cs index 2c36b8b6a..90b71835b 100644 --- a/SharpProof.Package.Test/WorkerMsBuildIntegrationTests.cs +++ b/SharpProof.Package.Test/WorkerMsBuildIntegrationTests.cs @@ -1780,7 +1780,7 @@ public async Task InvocationRunRootIsCleanedOnPrelaunchAndLaunchFailure() ("_SharpProofPackageNativeZ3Path", nativeZ3Path)); Assert.That(baseline.ExitCode, Is.Zero, baseline.Output); - var prelaunchId = "prelaunch-" + Guid.NewGuid().ToString("N"); + var prelaunchId = Guid.NewGuid().ToString("N"); var prelaunchRoot = project.CreateInvocationRunRoot(prelaunchId); var prelaunch = await project.RunVerificationTargetWithInvocationIdAsync( prelaunchId, @@ -1797,7 +1797,7 @@ public async Task InvocationRunRootIsCleanedOnPrelaunchAndLaunchFailure() prelaunch.Output); } - var launchFailureId = "launch-" + Guid.NewGuid().ToString("N"); + var launchFailureId = Guid.NewGuid().ToString("N"); var launchFailureRoot = project.CreateInvocationRunRoot(launchFailureId); await File.WriteAllTextAsync( Path.Combine(launchFailureRoot, "compiler-manifest.json"), @@ -1824,6 +1824,67 @@ await project.RunVerificationTargetWithInvocationIdAsync( } } + [Test] + [SupportedOSPlatform("linux")] + public async Task NoncanonicalInvocationIdCannotEscapeRunsRoot() + { + RequireContainerWorker(); + using var project = ConsumerProject.Create(IdentitySource); + var nativeZ3Path = ContainerContract.ResolveZ3LibraryRequired(); + var baseline = await project.BuildAsync( + verify: false, + ("_SharpProofPackageNativeZ3Path", nativeZ3Path)); + Assert.That(baseline.ExitCode, Is.Zero, baseline.Output); + + var sentinelDirectory = Path.Combine( + project.Root, + "invocation-escape-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(sentinelDirectory); + var sentinel = Path.Combine(sentinelDirectory, "harmless-sentinel.txt"); + await File.WriteAllTextAsync(sentinel, "preserve"); + var traversalId = Path.GetRelativePath( + project.InvocationRunsDirectory, + sentinelDirectory) + .Replace(Path.DirectorySeparatorChar, '/'); + + var result = await project.RunVerificationTargetWithInvocationIdAsync( + traversalId, + ("_SharpProofPackageNativeZ3Path", nativeZ3Path), + ("_SharpProofInvocationIdIsSafe", "True"), + ("_SharpProofInvocationDirectoryIsContained", "True"), + ("_SharpProofCleanupInvocationIdIsSafe", "True"), + ("_SharpProofCleanupInvocationDirectoryIsContained", "True"), + ("SharpProofVerifyPolicy", "invalid")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.ExitCode, Is.Not.Zero, result.Output); + Assert.That( + result.Output, + Does.Contain("invocation ID must be an exact safe identifier")); + Assert.That(Directory.Exists(sentinelDirectory), Is.True, + result.Output); + Assert.That(File.Exists(sentinel), Is.True, result.Output); + } + + var safeId = Guid.NewGuid().ToString("N"); + var noncontained = await project.RunCleanupTargetAsync( + safeId, + sentinelDirectory); + + using (Assert.EnterMultipleScope()) + { + Assert.That(noncontained.ExitCode, Is.Not.Zero, noncontained.Output); + Assert.That( + noncontained.Output, + Does.Contain( + "invocation cleanup directory must resolve canonically")); + Assert.That(Directory.Exists(sentinelDirectory), Is.True, + noncontained.Output); + Assert.That(File.Exists(sentinel), Is.True, noncontained.Output); + } + } + [Test] [SupportedOSPlatform("linux")] public async Task InvocationCleanupFailurePreservesDiagnosticAndRecovers() @@ -1836,7 +1897,7 @@ public async Task InvocationCleanupFailurePreservesDiagnosticAndRecovers() ("_SharpProofPackageNativeZ3Path", nativeZ3Path)); Assert.That(baseline.ExitCode, Is.Zero, baseline.Output); - var invocationId = "cleanup-failure-" + Guid.NewGuid().ToString("N"); + var invocationId = Guid.NewGuid().ToString("N"); var invocationRoot = project.CreateInvocationRunRoot(invocationId); var runsMode = File.GetUnixFileMode(project.InvocationRunsDirectory); File.SetUnixFileMode( @@ -1865,7 +1926,7 @@ public async Task InvocationCleanupFailurePreservesDiagnosticAndRecovers() failure.Output); } - var recovery = await project.RunCleanupTargetAsync(invocationRoot); + var recovery = await project.RunCleanupTargetAsync(invocationId); using (Assert.EnterMultipleScope()) { Assert.That(recovery.ExitCode, Is.Zero, recovery.Output); @@ -1919,9 +1980,8 @@ public async Task PublicationFailureLeavesStableResultAbsent() File.Delete(sarifPath); var publicationDirectory = Path.GetDirectoryName( project.RequestPath)!; - var invocationDirectory = Path.Combine( - project.Root, - "publication-failure-invocation"); + var invocationId = Guid.NewGuid().ToString("N"); + var invocationDirectory = project.InvocationRunRoot(invocationId); Directory.CreateDirectory(invocationDirectory); File.SetUnixFileMode( publicationDirectory, @@ -1929,14 +1989,8 @@ public async Task PublicationFailureLeavesStableResultAbsent() BuildResult failed; try { - failed = await project.RunVerificationTargetAsync( - ("_SharpProofInvocationDirectory", invocationDirectory), - ("_SharpProofInvocationRequestFile", Path.Combine( - invocationDirectory, - "request.json")), - ("_SharpProofInvocationResultFile", Path.Combine( - invocationDirectory, - "result.json")), + failed = await project.RunVerificationTargetWithInvocationIdAsync( + invocationId, ("_SharpProofCompilerManifestPath", failedInvocationManifest), ("SharpProofCompilerManifestFile", failedManifestPath), ("SharpProofVerifyPolicy", "advisory"), @@ -2833,6 +2887,9 @@ public void CompilerManifestPropertiesAreVisibleBeforeEditorConfigGeneration() .Single(static onError => onError.Attribute("ExecuteTargets")?.Value == "_SharpProofCleanupInvocation"); + var cleanupElements = cleanup.Elements().ToList(); + var cleanupRemove = cleanup.Elements("RemoveDir").Single(); + var cleanupSafetyErrors = cleanup.Elements("Error").ToArray(); var verifyCoreElements = verifyCore.Elements().ToList(); var runnerTask = targets.Descendants("UsingTask") .Single(static task => task.Attribute("TaskName")?.Value == @@ -2902,11 +2959,27 @@ public void CompilerManifestPropertiesAreVisibleBeforeEditorConfigGeneration() Is.True); Assert.That( cleanup.Attribute("Condition")?.Value, - Is.EqualTo("'$(_SharpProofInvocationDirectory)' != ''")); + Is.EqualTo("'$(_SharpProofInvocationId)' != ''")); + Assert.That( + cleanupRemove.Attribute("Directories")?.Value, + Is.EqualTo("$(_SharpProofCleanupInvocationDirectoryFullPath)")); + Assert.That( + cleanupSafetyErrors.Count(static error => + error.Attribute("Text")?.Value.Contains( + "exact safe identifier", + StringComparison.Ordinal) == true), + Is.EqualTo(1)); Assert.That( - cleanup.Descendants("RemoveDir").Single() - .Attribute("Directories")?.Value, - Is.EqualTo("$(_SharpProofInvocationDirectory)")); + cleanupSafetyErrors.Count(static error => + error.Attribute("Text")?.Value.Contains( + "resolve canonically", + StringComparison.Ordinal) == true), + Is.EqualTo(1)); + Assert.That( + cleanupSafetyErrors.Select(error => + cleanupElements.IndexOf(error)), + Is.All.LessThan(cleanupElements.IndexOf(cleanupRemove)), + "Cleanup validation must run before RemoveDir."); Assert.That( cleanupCall.Attribute("Condition"), Is.Null, @@ -3345,7 +3418,7 @@ [.. compilation.GetProperty("references").EnumerateArray() Is.EqualTo("SharpProof.CompilerManifest")); Assert.That( root.GetProperty("schemaVersion").GetInt32(), - Is.EqualTo(14)); + Is.EqualTo(15)); Assert.That( root.GetProperty("protocolVersion").GetString(), Is.EqualTo(WorkerProtocolVersions.Current)); @@ -3848,14 +3921,7 @@ internal Task BuildIsolatedAsync( internal Task RunVerificationTargetAsync( params (string Name, string Value)[] properties) { - var invocationDirectory = Path.Combine( - _root, - "obj", - "Release", - "net8.0", - "SharpProof", - "runs", - "direct-" + Guid.NewGuid().ToString("N")); + var invocationId = Guid.NewGuid().ToString("N"); var arguments = new List { "msbuild", ProjectPath, @@ -3875,12 +3941,7 @@ internal Task RunVerificationTargetAsync( "net8.0", "SharpProof", "cache"), - "-p:_SharpProofInvocationDirectory=" + - invocationDirectory, - "-p:_SharpProofInvocationRequestFile=" + - Path.Combine(invocationDirectory, "request.json"), - "-p:_SharpProofInvocationResultFile=" + - Path.Combine(invocationDirectory, "result.json") + "-p:_SharpProofInvocationId=" + invocationId }; arguments.AddRange(properties.Select(static property => "-p:" + property.Name + "=" + property.Value)); @@ -3918,16 +3979,25 @@ internal Task RunVerificationTargetWithInvocationIdAsync( } internal Task RunCleanupTargetAsync( - string invocationRoot) + string invocationId, + string? invocationDirectory = null) { - return RunDotNetAsync([ + var arguments = new List { "msbuild", ProjectPath, "/t:_SharpProofCleanupInvocation", "/nologo", "/nodeReuse:false", - "-p:_SharpProofInvocationDirectory=" + invocationRoot - ]); + "-p:Configuration=Release", + "-p:TargetFramework=net8.0", + "-p:_SharpProofInvocationId=" + invocationId + }; + if (!string.IsNullOrEmpty(invocationDirectory)) + { + arguments.Add( + "-p:_SharpProofInvocationDirectory=" + invocationDirectory); + } + return RunDotNetAsync(arguments); } internal Task RunNonBuildingInitializationAsync( diff --git a/SharpProof.Package/buildTransitive/SharpProof.targets b/SharpProof.Package/buildTransitive/SharpProof.targets index e31f9a749..2d1a6a793 100644 --- a/SharpProof.Package/buildTransitive/SharpProof.targets +++ b/SharpProof.Package/buildTransitive/SharpProof.targets @@ -10,8 +10,8 @@ $([System.IO.Path]::GetFullPath('$(SharpProofCompilerCollectorPath)')) advisory all - <_SharpProofProfileNormalized>$([System.String]::Copy('$(SharpProofProfile)').ToLowerInvariant()) - <_SharpProofFeaturesNormalized>$([System.String]::Copy('$(SharpProofFeatures)').ToLowerInvariant()) + <_SharpProofProfileNormalized>$([System.String]::Copy('$(SharpProofProfile)').Trim().ToLowerInvariant()) + <_SharpProofFeaturesNormalized>$([System.String]::Copy('$(SharpProofFeatures)').Trim().ToLowerInvariant()) true false <_SharpProofContractsRuntimeEnabled diff --git a/SharpProof.Specs/FrameworkTypeMetadataNames.cs b/SharpProof.Specs/FrameworkTypeMetadataNames.cs index 616cbcfef..73a01782f 100644 --- a/SharpProof.Specs/FrameworkTypeMetadataNames.cs +++ b/SharpProof.Specs/FrameworkTypeMetadataNames.cs @@ -31,4 +31,8 @@ public static class FrameworkTypeMetadataNames "System.NullReferenceException"; public const string OverflowException = "System.OverflowException"; public const string ReferenceAssemblyAttribute = "System.Runtime.CompilerServices.ReferenceAssemblyAttribute"; + public const string SwitchExpressionException = + "System.Runtime.CompilerServices.SwitchExpressionException"; + public const string TypeInitializationException = + "System.TypeInitializationException"; } diff --git a/SharpProof.Verifier/SharpProof.Verifier.nuspec b/SharpProof.Verifier/SharpProof.Verifier.nuspec index 9ffa888b7..b754601ef 100644 --- a/SharpProof.Verifier/SharpProof.Verifier.nuspec +++ b/SharpProof.Verifier/SharpProof.Verifier.nuspec @@ -44,6 +44,8 @@ + + diff --git a/SharpProof.Verifier/buildTransitive/SharpProof.Verifier.targets b/SharpProof.Verifier/buildTransitive/SharpProof.Verifier.targets index 4c3710020..143959dbf 100644 --- a/SharpProof.Verifier/buildTransitive/SharpProof.Verifier.targets +++ b/SharpProof.Verifier/buildTransitive/SharpProof.Verifier.targets @@ -1,4 +1,4 @@ - + <_SharpProofToolsDirectory>$([System.IO.Path]::GetFullPath('$(SharpProofToolsDirectory)')) <_SharpProofWorkerPathConfigured>$([System.String]::Copy('$(SharpProofWorkerPath)').Trim()) @@ -54,11 +54,23 @@ <_SharpProofEffectiveCacheDirectory Condition="'$(_SharpProofVerifyCacheDirectoryNormalized)' == ''">$([System.IO.Path]::Combine('$(_SharpProofVerifyDirectory)', 'cache')) <_SharpProofEffectiveCacheDirectory Condition="'$(_SharpProofVerifyCacheDirectoryNormalized)' != ''">$(SharpProofVerifyCacheDirectory) <_SharpProofActiveCacheDirectory Condition="'$(_SharpProofVerifyCacheEnabledNormalized)' == 'true'">$(_SharpProofEffectiveCacheDirectory) + <_SharpProofInvocationRunsDirectory>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(_SharpProofVerifyDirectory)', 'runs')))) <_SharpProofInvocationId Condition="'$(_SharpProofInvocationId)' == ''">$([System.Guid]::NewGuid().ToString('N')) - <_SharpProofInvocationDirectory>$([System.IO.Path]::Combine('$(_SharpProofVerifyDirectory)', 'runs', '$(_SharpProofInvocationId)')) + <_SharpProofInvocationIdIsSafe>$([System.Text.RegularExpressions.Regex]::IsMatch('$(_SharpProofInvocationId)', '\A[0-9a-f]{32}\z')) + <_SharpProofExpectedInvocationDirectory>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(_SharpProofInvocationRunsDirectory)', '$(_SharpProofInvocationId)')))) + <_SharpProofInvocationDirectory>$(_SharpProofExpectedInvocationDirectory) + <_SharpProofInvocationDirectoryFullPath>$([System.IO.Path]::GetFullPath('$(_SharpProofInvocationDirectory)')) + <_SharpProofInvocationDirectoryIsCanonical>$([System.String]::Equals('$(_SharpProofInvocationDirectory)', '$(_SharpProofInvocationDirectoryFullPath)', System.StringComparison.Ordinal)) + <_SharpProofInvocationDirectoryMatchesExpected>$([System.String]::Equals('$(_SharpProofInvocationDirectoryFullPath)', '$(_SharpProofExpectedInvocationDirectory)', System.StringComparison.Ordinal)) + <_SharpProofInvocationDirectoryParent>$([System.IO.Path]::GetDirectoryName('$(_SharpProofInvocationDirectoryFullPath)')) + <_SharpProofInvocationDirectoryIsContained>$([System.String]::Equals('$(_SharpProofInvocationDirectoryParent)', '$(_SharpProofInvocationRunsDirectory)', System.StringComparison.Ordinal)) <_SharpProofInvocationRequestFile>$([System.IO.Path]::Combine('$(_SharpProofInvocationDirectory)', 'request.json')) <_SharpProofInvocationResultFile>$([System.IO.Path]::Combine('$(_SharpProofInvocationDirectory)', 'result.json')) + + <_SharpProofCompilationTargetFramework>$(TargetFramework) <_SharpProofCompilerManifestPath>$([System.IO.Path]::Combine('$(_SharpProofInvocationDirectory)', 'compiler-manifest.json')) @@ -103,8 +115,24 @@ - + Condition="'$(_SharpProofInvocationId)' != ''"> + + <_SharpProofCleanupVerifyDirectory>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)SharpProof')))) + <_SharpProofCleanupRunsDirectory>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(_SharpProofCleanupVerifyDirectory)', 'runs')))) + <_SharpProofCleanupInvocationIdIsSafe>$([System.Text.RegularExpressions.Regex]::IsMatch('$(_SharpProofInvocationId)', '\A[0-9a-f]{32}\z')) + <_SharpProofCleanupExpectedInvocationDirectory>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(_SharpProofCleanupRunsDirectory)', '$(_SharpProofInvocationId)')))) + <_SharpProofInvocationDirectory Condition="'$(_SharpProofInvocationDirectory)' == ''">$(_SharpProofCleanupExpectedInvocationDirectory) + <_SharpProofCleanupInvocationDirectoryFullPath>$([System.IO.Path]::GetFullPath('$(_SharpProofInvocationDirectory)')) + <_SharpProofCleanupInvocationDirectoryIsCanonical>$([System.String]::Equals('$(_SharpProofInvocationDirectory)', '$(_SharpProofCleanupInvocationDirectoryFullPath)', System.StringComparison.Ordinal)) + <_SharpProofCleanupInvocationDirectoryMatchesExpected>$([System.String]::Equals('$(_SharpProofCleanupInvocationDirectoryFullPath)', '$(_SharpProofCleanupExpectedInvocationDirectory)', System.StringComparison.Ordinal)) + <_SharpProofCleanupInvocationDirectoryParent>$([System.IO.Path]::GetDirectoryName('$(_SharpProofCleanupInvocationDirectoryFullPath)')) + <_SharpProofCleanupInvocationDirectoryIsContained>$([System.String]::Equals('$(_SharpProofCleanupInvocationDirectoryParent)', '$(_SharpProofCleanupRunsDirectory)', System.StringComparison.Ordinal)) + + + + + WorkingDirectory="$(MSBuildProjectDirectory)" + ProjectWallTimeMilliseconds="$(SharpProofVerifyProjectWallTimeMilliseconds)" + TerminationGraceMilliseconds="$(SharpProofVerifyTerminationGraceMilliseconds)"> ((Action)(() => CompilerManifestArtifactJson.DecodeCallables(parameterSwap))); + + var pairedSwap = CreateContractArtifact(parameterSource); + bindings = pairedSwap.Callables[0].Body!.ParameterBindings; + (bindings[0].Target, bindings[1].Target) = + (bindings[1].Target, bindings[0].Target); + (bindings[0].SourceOrdinal, bindings[1].SourceOrdinal) = + (bindings[1].SourceOrdinal, bindings[0].SourceOrdinal); + Assert.Throws((Action)(() => + CompilerManifestArtifactJson.Serialize(pairedSwap))); } [Test] @@ -2107,6 +2117,17 @@ internal static int Sum(int left, int right) { (preStates[1].CurrentStateVariable, preStates[0].CurrentStateVariable); Assert.Throws((Action)(() => CompilerManifestArtifactJson.DecodeCallables(preStateSwap))); + + var pairedSwap = CreateContractArtifact(preStateSource); + preStates = pairedSwap.Callables[0].Variables + .Where(static item => item.Role == CompilerVariableRole.PreState) + .ToArray(); + (preStates[0].CurrentStateVariable, preStates[1].CurrentStateVariable) = + (preStates[1].CurrentStateVariable, preStates[0].CurrentStateVariable); + (preStates[0].SourceOrdinal, preStates[1].SourceOrdinal) = + (preStates[1].SourceOrdinal, preStates[0].SourceOrdinal); + Assert.Throws((Action)(() => + CompilerManifestArtifactJson.Serialize(pairedSwap))); } [Test] @@ -2146,8 +2167,7 @@ internal static int Call(int value) { artifact.Callables[0].Body!.SummaryCalls, Has.Length.EqualTo(1)); corrupt(artifact.Callables[0]); - var resealed = CompilerManifestArtifactJson.Deserialize( - CompilerManifestArtifactJson.Serialize(artifact)); + var resealed = CanonicalRoundTrip(artifact); Assert.Throws((Action)(() => CompilerManifestArtifactJson.DecodeCallables(resealed))); @@ -2184,8 +2204,7 @@ internal static bool Call(bool left, bool right) { { var artifact = CreateContractArtifact(source); corrupt(artifact.Callables[0]); - var resealed = CompilerManifestArtifactJson.Deserialize( - CompilerManifestArtifactJson.Serialize(artifact)); + var resealed = CanonicalRoundTrip(artifact); Assert.Throws((Action)(() => CompilerManifestArtifactJson.DecodeCallables(resealed))); @@ -2210,8 +2229,7 @@ internal static int Call(int value, bool flag) { var artifact = CreateContractArtifact(source); var call = FindCall(artifact.Callables[0]); (call.Items[0], call.Items[1]) = (call.Items[1], call.Items[0]); - var resealed = CompilerManifestArtifactJson.Deserialize( - CompilerManifestArtifactJson.Serialize(artifact)); + var resealed = CanonicalRoundTrip(artifact); Assert.Throws((Action)(() => CompilerManifestArtifactJson.DecodeCallables(resealed))); @@ -2244,8 +2262,7 @@ internal static bool Call(Box box, bool value) { call.C = Array.FindIndex(callable.Graph.Terms, value => value.Kind == IrTermKind.Variable && value.A == boxIndex); Assert.That(call.C, Is.GreaterThanOrEqualTo(0)); - var resealed = CompilerManifestArtifactJson.Deserialize( - CompilerManifestArtifactJson.Serialize(artifact)); + var resealed = CanonicalRoundTrip(artifact); Assert.Throws((Action)(() => CompilerManifestArtifactJson.DecodeCallables(resealed))); @@ -2282,8 +2299,7 @@ internal static int Call(int value) { { var artifact = CreateContractArtifact(source); corrupt(artifact.Callables.Single()); - var resealed = CompilerManifestArtifactJson.Deserialize( - CompilerManifestArtifactJson.Serialize(artifact)); + var resealed = CanonicalRoundTrip(artifact); Assert.Throws((Action)(() => CompilerManifestArtifactJson.DecodeCallables(resealed))); @@ -2459,6 +2475,8 @@ internal static int Identity(int value) { private static CompilerManifestArtifact CanonicalRoundTrip( CompilerManifestArtifact artifact) { + artifact.FeatureScopeSha256 = + CompilerFeatureScopeFingerprint.ComputeSha256(artifact); return CompilerManifestArtifactJson.Deserialize( CompilerManifestArtifactJson.Serialize(artifact)); } diff --git a/SharpProof.Worker.Test/CompilerRuntimeSymbolArtifactTests.cs b/SharpProof.Worker.Test/CompilerRuntimeSymbolArtifactTests.cs index b8ebdd981..8f6feab43 100644 --- a/SharpProof.Worker.Test/CompilerRuntimeSymbolArtifactTests.cs +++ b/SharpProof.Worker.Test/CompilerRuntimeSymbolArtifactTests.cs @@ -30,7 +30,7 @@ public async Task ProjectSymbolNeutralizedByUndefRemainsValidEvidence() using (Assert.EnterMultipleScope()) { - Assert.That(artifact.SchemaVersion, Is.EqualTo(14)); + Assert.That(artifact.SchemaVersion, Is.EqualTo(15)); Assert.That( tree.PreprocessorSymbols, Does.Contain(Contract.ConditionalSymbol)); diff --git a/SharpProof.Worker.Test/PortableIrGraphCodecTests.cs b/SharpProof.Worker.Test/PortableIrGraphCodecTests.cs index ea3611e18..b4a60051d 100644 --- a/SharpProof.Worker.Test/PortableIrGraphCodecTests.cs +++ b/SharpProof.Worker.Test/PortableIrGraphCodecTests.cs @@ -697,6 +697,40 @@ public void DecoderRejectsVeryDeepAcyclicAndCyclicGraphs( (Action)(() => PortableIrGraphCodec.Decode(graph))); } + [Test] + public void EncoderRejectsTermsDeeperThanTheDecoderLimit() + { + var factory = new IrFactory(); + IrTerm term = factory.Variable( + factory.CreateVariable("value", factory.BooleanType)); + for (var index = 0; + index < PortableIrGraphCodec.MaximumGraphDepth; + index++) + { + term = factory.Unary(IrUnaryOperator.Not, term); + } + + Assert.Throws((Action)(() => + PortableIrGraphCodec.Encode(factory, null, [term]))); + } + + [Test] + public void EncoderRejectsTypesDeeperThanTheDecoderLimit() + { + var factory = new IrFactory(); + var type = factory.IntegerType; + for (var index = 0; + index < PortableIrGraphCodec.MaximumGraphDepth; + index++) + { + type = factory.GetOrCreateSequenceType(type); + } + var term = factory.Variable(factory.CreateVariable("value", type)); + + Assert.Throws((Action)(() => + PortableIrGraphCodec.Encode(factory, null, [term]))); + } + private static PortableIrGraph DeepGraph( DeepGraphKind kind, bool cyclic, diff --git a/SharpProof.Worker.Test/ProtocolJsonTests.cs b/SharpProof.Worker.Test/ProtocolJsonTests.cs index b7040172a..d706856d3 100644 --- a/SharpProof.Worker.Test/ProtocolJsonTests.cs +++ b/SharpProof.Worker.Test/ProtocolJsonTests.cs @@ -172,7 +172,7 @@ public void CompilerManifestArtifactIsCanonicalAndCarriesAssumptions() using (Assert.EnterMultipleScope()) { - Assert.That(roundTrip.SchemaVersion, Is.EqualTo(14)); + Assert.That(roundTrip.SchemaVersion, Is.EqualTo(15)); Assert.That(roundTrip.ProtocolVersion, Is.EqualTo("11")); Assert.That(roundTrip.Manifest.Hash, Is.EqualTo(manifest.Hash)); Assert.That(roundTrip.Manifest.Callables[0].Assumptions, Has.Length.EqualTo(2)); diff --git a/SharpProof.Worker.Test/WorkerBinaryIdentityTests.cs b/SharpProof.Worker.Test/WorkerBinaryIdentityTests.cs index 9a604f3dd..6c824f84c 100644 --- a/SharpProof.Worker.Test/WorkerBinaryIdentityTests.cs +++ b/SharpProof.Worker.Test/WorkerBinaryIdentityTests.cs @@ -65,6 +65,25 @@ public void RuntimeComponentReadsRetainTheDeclaredSizeBoundary() } } + [Test] + public void CompilerManifestReaderRejectsEmptyOpenedFile() + { + var path = Path.Combine( + Path.GetTempPath(), + "SharpProof.EmptyManifest." + Guid.NewGuid().ToString("N")); + try + { + File.WriteAllBytes(path, []); + Assert.That( + (Action)(() => CompilerManifestArtifactFile.ReadAllBytes(path)), + Throws.TypeOf()); + } + finally + { + File.Delete(path); + } + } + [Test] public void RuntimeClosureLimitsFailClosedAtEveryBoundary() { diff --git a/Tools/SharpProof.Fuzz/FrontendFuzzing.cs b/Tools/SharpProof.Fuzz/FrontendFuzzing.cs index 9c5b2045e..8d7bfed86 100644 --- a/Tools/SharpProof.Fuzz/FrontendFuzzing.cs +++ b/Tools/SharpProof.Fuzz/FrontendFuzzing.cs @@ -1008,11 +1008,25 @@ public ImmutableArray CompareBatch( var failure = Mismatch( "Generated C# did not compile: " + FormatErrors(emit.Diagnostics)); - return [.. Enumerable.Repeat(failure, generatedCases.Count)]; + if (generatedCases.Count == 1) + { + return [failure]; + } + + var midpoint = generatedCases.Count / 2; + var left = CompareBatch( + generatedCases.Take(midpoint).ToArray(), + cancellationToken); + var right = CompareBatch( + generatedCases.Skip(midpoint).ToArray(), + cancellationToken); + return [.. left, .. right]; } cancellationToken.ThrowIfCancellationRequested(); - var model = compilation.GetSemanticModel(syntaxTree); + var model = SharpProof.Frontend.Host.CompilationModelProvider.GetSemanticModel( + compilation, + syntaxTree); var methodSyntaxes = syntaxTree.GetRoot(cancellationToken) .DescendantNodes() .OfType() @@ -1153,28 +1167,67 @@ public ImmutableArray CompareSemanticEdges( var emit = compilation.Emit(image, cancellationToken: cancellationToken); if (!emit.Success) { - return RepeatSemanticFailure( - cases.Count, + return IsolateSemanticEdgeFailure( + cases, "Generated semantic-edge C# did not compile: " + - FormatErrors(emit.Diagnostics)); + FormatErrors(emit.Diagnostics), + cancellationToken); } - var model = compilation.GetSemanticModel(syntaxTree); - var methods = syntaxTree.GetRoot(cancellationToken) + var model = SharpProof.Frontend.Host.CompilationModelProvider.GetSemanticModel( + compilation, + syntaxTree); + var compilationUnit = (CompilationUnitSyntax)syntaxTree.GetRoot( + cancellationToken); + var generatedTypes = compilationUnit .DescendantNodes() + .OfType() + .Where(static type => type.Identifier.ValueText == + "SharpProofGeneratedFrontendEdges") + .ToArray(); + if (generatedTypes.Length != 1) + { + return IsolateSemanticEdgeFailure( + cases, + "Roslyn exposed an unexpected semantic-edge type shape.", + cancellationToken); + } + var generatedType = generatedTypes[0]; + var hasExpectedTopology = + compilationUnit.AttributeLists.Count == 0 && + compilationUnit.Members.Count == 3 && + compilationUnit.Members[0] is EnumDeclarationSyntax + { Identifier.ValueText: "SharpProofGeneratedEdgeEnum" } && + compilationUnit.Members[1] is StructDeclarationSyntax + { Identifier.ValueText: "SharpProofGeneratedConvertible" } && + compilationUnit.Members[2] is ClassDeclarationSyntax + { Identifier.ValueText: "SharpProofGeneratedFrontendEdges" }; + if (!hasExpectedTopology) + { + return IsolateSemanticEdgeFailure( + cases, + "Roslyn exposed an unexpected semantic-edge file shape.", + cancellationToken); + } + var methods = generatedType.Members .OfType() - .Where(static method => method.Identifier.ValueText.StartsWith( - SemanticEdgeMethodPrefix, - StringComparison.Ordinal)) - .OrderBy(static method => ParseSemanticEdgeMethodIndex( - method.Identifier.ValueText)) .ToArray(); - if (methods.Length != cases.Count) - { - return RepeatSemanticFailure( - cases.Count, - "Roslyn exposed an unexpected semantic-edge method count."); - } + if (methods.Length != cases.Count || + generatedType.Members.Count != cases.Count || + Enumerable.Range(0, cases.Count).Any(index => + methods.All(method => + method.Identifier.ValueText != + SemanticEdgeMethodName(index)))) + { + return IsolateSemanticEdgeFailure( + cases, + "Roslyn exposed an unexpected semantic-edge method shape.", + cancellationToken); + } + methods = Enumerable.Range(0, cases.Count) + .Select(index => methods.Single(method => + method.Identifier.ValueText == SemanticEdgeMethodName(index))) + .ToArray(); image.Position = 0; var loadContext = new AssemblyLoadContext( @@ -1207,6 +1260,27 @@ public ImmutableArray CompareSemanticEdges( } } + private ImmutableArray + IsolateSemanticEdgeFailure( + IReadOnlyList cases, + string detail, + CancellationToken cancellationToken) + { + if (cases.Count == 1) + { + return RepeatSemanticFailure(1, detail); + } + + var midpoint = cases.Count / 2; + var left = CompareSemanticEdges( + cases.Take(midpoint).ToArray(), + cancellationToken); + var right = CompareSemanticEdges( + cases.Skip(midpoint).ToArray(), + cancellationToken); + return [.. left, .. right]; + } + private static Dictionary CreateEnvironment( IrFactory factory, IMethodSymbol method, @@ -1349,6 +1423,8 @@ private static Dictionary IReadOnlyList arguments) { var environment = new Dictionary(); + var sequenceValues = new Dictionary>( + ReferenceEqualityComparer.Instance); foreach (var binding in lowering.Variables) { if (binding.Symbol is not IParameterSymbol parameter || @@ -1365,7 +1441,8 @@ private static Dictionary CreateSemanticEdgeValue( factory, type, - arguments[parameter.Ordinal])); + arguments[parameter.Ordinal], + sequenceValues)); } return environment; } @@ -1373,7 +1450,8 @@ private static Dictionary private static IrValue CreateSemanticEdgeValue( IrFactory factory, IrTypeId type, - object? value) + object? value, + Dictionary> sequenceValues) { var kind = factory.GetTypeInfo(type).Kind; if (value == null) @@ -1401,9 +1479,39 @@ IrTypeKind.Integer when value is sbyte or byte or short or ushort or factory.CreateStringValue(text), IrTypeKind.Reference => factory.CreateReferenceValue(type, value), + IrTypeKind.Sequence when value is Array array => + CreateSequenceValue(array), _ => throw new InvalidOperationException( "A semantic-edge value is outside the executable IR subset.") }; + + IrValue CreateSequenceValue(Array array) + { + if (sequenceValues.TryGetValue(array, out var typedValues) && + typedValues.TryGetValue(type, out var existing)) + { + return existing; + } + + var elementType = factory.GetTypeInfo(type).ElementType ?? + throw new InvalidOperationException( + "A semantic-edge sequence has no element type."); + var created = factory.CreateSequenceValue( + type, + array.Cast().Select(element => + CreateSemanticEdgeValue( + factory, + elementType, + element, + sequenceValues))); + if (!sequenceValues.TryGetValue(array, out typedValues)) + { + typedValues = []; + sequenceValues.Add(array, typedValues); + } + typedValues.Add(type, created); + return created; + } } private static IOperation? GetExpressionOperation( @@ -1544,14 +1652,6 @@ private static int ParseMethodIndex(string name) CultureInfo.InvariantCulture); } - private static int ParseSemanticEdgeMethodIndex(string name) - { - return int.Parse( - name.AsSpan(SemanticEdgeMethodPrefix.Length), - NumberStyles.None, - CultureInfo.InvariantCulture); - } - private static string ReturnType(GeneratedExpressionType type) { return type switch @@ -1601,27 +1701,58 @@ private static FrontendDifferentialResult CompareOutcomes( "."); } - var agrees = interpreted.Value!.Kind switch + var agrees = SemanticValueEquals(actual.Value, interpreted.Value!); + return agrees + ? Agreement() + : Mismatch( + "Compiled C# and the lowered IR produced different values."); + } + + private static bool SemanticValueEquals(object? actual, IrValue interpreted) + { + return interpreted.Kind switch { IrValueKind.Boolean => - actual.Value is bool value && - value == interpreted.Value.Boolean, + actual is bool value && + value == interpreted.Boolean, IrValueKind.Integer => - actual.Value is long value && - value == interpreted.Value.Integer, + IntegralValueEquals( + actual, + interpreted.Integer), IrValueKind.String => - actual.Value is string value && + actual is string value && string.Equals( value, - interpreted.Value.String, + interpreted.String, StringComparison.Ordinal), - IrValueKind.Null => actual.Value == null, + IrValueKind.Reference => + ReferenceEquals(actual, interpreted.Reference), + IrValueKind.Sequence => + actual is Array array && + array.Length == interpreted.Elements.Length && + array.Cast().Zip( + interpreted.Elements, + SemanticValueEquals).All(static equal => equal), + IrValueKind.Null => actual == null, + _ => false + }; + } + + private static bool IntegralValueEquals(object? value, long expected) + { + return value switch + { + sbyte item => item == expected, + byte item => item == expected, + short item => item == expected, + ushort item => item == expected, + int item => item == expected, + uint item => item == expected, + long item => item == expected, + ulong item => item <= long.MaxValue && (long)item == expected, + char item => item == expected, _ => false }; - return agrees - ? Agreement() - : Mismatch( - "Compiled C# and the lowered IR produced different values."); } private static string Describe(IrEvaluationResult result) diff --git a/Tools/SharpProof.Fuzz/FuzzOptions.cs b/Tools/SharpProof.Fuzz/FuzzOptions.cs index 56fd0c5ea..12493818d 100644 --- a/Tools/SharpProof.Fuzz/FuzzOptions.cs +++ b/Tools/SharpProof.Fuzz/FuzzOptions.cs @@ -5,6 +5,7 @@ namespace SharpProof.Fuzz; public sealed record FuzzOptions(int Cases, int Seed, int MaximumParallelism) { public const int DefaultCases = 1000; + public const int MaximumCases = 1_000_000; public const int DefaultSeed = 0x5A17; public const int DefaultMaximumParallelism = 4; @@ -37,6 +38,13 @@ public static FuzzOptions Parse(IReadOnlyList arguments) { case "--cases": cases = ParsePositive(value, argument); + if (cases > MaximumCases) + { + throw new FuzzUsageException( + "--cases cannot exceed the limit of " + + MaximumCases.ToString(CultureInfo.InvariantCulture) + + "."); + } break; case "--seed": if (!int.TryParse( diff --git a/Tools/SharpProof.Fuzz/FuzzRunner.cs b/Tools/SharpProof.Fuzz/FuzzRunner.cs index 942632f81..306ea0d5b 100644 --- a/Tools/SharpProof.Fuzz/FuzzRunner.cs +++ b/Tools/SharpProof.Fuzz/FuzzRunner.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Collections.Immutable; using SharpProof.Ir; using SharpProof.Testing; @@ -16,6 +15,11 @@ public sealed record FuzzFailure( public string Term => Minimized; } +internal readonly record struct FuzzFailureKey(int Case, string Oracle); +internal readonly record struct FuzzCaseClassification( + bool HasMismatch, + bool HasAbstention); + public sealed record FrontendFuzzCoverage( int TextParameters, int StringLiterals, @@ -31,6 +35,21 @@ public sealed record FrontendFuzzCoverage( int IndexOutOfRangeExceptions, int InvalidCastExceptions) { + public bool HasValidCounts => + TextParameters >= 0 && + StringLiterals >= 0 && + NullStrings >= 0 && + StringConcatenations >= 0 && + StringLengths >= 0 && + StringCasts >= 0 && + ArrayLengths >= 0 && + ArrayIndexes >= 0 && + DivideByZeroExceptions >= 0 && + OverflowExceptions >= 0 && + NullReferenceExceptions >= 0 && + IndexOutOfRangeExceptions >= 0 && + InvalidCastExceptions >= 0; + public bool HasExpandedCategories => TextParameters > 0 && StringLiterals > 0 && @@ -45,6 +64,16 @@ public sealed record FrontendFuzzCoverage( NullReferenceExceptions > 0 && IndexOutOfRangeExceptions > 0 && InvalidCastExceptions > 0; + + public bool HasValidExceptionCounts(int cases) + { + return cases >= 0 && + (long)DivideByZeroExceptions + + OverflowExceptions + + NullReferenceExceptions + + IndexOutOfRangeExceptions + + InvalidCastExceptions <= cases; + } } public sealed record FuzzSummary( @@ -62,7 +91,17 @@ public sealed record FuzzSummary( ImmutableArray Failures) { public bool Passed => - Failures.IsDefaultOrEmpty && + SchemaVersion == 4 && + Cases > 0 && + MaximumParallelism is >= 1 and <= 4 && + !Failures.IsDefault && + Failures.IsEmpty && + FrontendCoverage != null && + FrontendCoverage.HasValidCounts && + FrontendCoverage.HasValidExceptionCounts(Cases) && + CoverageSatisfied == + (Cases < FuzzOptions.DefaultCases || + FrontendCoverage.HasExpandedCategories) && CoverageSatisfied && Abstentions == 0 && Agreements == Cases && @@ -75,6 +114,7 @@ public static class FuzzRunner { private const int FrontendCompilationBatchSize = 256; private const int PullRequestCoverageBudget = FuzzOptions.DefaultCases; + internal const int MaximumRetainedFailures = 64; public static async Task RunAsync( FuzzOptions options, @@ -84,8 +124,22 @@ public static async Task RunAsync( { throw new ArgumentNullException(nameof(options)); } + if (options.Cases <= 0 || options.Cases > FuzzOptions.MaximumCases) + { + throw new ArgumentOutOfRangeException( + nameof(options), + options.Cases, + "The fuzz case count must be between 1 and " + + FuzzOptions.MaximumCases + "."); + } + if (options.MaximumParallelism is < 1 or > 4) + { + throw new ArgumentOutOfRangeException( + nameof(options), + options.MaximumParallelism, + "Maximum parallelism must be between 1 and 4."); + } - var failures = new ConcurrentQueue(); var agreements = 0; var abstentions = 0; var frontendAgreements = 0; @@ -102,6 +156,9 @@ public static async Task RunAsync( } var frontendResults = new FrontendDifferentialResult[options.Cases]; + var frontendStatuses = new FuzzOracleStatus[options.Cases]; + var smtStatuses = new FuzzOracleStatus[options.Cases]; + var partialStatuses = new FuzzOracleStatus[options.Cases]; var frontendOracle = new FrontendDifferentialOracle(); for (var offset = 0; offset < frontendCases.Length; @@ -140,6 +197,7 @@ await Parallel.ForEachAsync( var caseSeed = CreateCaseSeed(options.Seed, index); var frontendCase = frontendCases[index]; var frontend = frontendResults[index]; + frontendStatuses[index] = frontend.Status; if (frontend.Status == FuzzOracleStatus.Agreement) { Interlocked.Increment(ref frontendAgreements); @@ -156,6 +214,7 @@ await Parallel.ForEachAsync( formula, token) .ConfigureAwait(false); + smtStatuses[index] = smt.Status; if (smt.Status == FuzzOracleStatus.Agreement) { Interlocked.Increment(ref smtAgreements); @@ -171,34 +230,68 @@ await Parallel.ForEachAsync( partialCase, token) .ConfigureAwait(false); + partialStatuses[index] = partial.Status; if (partial.Status == FuzzOracleStatus.Agreement) { Interlocked.Increment(ref partialSmtAgreements); } - if (frontend.Status == FuzzOracleStatus.Mismatch) + var classification = ClassifyCase( + frontend.Status, + smt.Status, + partial.Status); + if (!classification.HasMismatch && + !classification.HasAbstention) { - var minimized = CSharpStructuralShrinker.Minimize( - frontendCase, - candidate => - frontendOracle.Compare(candidate, token).Status == - FuzzOracleStatus.Mismatch, - token); - var minimizedResult = frontendOracle.Compare( - minimized, - token); - failures.Enqueue( - new FuzzFailure( - index, - caseSeed, - "frontend", - frontendCase.Source, - minimized.Source, - minimizedResult.Detail)); + Interlocked.Increment(ref agreements); } - if (smt.Status == FuzzOracleStatus.Mismatch) + else if (!classification.HasMismatch) { - var minimized = await IrStructuralShrinker.MinimizeAsync( + Interlocked.Increment(ref abstentions); + } + }); + + var failureKeys = SelectFailureKeys( + frontendStatuses, + smtStatuses, + partialStatuses); + var failures = new List(failureKeys.Length); + foreach (var failureKey in failureKeys) + { + cancellationToken.ThrowIfCancellationRequested(); + var index = failureKey.Case; + var caseSeed = CreateCaseSeed(options.Seed, index); + switch (failureKey.Oracle) + { + case "frontend": + var frontendCase = frontendCases[index]; + var minimizedFrontend = CSharpStructuralShrinker.Minimize( + frontendCase, + candidate => frontendOracle.Compare( + candidate, + cancellationToken).Status == + FuzzOracleStatus.Mismatch, + cancellationToken); + var minimizedFrontendResult = frontendOracle.Compare( + minimizedFrontend, + cancellationToken); + failures.Add(new FuzzFailure( + index, + caseSeed, + failureKey.Oracle, + frontendCase.Source, + minimizedFrontend.Source, + minimizedFrontendResult.Detail)); + break; + case "finite-domain-smt": + var factory = new IrFactory(); + var formula = CreateTotalFiniteDomainFormula( + factory, + caseSeed, + cancellationToken); + var smtOracle = new FiniteDomainSmtDifferentialOracle(); + var minimizedFormula = await IrStructuralShrinker + .MinimizeAsync( factory, formula, async (candidate, cancellation) => @@ -208,53 +301,45 @@ await Parallel.ForEachAsync( cancellation) .ConfigureAwait(false)).Status == FuzzOracleStatus.Mismatch, - token) + cancellationToken) .ConfigureAwait(false); - var minimizedResult = await smtOracle.CompareAsync( + var minimizedSmtResult = await smtOracle.CompareAsync( factory, - minimized, - token) + minimizedFormula, + cancellationToken) .ConfigureAwait(false); var printer = new IrPrinter(factory); - failures.Enqueue( - new FuzzFailure( - index, - caseSeed, - "finite-domain-smt", - printer.Print(formula), - printer.Print(minimized), - minimizedResult.Detail)); - } - if (partial.Status != FuzzOracleStatus.Agreement) - { - var printer = new IrPrinter(factory); - failures.Enqueue( - new FuzzFailure( - index, - caseSeed, - "partial-term-smt", - printer.Print(partialCase.Formula), - printer.Print(partialCase.Formula), - partial.Detail)); - } - - var hasMismatch = - frontend.Status == FuzzOracleStatus.Mismatch || - smt.Status == FuzzOracleStatus.Mismatch || - partial.Status != FuzzOracleStatus.Agreement; - var hasAbstention = - frontend.Status == FuzzOracleStatus.Abstained || - smt.Status == FuzzOracleStatus.Abstained || - partial.Status == FuzzOracleStatus.Abstained; - if (!hasMismatch && !hasAbstention) - { - Interlocked.Increment(ref agreements); - } - else if (!hasMismatch) - { - Interlocked.Increment(ref abstentions); - } - }); + failures.Add(new FuzzFailure( + index, + caseSeed, + failureKey.Oracle, + printer.Print(formula), + printer.Print(minimizedFormula), + minimizedSmtResult.Detail)); + break; + case "partial-term-smt": + var partialFactory = new IrFactory(); + var partialCase = PartialTermSmtCaseGenerator.Create( + partialFactory, + unchecked(caseSeed ^ 0x243F6A88)); + var partialResult = await new + PartialTermSmtDifferentialOracle().CompareAsync( + partialFactory, + partialCase, + cancellationToken) + .ConfigureAwait(false); + var partialPrinter = new IrPrinter(partialFactory); + var printed = partialPrinter.Print(partialCase.Formula); + failures.Add(new FuzzFailure( + index, + caseSeed, + failureKey.Oracle, + printed, + printed, + partialResult.Detail)); + break; + } + } return new FuzzSummary( SchemaVersion: 4, @@ -268,9 +353,62 @@ await Parallel.ForEachAsync( partialSmtAgreements, frontendCoverage, coverageSatisfied, - [.. failures - .OrderBy(static failure => failure.Case) - .ThenBy(static failure => failure.Oracle, StringComparer.Ordinal)]); + [.. failures]); + } + + internal static ImmutableArray SelectFailureKeys( + IReadOnlyList frontend, + IReadOnlyList smt, + IReadOnlyList partial) + { + if (frontend.Count != smt.Count || smt.Count != partial.Count) + { + throw new ArgumentException( + "Fuzz oracle status collections must have equal lengths."); + } + + var keys = ImmutableArray.CreateBuilder( + MaximumRetainedFailures); + for (var index = 0; index < frontend.Count; index++) + { + Add(index, "finite-domain-smt", + smt[index] == FuzzOracleStatus.Mismatch); + Add(index, "frontend", + frontend[index] == FuzzOracleStatus.Mismatch); + Add(index, "partial-term-smt", + partial[index] == FuzzOracleStatus.Mismatch); + if (keys.Count >= MaximumRetainedFailures) + { + break; + } + } + + keys.Capacity = keys.Count; + return keys.MoveToImmutable(); + + void Add(int index, string oracle, bool failed) + { + if (failed && keys.Count < MaximumRetainedFailures) + { + keys.Add(new FuzzFailureKey(index, oracle)); + } + } + } + + internal static FuzzCaseClassification ClassifyCase( + FuzzOracleStatus frontendStatus, + FuzzOracleStatus smtStatus, + FuzzOracleStatus partialStatus) + { + var hasMismatch = + frontendStatus == FuzzOracleStatus.Mismatch || + smtStatus == FuzzOracleStatus.Mismatch || + partialStatus == FuzzOracleStatus.Mismatch; + var hasAbstention = + frontendStatus == FuzzOracleStatus.Abstained || + smtStatus == FuzzOracleStatus.Abstained || + partialStatus == FuzzOracleStatus.Abstained; + return new FuzzCaseClassification(hasMismatch, hasAbstention); } private static FrontendFuzzCoverage CreateFrontendCoverage( diff --git a/Tools/SharpProof.Fuzz/SharpProof.Fuzz.csproj b/Tools/SharpProof.Fuzz/SharpProof.Fuzz.csproj index 7f448f0a7..f42a98b7b 100644 --- a/Tools/SharpProof.Fuzz/SharpProof.Fuzz.csproj +++ b/Tools/SharpProof.Fuzz/SharpProof.Fuzz.csproj @@ -1,4 +1,7 @@ + + + Exe net9.0 diff --git a/Tools/SharpProof.Fuzz/packages.lock.json b/Tools/SharpProof.Fuzz/packages.lock.json index 49fe0ec38..1c9cf7a5e 100644 --- a/Tools/SharpProof.Fuzz/packages.lock.json +++ b/Tools/SharpProof.Fuzz/packages.lock.json @@ -2,6 +2,12 @@ "version": 2, "dependencies": { "net9.0": { + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, "Microsoft.Z3": { "type": "Direct", "requested": "[4.12.2, )", diff --git a/docs/README.md b/docs/README.md index d1fa94ee3..9c490885b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -113,7 +113,7 @@ the current coverage inventory or normative semantics. ## Known production gaps During container verification, the production analyzer emits a deterministic -schema-14 compiler artifact from the final post-generator Roslyn +schema-15 compiler artifact from the final post-generator Roslyn `Compilation`. It contains the selected-claim manifest and portable lowered whole-body CFG/IR for supported selected callables, plus bounded relational source/implementation-IL/audited-pack calls, bound contract/spec @@ -130,7 +130,7 @@ production-plan Step 4 is complete for the bounded verifier subset. Independent whole-body postcondition-counterexample replay is implemented for the admitted scalar program subset. The proof kernel checks exact model closure and the lowered assumptions/goal before the worker independently executes the -compiler-produced whole-body CFG. Schema 14 retains the independently +compiler-produced whole-body CFG. Schema 15 retains the independently replayable event for an unconditional definite managed object/array allocation. The worker can use it to refute `ZeroAllocations` or an `EffectContract` excluding `Allocates`; other effect candidates still fail closed as typed diff --git a/docs/analysis-limits.md b/docs/analysis-limits.md index ea4115340..c9963d9a2 100644 --- a/docs/analysis-limits.md +++ b/docs/analysis-limits.md @@ -115,7 +115,7 @@ SharpProof does not inspect or duplicate cgroup enforcement. `SharpProofVerifyMaximumExpressionDepth` is also a compiler-visible property. The collector parses it, enforces the 1-through-256 range, and seals it into the -schema-14 compiler artifact. The launcher supplies the same property as the +schema-15 compiler artifact. The launcher supplies the same property as the worker request budget. A mismatch is `CompilerManifestMismatch` and stops before cache lookup or backend creation; neither side may silently use a different depth. @@ -220,7 +220,7 @@ is the observed runner total rather than the requested budget. | IDE edit maximum | At most 250 ms | The active contract also fixes protocol version 11, cache schema version 13, -claim-manifest schema version 4, compiler artifact schema version 14, +claim-manifest schema version 4, compiler artifact schema version 15, relational-summary schema version 2, and specification-pack schema version 1, along with exact proof-kernel and component TCB path inventories, formatting-neutral Roslyn complexity ratchets, and the reference surfaces `netstandard2.0`, diff --git a/docs/architecture.md b/docs/architecture.md index 6c70f3cf2..b2a1a60ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -224,7 +224,7 @@ models are not cacheable. During container verification, the build-only compiler collector observes the final post-generator Roslyn `Compilation` and atomically emits compiler -artifact schema version 14. The compiler owns selection, contract/spec binding, +artifact schema version 15. The compiler owns selection, contract/spec binding, effect evaluation, relational-summary inference, and body lowering. Every selected callable has either a typed failure record or a portable graph containing its bound clauses, canonical variables, whole-body CFG/IR, body start, initial environment, @@ -301,7 +301,7 @@ direct candidates become `Unknown(CounterexampleNotReplayable)`. Conditional/path-dependent and may-only conflicts remain `Unknown(EffectContractNotEstablished)`. A semantic replay disagreement becomes `Unknown(CounterexampleReplayFailed)` and fails the run. Effect results -remain noncacheable. Under compiler artifact schema 14, worker protocol version +remain noncacheable. Under compiler artifact schema 15, worker protocol version 11 and cache schema version 13 carry the current request and cache wire break. Optional deterministic SARIF 2.1.0 projects the validated response under the diff --git a/docs/coverage-and-limits.md b/docs/coverage-and-limits.md index 05b1dc319..f047c6a4e 100644 --- a/docs/coverage-and-limits.md +++ b/docs/coverage-and-limits.md @@ -191,7 +191,7 @@ ghost specification evidence. state is a fatal `CounterexampleReplayFailed`; one on an unselected path does not block the refutation. Result models expose only canonical user variables. - For an effect candidate, compiler artifact schema 14 currently admits one + For an effect candidate, compiler artifact schema 15 currently admits one unconditional definite managed object/array allocation event. The worker recomputes its constraint and operation identities, checks its source-tree identity/span and sealed witness, and independently derives `Allocates`. @@ -243,7 +243,7 @@ ghost specification evidence. ## Closed compiler artifact and remaining limits During container verification, the production analyzer captures compiler -artifact schema version 14 from the post-generator compilation. The artifact +artifact schema version 15 from the post-generator compilation. The artifact contains: - the feature-selected, sealed claim manifest; diff --git a/docs/diagnostic-examples.md b/docs/diagnostic-examples.md index dfc9d8301..62a339039 100644 --- a/docs/diagnostic-examples.md +++ b/docs/diagnostic-examples.md @@ -237,7 +237,7 @@ artifact lowering, serialization, or write failure. The diagnostic is an error because the required closed compiler evidence is missing. It is an infrastructure failure, never a contract or proof outcome. -Compiler artifact schema version 14 includes the sealed selected-claim manifest, +Compiler artifact schema version 15 includes the sealed selected-claim manifest, compiler diagnostics, source/generated-tree hashes and parse evidence, and, for each supported selected callable, bound contract/spec metadata plus portable whole-body lowered CFG/IR. It contains no source text. The worker diff --git a/docs/native-smt-packaging.md b/docs/native-smt-packaging.md index 07617923f..c0ba0da7d 100644 --- a/docs/native-smt-packaging.md +++ b/docs/native-smt-packaging.md @@ -68,7 +68,7 @@ never used. A main or symbol collision fails closed; any partial publication requires a new version rather than reusing remote bytes. The worker protocol is 11, the cache schema is 13, and compiler artifacts use -schema 14. The worker consumes sealed compiler artifacts rather than parsing +schema 15. The worker consumes sealed compiler artifacts rather than parsing source or rereading references. The admitted semantic subset and typed `Unknown` behavior are documented separately in `SEMANTICS.md` and `docs/analysis-limits.md`. diff --git a/docs/smt-lifecycle.md b/docs/smt-lifecycle.md index 59a2af1ce..bf7d8cc32 100644 --- a/docs/smt-lifecycle.md +++ b/docs/smt-lifecycle.md @@ -52,7 +52,7 @@ Project timeout and caller cancellation use the separate `TimedOut` and `Canceled` run statuses. Effect refutation replay is independent of this SMT lifecycle. Compiler -artifact schema 14 retains schema 10's unconditional definite managed object/array +artifact schema 15 retains schema 10's unconditional definite managed object/array allocation event. A worker-owned interpreter validates the event identity, source-tree span, selected constraint, and sealed witness, then derives `Allocates` without trusting compiler effect bits or executing user code. That diff --git a/docs/unknown-reasons.md b/docs/unknown-reasons.md index 0c244e491..77b87e776 100644 --- a/docs/unknown-reasons.md +++ b/docs/unknown-reasons.md @@ -262,7 +262,7 @@ The exact typed outcome and effect-certainty authority follows. A may-effect summary is suitable for proving the absence of a disallowed effect, but the presence of a may-effect is not itself a concrete trace. Consequently a complete summary that does not establish the contract remains -`Unknown(EffectContractNotEstablished)`. Compiler artifact schema 14 can seal +`Unknown(EffectContractNotEstablished)`. Compiler artifact schema 15 can seal one unconditional definite managed object/array allocation event for independent worker replay. The worker validates its order, source-tree identity/span, selected-constraint and semantic-operation hashes, and sealed diff --git a/eng/acceptance/README.md b/eng/acceptance/README.md index a6d61c6fe..20f1689ce 100644 --- a/eng/acceptance/README.md +++ b/eng/acceptance/README.md @@ -65,7 +65,7 @@ effect-only artifacts exclude postcondition claims. under every policy. The removed `SharpProofMode` alias is rejected; the preview configuration surface is frozen on profile and feature properties. -This acceptance contract covers compiler artifact schema version 14, +This acceptance contract covers compiler artifact schema version 15, relational-summary schema version 2, specification-pack schema version 1, generated-tree accountability, portable whole-body lowered CFG/IR, exact manifest/lowered-callable/result equality, compiler-diagnostic propagation, and diff --git a/eng/acceptance/Verify.ps1 b/eng/acceptance/Verify.ps1 index 1d0620c64..5e648ac9c 100644 --- a/eng/acceptance/Verify.ps1 +++ b/eng/acceptance/Verify.ps1 @@ -16,6 +16,10 @@ $repositoryRoot = (Resolve-Path (Join-Path $acceptanceRoot '..\..')).Path $contractPath = Join-Path $acceptanceRoot 'contract.json' $wrapperPath = Join-Path $repositoryRoot 'scripts\Invoke-SharpProofDotnet.ps1' $contract = Get-Content -LiteralPath $contractPath -Raw | ConvertFrom-Json +. (Join-Path $repositoryRoot 'scripts\SharpProof.FuzzEvidenceLifecycle.ps1') +$pullRequestCases = Assert-SharpProofFuzzCaseBudget ` + -Value $contract.fuzz.pullRequestCases ` + -Name 'contract.fuzz.pullRequestCases' # BEGIN ACCEPTANCE TIMELINE AUTHORITY function Test-AcceptanceTimingTimeline { @@ -498,7 +502,7 @@ Assert-Equal ` Assert-Equal ($contract.supportedTargetFrameworks -join ',') 'netstandard2.0,net8.0,net472' 'supportedTargetFrameworks' Assert-Equal $contract.worker.protocolVersion 11 'worker.protocolVersion' Assert-Equal $contract.worker.manifestSchemaVersion 4 'worker.manifestSchemaVersion' -Assert-Equal $contract.worker.compilerArtifactSchemaVersion 14 'worker.compilerArtifactSchemaVersion' +Assert-Equal $contract.worker.compilerArtifactSchemaVersion 15 'worker.compilerArtifactSchemaVersion' Assert-Equal $contract.worker.maximumCompilerReferenceModuleBytes 268435456 'worker.maximumCompilerReferenceModuleBytes' Assert-Equal $contract.worker.maximumCompilerReferenceClosureBytes 1073741824 'worker.maximumCompilerReferenceClosureBytes' Assert-Equal $contract.worker.maximumCompilerReferenceModules 4096 'worker.maximumCompilerReferenceModules' @@ -740,7 +744,7 @@ try { '--no-build', '--', '--cases', - [string]$contract.fuzz.pullRequestCases, + [string]$pullRequestCases, '--seed', '23063', '--max-parallelism', diff --git a/eng/acceptance/algorithm-size-ratchets.json b/eng/acceptance/algorithm-size-ratchets.json index 4302ba6ba..bbdb276d8 100644 --- a/eng/acceptance/algorithm-size-ratchets.json +++ b/eng/acceptance/algorithm-size-ratchets.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "rationale": "These formatting-invariant complexity ratchets keep algorithm files and members reviewable. Raise a cap only with a reviewed decomposition or a soundness note explaining why the added executable structure belongs in the existing layer. The effect scanner ceiling includes the existing 3628-node implementation retained by the cumulative schema13 authority merge; its next decomposition remains a separate maintenance task.", + "rationale": "These formatting-invariant complexity ratchets keep algorithm files and members reviewable. Raise a cap only with a reviewed decomposition or a soundness note explaining why the added executable structure belongs in the existing layer. Assignment evaluation is separated from the core effect scanner so both layers retain independent reviewable ceilings.", "measurement": { "fileExpressionNodes": "Roslyn expression-node count for the complete file, excluding trivia and optional block syntax.", "memberExpressionNodes": "Roslyn expression-node count for each measured member, excluding trivia and optional block syntax.", @@ -10,8 +10,8 @@ "files": [ { "path": "SharpProof.Frontend/RoslynOperationLowerer.cs", - "maximumFileExpressionNodes": 2730, - "maximumMemberExpressionNodes": 335, + "maximumFileExpressionNodes": 2792, + "maximumMemberExpressionNodes": 340, "maximumFileDecisionPoints": 150, "maximumMemberDecisionPoints": 30 }, @@ -73,10 +73,17 @@ }, { "path": "SharpProof.Effects/OperationEffectScanner.cs", - "maximumFileExpressionNodes": 3650, + "maximumFileExpressionNodes": 4171, "maximumMemberExpressionNodes": 330, - "maximumFileDecisionPoints": 250, - "maximumMemberDecisionPoints": 30 + "maximumFileDecisionPoints": 256, + "maximumMemberDecisionPoints": 34 + }, + { + "path": "SharpProof.Effects/OperationEffectScanner.Assignments.cs", + "maximumFileExpressionNodes": 435, + "maximumMemberExpressionNodes": 140, + "maximumFileDecisionPoints": 25, + "maximumMemberDecisionPoints": 10 }, { "path": "SharpProof.Effects/ExternalEffectResolver.cs", diff --git a/eng/acceptance/contract.json b/eng/acceptance/contract.json index 82c5bf40c..aa1c5063b 100644 --- a/eng/acceptance/contract.json +++ b/eng/acceptance/contract.json @@ -42,6 +42,7 @@ "SharpProof.Contracts.Test\\SharpProof.Contracts.Test.csproj": 2, "SharpProof.ContractForGenerator.Test\\SharpProof.ContractForGenerator.Test.csproj": 2, "SharpProof.Frontend.Test\\SharpProof.Frontend.Test.csproj": 2, + "SharpProof.Fuzz.Test\\SharpProof.Fuzz.Test.csproj": 2, "SharpProof.Gates.Test\\SharpProof.Gates.Test.csproj": 2, "SharpProof.Ir.Test\\SharpProof.Ir.Test.csproj": 2, "SharpProof.Smt.Test\\SharpProof.Smt.Test.csproj": 2, @@ -57,13 +58,13 @@ }, "mutationEvidence": { "schemaVersion": 1, - "expectedCatalogCount": 234, - "expectedCatalogSha256": "83298b80c8dd16de224ba72b94b2d0e3ea84d1103868b4e9cbc1db2c9ffecd53" + "expectedCatalogCount": 261, + "expectedCatalogSha256": "66c1d833f29a5f6f6997ba443f0d3f077b6d1094f16b7ba28deeeee32fa5fa81" }, "worker": { "protocolVersion": 11, "manifestSchemaVersion": 4, - "compilerArtifactSchemaVersion": 14, + "compilerArtifactSchemaVersion": 15, "maximumCompilerReferenceModuleBytes": 268435456, "maximumCompilerReferenceClosureBytes": 1073741824, "maximumCompilerReferenceModules": 4096, @@ -209,7 +210,7 @@ }, "trustedComputingBase": { "measurement": "Exact path ownership; complexity is measured separately from formatting with Roslyn syntax metrics.", - "inventorySha256": "6285bf4670402f8922d33f6d42a8a70f11bda0616ce2930e719d7bd7c41ae422", + "inventorySha256": "bfe6906b52cc86d83a1e9279889fa48e75a69a4b4d40358cc433d7917a0dd9a7", "components": [ { "name": "discovery", @@ -498,7 +499,11 @@ "SharpProof.Effects/EffectContractMappings.cs", "SharpProof.Effects/CreationFlowCaptures.cs", "SharpProof.Effects/ConversionOwnershipClassifier.cs", + "SharpProof.Effects/OperationEffectScanner.Assignments.cs", "SharpProof.Effects/OperationEffectScanner.cs", + "SharpProof.Effects/OperationCompletionEvaluator.cs", + "SharpProof.Effects/ExceptionHandlerReachability.cs", + "SharpProof.Effects/SwitchExpressionFacts.cs", "SharpProof.Effects/PropertyDispatchFacts.cs", "SharpProof.Effects/EffectExceptionFlow.cs", "SharpProof.Effects/ManagedAbstractFlow.cs", @@ -612,8 +617,10 @@ "paths": [ "SharpProof.BuildTasks/SharpProof.BuildTasks.csproj", "SharpProof.BuildTasks/InvalidatePublishedResult.cs", + "SharpProof.BuildTasks/Program.cs", "SharpProof.BuildTasks/ResetPublishedVerification.cs", "SharpProof.BuildTasks/RunVerifier.cs", + "SharpProof.BuildTasks/VerifierProcessSupervisor.cs", "SharpProof.Host/SharpProof.Host.csproj", "SharpProof.Host/ContainerContract.cs", "SharpProof.Host/ContainerNativeLibrary.cs", @@ -721,6 +728,15 @@ { "name": "fuzzEvidenceAuthority", "paths": [ + "Tools/SharpProof.Fuzz/FiniteDomainSmtFuzzing.cs", + "Tools/SharpProof.Fuzz/FrontendFuzzing.cs", + "Tools/SharpProof.Fuzz/FuzzDifferential.cs", + "Tools/SharpProof.Fuzz/FuzzOptions.cs", + "Tools/SharpProof.Fuzz/FuzzRunner.cs", + "Tools/SharpProof.Fuzz/PartialTermSmtFuzzing.cs", + "Tools/SharpProof.Fuzz/Program.cs", + "Tools/SharpProof.Fuzz/SharpProof.Fuzz.csproj", + "Tools/SharpProof.Fuzz/packages.lock.json", "scripts/Assert-SharpProofFuzzRunnerResult.ps1", "scripts/SharpProof.FuzzEvidenceLifecycle.ps1", "scripts/Test-SharpProofFuzzEvidenceLifecycle.ps1", @@ -840,6 +856,7 @@ "fuzz": { "pullRequestCases": 1000, "nightlyCases": 10000, + "maximumCampaignCases": 1000000, "maximumParallelism": 4 }, "performance": { diff --git a/eng/acceptance/preview-interface.v1.json b/eng/acceptance/preview-interface.v1.json index 022feb22f..9e8404234 100644 --- a/eng/acceptance/preview-interface.v1.json +++ b/eng/acceptance/preview-interface.v1.json @@ -35,7 +35,7 @@ "versions": { "workerProtocol": 11, "workerManifest": 4, - "compilerArtifact": 14, + "compilerArtifact": 15, "relationalSummary": 2, "specificationPack": 1, "workerCache": 13 diff --git a/eng/agent-notes/status.md b/eng/agent-notes/status.md index a25a45b1c..97e98d3ae 100644 --- a/eng/agent-notes/status.md +++ b/eng/agent-notes/status.md @@ -10,12 +10,12 @@ Current architecture: - container-only verifier package and Core MSBuild host; - one analyzer Core implementation shared by the analyzer, generator, and compiler collector; -- compiler artifact schema 14 and worker protocol 11; +- compiler artifact schema 15 and worker protocol 11; - exact three-package release graph: `SharpProof.Attributes`, `SharpProof`, and `SharpProof.Verifier`. Static acceptance is green for deterministic generation, schema/catalog pins, -the 234-entry mutation catalog identity, the 263-path TCB inventory, frozen +the 261-entry mutation catalog identity, the 348-path TCB inventory, frozen preview interface, and structural complexity. Broad Debug and full Release acceptance are also green. diff --git a/eng/container/entrypoint.sh b/eng/container/entrypoint.sh index 9bba00781..7b7654eac 100644 --- a/eng/container/entrypoint.sh +++ b/eng/container/entrypoint.sh @@ -40,7 +40,8 @@ if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then fi source_has_git=false -if git_directory="$(git -C "${repo_root}" rev-parse --absolute-git-dir 2>/dev/null)"; then +if git_directory="$(git -c safe.directory="${repo_root}" -C "${repo_root}" \ + rev-parse --absolute-git-dir 2>/dev/null)"; then source_has_git=true git config --global --add safe.directory "${repo_root}" git config --global --add safe.directory "${git_directory}" @@ -114,6 +115,7 @@ case "${command_name}" in mkdir -p "${repo_root}/artifacts" if [[ "${source_has_git}" = "true" ]]; then git clone --quiet --shared --no-checkout "${repo_root}" "${task_root}" + git config --global --add safe.directory "${task_root}" source_origin="$(git -C "${repo_root}" remote get-url origin 2>/dev/null || true)" if [[ -n "${source_origin}" ]]; then git -C "${task_root}" remote set-url origin "${source_origin}" @@ -146,7 +148,6 @@ case "${command_name}" in # artifact mount. A trailing-slash ignore rule matches directories but # not this symlink, so exclude the exact task-local link explicitly. printf '/artifacts\n' >> "${task_root}/.git/info/exclude" - git config --global --add safe.directory "${task_root}" fi export SHARPPROOF_REPO_ROOT="${task_root}" cd "${task_root}" diff --git a/eng/coverage/baseline.json b/eng/coverage/baseline.json index e5d2cdf58..07b9e66b0 100644 --- a/eng/coverage/baseline.json +++ b/eng/coverage/baseline.json @@ -1,13 +1,13 @@ { "schemaVersion": 1, "minimumAggregateLinePercent": 86, - "minimumChangedTcbLinePercent": 73.4, + "minimumChangedTcbLinePercent": 73.32, "declarationOnlyTcbFiles": [ "SharpProof.Analyzer.Core/EffectEvaluationTypes.cs" ], "projects": { "SharpProof.Analyzer": 91.71, - "SharpProof.Analyzer.Core": 91.0, + "SharpProof.Analyzer.Core": 86.77, "SharpProof.Attributes": 55.88, "SharpProof.BuildTasks": 82.08, "SharpProof.CompilerArtifact": 90.2, @@ -15,8 +15,9 @@ "SharpProof.ContractForGenerator": 93.92, "SharpProof.Contracts": 88.96, "SharpProof.Dataflow": 91.47, - "SharpProof.Effects": 89.05, + "SharpProof.Effects": 86.43, "SharpProof.Frontend": 81.0, + "SharpProof.Fuzz": 79.0, "SharpProof.Gates": 74.85, "SharpProof.Host": 70.0, "SharpProof.Ir": 88.67, diff --git a/scripts/Assert-SharpProofFuzzRunnerResult.ps1 b/scripts/Assert-SharpProofFuzzRunnerResult.ps1 index 935d2be82..30c61e250 100644 --- a/scripts/Assert-SharpProofFuzzRunnerResult.ps1 +++ b/scripts/Assert-SharpProofFuzzRunnerResult.ps1 @@ -55,13 +55,45 @@ function Assert-SharpProofFuzzRunnerResult { [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][int]$ExpectedCases, [Parameter(Mandatory = $true)][int]$ExpectedSeed, - [Parameter(Mandatory = $true)][int]$ExpectedMaximumParallelism + [Parameter(Mandatory = $true)][int]$ExpectedMaximumParallelism, + [scriptblock]$AfterValidation ) $document = $null + $bytes = $null + $json = $null try { + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read) + try { + if ($stream.Length -eq 0 -or $stream.Length -gt 1048576) { + throw 'The fuzz runner result exceeds its byte limit.' + } + $bytes = [byte[]]::new([int]$stream.Length) + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read( + $bytes, + $offset, + $bytes.Length - $offset) + if ($read -eq 0) { + throw 'The fuzz runner result ended before its declared length.' + } + $offset += $read + } + if ($stream.ReadByte() -ne -1) { + throw 'The fuzz runner result changed during validation.' + } + } + finally { + $stream.Dispose() + } + $json = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) $document = [Text.Json.JsonDocument]::Parse( - [IO.File]::ReadAllText($Path)) + $json) $root = $document.RootElement Assert-ExactJsonObjectProperties -Object $root ` -Description 'Fuzz runner result' ` @@ -85,6 +117,12 @@ function Assert-SharpProofFuzzRunnerResult { $passed = Get-ExactJsonBoolean $root 'Passed' if ($schema -ne 4) { throw "Unsupported fuzz schema '$schema'." } + if ($cases -lt 1) { + throw 'The fuzz runner case count must be positive.' + } + if ($maximumParallelism -lt 1 -or $maximumParallelism -gt 4) { + throw 'The fuzz runner maximum parallelism must be between 1 and 4.' + } if ($cases -ne $ExpectedCases -or $seed -ne $ExpectedSeed -or $maximumParallelism -ne $ExpectedMaximumParallelism) { throw 'The fuzz runner invocation identity does not match its result.' @@ -110,10 +148,21 @@ function Assert-SharpProofFuzzRunnerResult { Assert-ExactJsonObjectProperties -Object $coverage ` -Expected $coverageProperties -Description 'Frontend coverage' foreach ($name in $coverageProperties) { - if ((Get-ExactJsonInt32 $coverage $name) -le 0) { - throw "Frontend coverage '$name' must be positive." + $count = Get-ExactJsonInt32 $coverage $name + if ($count -lt 0 -or ($cases -ge 1000 -and $count -eq 0)) { + throw "Frontend coverage '$name' is invalid for the executed case count." } } + $exceptionTotal = [long](Get-ExactJsonInt32 ` + $coverage 'DivideByZeroExceptions') + + [long](Get-ExactJsonInt32 $coverage 'OverflowExceptions') + + [long](Get-ExactJsonInt32 $coverage 'NullReferenceExceptions') + + [long](Get-ExactJsonInt32 ` + $coverage 'IndexOutOfRangeExceptions') + + [long](Get-ExactJsonInt32 $coverage 'InvalidCastExceptions') + if ($exceptionTotal -gt $cases) { + throw 'Frontend exception coverage exceeds the executed case count.' + } $failures = $root.GetProperty('Failures') if ($failures.ValueKind -ne [Text.Json.JsonValueKind]::Array) { @@ -147,6 +196,13 @@ function Assert-SharpProofFuzzRunnerResult { if ($null -ne $document) { $document.Dispose() } } - return Get-Content -LiteralPath $Path -Raw | - ConvertFrom-Json -ErrorAction Stop + if ($null -ne $AfterValidation) { + & $AfterValidation $Path + } + + $result = $json | ConvertFrom-Json -ErrorAction Stop + $result | Add-Member -NotePropertyName ResultSha256 -NotePropertyValue ( + [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()) + return $result } diff --git a/scripts/Generate-CSharpScalarSemantics.ps1 b/scripts/Generate-CSharpScalarSemantics.ps1 index aef5da8ca..c9edfe02c 100644 --- a/scripts/Generate-CSharpScalarSemantics.ps1 +++ b/scripts/Generate-CSharpScalarSemantics.ps1 @@ -675,6 +675,7 @@ Assert-Properties ` -Allowed @( 'allowReferenceTypes', 'excludedReferenceTypeKinds', + 'excludedSpecialTypes', 'excludeAbstractReferenceTypes', 'specialTypes') ` -Context 'builtInEquality' @@ -693,6 +694,18 @@ if (@($excludedReferenceTypeKinds | Select-Object -Unique).Count -ne $excludedReferenceTypeKinds.Count) { throw 'builtInEquality.excludedReferenceTypeKinds contains duplicates.' } +$excludedEqualitySpecialTypes = @( + $catalog.builtInEquality.excludedSpecialTypes | + ForEach-Object { + Assert-EnumName ` + -Value $_ ` + -Allowed @('System_Delegate', 'System_MulticastDelegate') ` + -Context 'builtInEquality.excludedSpecialTypes' + }) +if (@($excludedEqualitySpecialTypes | Select-Object -Unique).Count -ne + $excludedEqualitySpecialTypes.Count) { + throw 'builtInEquality.excludedSpecialTypes contains duplicates.' +} $excludeAbstractReferenceTypes = Assert-Boolean ` -Value $catalog.builtInEquality.excludeAbstractReferenceTypes ` -Context 'builtInEquality.excludeAbstractReferenceTypes' @@ -994,6 +1007,13 @@ if ($allowReferenceEquality) { } $referencePattern = "{ IsReferenceType: true, TypeKind: not $excludedKindPattern }" + if ($excludedEqualitySpecialTypes.Count -gt 0) { + $excludedSpecialTypePattern = @( + $excludedEqualitySpecialTypes | + ForEach-Object { "SpecialType.$_" }) -join ' or ' + $referencePattern = $referencePattern.TrimEnd(' }') + + ", SpecialType: not ($excludedSpecialTypePattern) }" + } if ($excludeAbstractReferenceTypes) { $referencePattern = "($referencePattern and not INamedTypeSymbol { IsAbstract: true })" } diff --git a/scripts/Generate-CompilerArtifactModel.ps1 b/scripts/Generate-CompilerArtifactModel.ps1 index 0e923620c..3b8356569 100644 --- a/scripts/Generate-CompilerArtifactModel.ps1 +++ b/scripts/Generate-CompilerArtifactModel.ps1 @@ -563,8 +563,8 @@ foreach ($declaration in $declarations) { $envelope = Get-RequiredMember $schema 'artifactEnvelope' 'schema' if ([string](Get-RequiredMember $envelope 'schema' 'artifact envelope') -ne 'SharpProof.CompilerManifest' -or - [int](Get-RequiredMember $envelope 'version' 'artifact envelope') -ne 14) { - throw 'The compiler-artifact envelope must remain schema version 14.' + [int](Get-RequiredMember $envelope 'version' 'artifact envelope') -ne 15) { + throw 'The compiler-artifact envelope must remain schema version 15.' } $catalogs = @(Get-RequiredMember $schema 'wireEnumCatalogs' 'schema') diff --git a/scripts/GeneratedFileHelpers.ps1 b/scripts/GeneratedFileHelpers.ps1 index 97be890de..efef644d1 100644 --- a/scripts/GeneratedFileHelpers.ps1 +++ b/scripts/GeneratedFileHelpers.ps1 @@ -128,8 +128,11 @@ function Update-SharpProofGeneratedFile throw "$DisplayPath is missing. Run $GeneratorCommand." } - $existing = ConvertTo-SharpProofGeneratedText -Text (Get-Content -LiteralPath $Path -Raw) - if (-not [string]::Equals($existing, $normalizedContent, [System.StringComparison]::Ordinal)) + $encoding = [System.Text.UTF8Encoding]::new($false) + $expectedBytes = $encoding.GetBytes($normalizedContent) + $actualBytes = [System.IO.File]::ReadAllBytes($Path) + if ([Convert]::ToBase64String($actualBytes) -cne + [Convert]::ToBase64String($expectedBytes)) { throw "$DisplayPath is stale. Run $GeneratorCommand." } diff --git a/scripts/Get-SharpProofReleaseVersion.ps1 b/scripts/Get-SharpProofReleaseVersion.ps1 index fe350d8ab..4a9834163 100644 --- a/scripts/Get-SharpProofReleaseVersion.ps1 +++ b/scripts/Get-SharpProofReleaseVersion.ps1 @@ -1,3 +1,14 @@ +function Test-SharpProofReleaseVersionSyntax { + param([Parameter(Mandatory = $true)][string]$Version) + + return $Version -cmatch ( + '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.' + + '(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|' + + '(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))' + + '(?:\.(?:(?:0|[1-9][0-9]*)|' + + '(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?\z') +} + function Get-SharpProofReleaseVersion { param([Parameter(Mandatory = $true)][string]$RepositoryRoot) @@ -18,8 +29,7 @@ function Get-SharpProofReleaseVersion { $template.IndexOf('$(SharpProofVersionPrefix)', [StringComparison]::Ordinal) -lt 0 -or $version.Contains('$(', [StringComparison]::Ordinal) -or - $version -notmatch - '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + -not (Test-SharpProofReleaseVersionSyntax -Version $version)) { throw 'SharpProof.Release.props has an invalid package version.' } return $version diff --git a/scripts/Invoke-SharpProofFuzzCampaign.ps1 b/scripts/Invoke-SharpProofFuzzCampaign.ps1 index 5950805c1..c50a7d1b8 100644 --- a/scripts/Invoke-SharpProofFuzzCampaign.ps1 +++ b/scripts/Invoke-SharpProofFuzzCampaign.ps1 @@ -34,15 +34,14 @@ $contract = Get-Content ` -LiteralPath (Join-Path $repositoryRoot 'eng\acceptance\contract.json') ` -Raw | ConvertFrom-Json -$retained = Get-Content ` - -LiteralPath (Join-Path $repositoryRoot 'eng\fuzz\retained-seeds.json') ` - -Raw | - ConvertFrom-Json -if ($retained.schemaVersion -ne 1 -or - [int]$retained.casesPerSeed -le 0 -or - @($retained.seeds).Count -eq 0) { - throw 'Invalid retained fuzz seed manifest.' -} +$nightlyCases = Assert-SharpProofFuzzCaseBudget ` + -Value $contract.fuzz.nightlyCases ` + -Name 'contract.fuzz.nightlyCases' +$retainedManifestPath = Join-Path ` + $repositoryRoot 'eng\fuzz\retained-seeds.json' +$retained = Read-SharpProofRetainedFuzzSeedManifest ` + -Path $retainedManifestPath +$retainedSeeds = @($retained.Seeds) if (-not $PSBoundParameters.ContainsKey('RotatingSeed')) { $RotatingSeed = [int][DateTime]::UtcNow.ToString( 'yyyyMMdd', @@ -52,14 +51,26 @@ $effectiveRotatingCases = if ($RotatingCases -gt 0) { $RotatingCases } else { - [int]$contract.fuzz.nightlyCases + $nightlyCases } $effectiveRetainedCases = if ($RetainedCases -gt 0) { $RetainedCases } else { - [int]$retained.casesPerSeed + $retained.CasesPerSeed } +$maximumCampaignCases = Assert-SharpProofFuzzCaseBudget ` + -Value $contract.fuzz.maximumCampaignCases ` + -Name 'contract.fuzz.maximumCampaignCases' +$retainedRunSeeds = @($retainedSeeds | Where-Object { + [int]$_ -ne $RotatingSeed -or + $effectiveRotatingCases -lt $effectiveRetainedCases + }) +$requestedCampaignCases = Assert-SharpProofFuzzCampaignBudget ` + -RotatingCases $effectiveRotatingCases ` + -RetainedCases $effectiveRetainedCases ` + -RetainedRunCount $retainedRunSeeds.Count ` + -MaximumCases $maximumCampaignCases function Invoke-FuzzRun { param( [Parameter(Mandatory = $true)] @@ -123,6 +134,7 @@ function Invoke-FuzzRun { $abstentions = 0 $runnerSchemaVersion = $null $runnerPassed = $false + $resultSha256 = $null try { if ($process.ExitCode -ne 0) { throw "runner exited with code $($process.ExitCode)" @@ -140,6 +152,7 @@ function Invoke-FuzzRun { $agreements = [int]$result.Agreements $abstentions = [int]$result.Abstentions $runnerPassed = [bool]$result.Passed + $resultSha256 = [string]$result.ResultSha256 } catch { $validationError = $_.Exception.Message @@ -156,9 +169,7 @@ function Invoke-FuzzRun { runnerPassed = $runnerPassed validationPassed = $null -eq $validationError validationError = $validationError - resultSha256 = if (Test-Path -LiteralPath $standardOutput -PathType Leaf) { - (Get-FileHash -LiteralPath $standardOutput -Algorithm SHA256).Hash.ToLowerInvariant() - } else { $null } + resultSha256 = $resultSha256 standardOutput = [IO.Path]::GetRelativePath( $repositoryRoot, $standardOutput).Replace('\', '/') @@ -173,10 +184,7 @@ $runs.Add((Invoke-FuzzRun ` -Name "rotating-$RotatingSeed" ` -Cases $effectiveRotatingCases ` -Seed $RotatingSeed)) -foreach ($seed in @($retained.seeds)) { - if ([int]$seed -eq $RotatingSeed) { - continue - } +foreach ($seed in $retainedRunSeeds) { $runs.Add((Invoke-FuzzRun ` -Name "retained-$seed" ` -Cases $effectiveRetainedCases ` @@ -191,12 +199,9 @@ $summary = [pscustomobject][ordered]@{ rotatingSeed = $RotatingSeed rotatingCases = $effectiveRotatingCases retainedCasesPerSeed = $effectiveRetainedCases - retainedSeeds = @($retained.seeds | ForEach-Object { [int]$_ }) - retainedSeedManifestSha256 = (Get-FileHash -LiteralPath ( - Join-Path $repositoryRoot 'eng\fuzz\retained-seeds.json') ` - -Algorithm SHA256).Hash.ToLowerInvariant() - requestedCases = [int](@($runs | - Measure-Object -Property requestedCases -Sum).Sum) + retainedSeeds = $retainedSeeds + retainedSeedManifestSha256 = $retained.Sha256 + requestedCases = $requestedCampaignCases totalCases = [int](@($runs | Measure-Object -Property observedCases -Sum).Sum) runs = @($runs) diff --git a/scripts/Invoke-SharpProofReleaseContainer.ps1 b/scripts/Invoke-SharpProofReleaseContainer.ps1 index 69fcb87b4..99c9f5165 100644 --- a/scripts/Invoke-SharpProofReleaseContainer.ps1 +++ b/scripts/Invoke-SharpProofReleaseContainer.ps1 @@ -21,6 +21,7 @@ if (-not $IsLinux -or $env:SHARPPROOF_CONTAINER -cne '1') { $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path Set-Location $repositoryRoot . (Join-Path $PSScriptRoot 'Get-SharpProofReleaseVersion.ps1') +. (Join-Path $PSScriptRoot 'Resolve-SharpProofContainedPath.ps1') function Require-Environment([string]$Name) { $value = [Environment]::GetEnvironmentVariable($Name) @@ -31,18 +32,10 @@ function Require-Environment([string]$Name) { } function Resolve-RepositoryPath([string]$Path) { - $candidate = if ([IO.Path]::IsPathRooted($Path)) { - $Path - } - else { - Join-Path $repositoryRoot $Path - } - $resolved = [IO.Path]::GetFullPath($candidate) - $prefix = $repositoryRoot + [IO.Path]::DirectorySeparatorChar - if (-not $resolved.StartsWith($prefix, [StringComparison]::Ordinal)) { - throw "Path must be inside the repository: $resolved" - } - return $resolved + return Resolve-SharpProofContainedPath ` + -Root $repositoryRoot ` + -Path $Path ` + -ParameterName 'Release path' } switch ($Mode) { @@ -99,11 +92,32 @@ switch ($Mode) { 'WriteQualificationEvidence' { $commit = Require-Environment 'GITHUB_SHA' $tag = Require-Environment 'GITHUB_REF_NAME' + $packageRoot = Resolve-RepositoryPath $PackageSource $head = (& git -C $repositoryRoot rev-parse HEAD).Trim() if ($commit -cne $head) { throw "Qualification commit '$commit' does not match checkout HEAD '$head'." } - if (@(& git -C $repositoryRoot status --porcelain).Count -ne 0) { + $trackedChanges = @(& git -C $repositoryRoot status --porcelain ` + --untracked-files=no) + if ($LASTEXITCODE -ne 0) { + throw 'Qualification could not inspect tracked checkout state.' + } + $packageRelativePath = [IO.Path]::GetRelativePath( + $repositoryRoot, + $packageRoot).Replace('\', '/') + $allUntrackedChanges = @(& git -C $repositoryRoot ls-files ` + --others --exclude-standard -- .) + if ($LASTEXITCODE -ne 0) { + throw 'Qualification could not inspect untracked checkout state.' + } + $packagePrefix = $packageRelativePath.TrimEnd('/') + '/' + $untrackedChanges = @($allUntrackedChanges | Where-Object { + -not $_.StartsWith( + $packagePrefix, + [StringComparison]::Ordinal) + }) + if ($trackedChanges.Count -ne 0 -or + $untrackedChanges.Count -ne 0) { throw 'Qualification requires a clean checkout.' } $version = Get-SharpProofReleaseVersion ` @@ -118,7 +132,6 @@ switch ($Mode) { $head) { throw 'Qualification requires an annotated tag at checkout HEAD.' } - $packageRoot = Resolve-RepositoryPath $PackageSource & (Join-Path $repositoryRoot ` 'scripts/Test-SharpProofReleaseArtifacts.ps1') ` -PackageSource $packageRoot ` diff --git a/scripts/Invoke-SharpProofTrustedMutationsParallel.ps1 b/scripts/Invoke-SharpProofTrustedMutationsParallel.ps1 index 741921278..46fbcfff2 100644 --- a/scripts/Invoke-SharpProofTrustedMutationsParallel.ps1 +++ b/scripts/Invoke-SharpProofTrustedMutationsParallel.ps1 @@ -21,6 +21,8 @@ Import-Module (Join-Path ` $PSScriptRoot 'SharpProof.ContainerExecution.psm1') -Force Import-Module (Join-Path ` $PSScriptRoot 'SharpProof.MutationBaselines.psm1') -Force +Import-Module (Join-Path ` + $PSScriptRoot 'SharpProof.MutationEvidence.psm1') -Force $contract = Get-Content -LiteralPath (Join-Path ` $repositoryRoot 'eng/acceptance/contract.json') -Raw | ConvertFrom-Json @@ -339,8 +341,11 @@ foreach ($shard in $shards) { } } $orderedResults = @($orderedResults | Sort-Object catalogOrdinal) +$actualCatalogSha256 = Get-SharpProofMutationCatalogSha256 ` + -Mutations $orderedResults if ($orderedResults.Count -ne $catalogCount -or - @($orderedResults.name | Sort-Object -Unique).Count -ne $catalogCount) { + @($orderedResults.name | Sort-Object -Unique).Count -ne $catalogCount -or + $actualCatalogSha256 -ne $catalogSha256) { throw 'Parallel mutation shards do not cover the exact mutation catalog.' } foreach ($result in $orderedResults) { diff --git a/scripts/Publish-SharpProofRelease.ps1 b/scripts/Publish-SharpProofRelease.ps1 index dce95c2f1..7d802c5c4 100644 --- a/scripts/Publish-SharpProofRelease.ps1 +++ b/scripts/Publish-SharpProofRelease.ps1 @@ -273,8 +273,7 @@ function Get-ValidatedRelease { $manifest ` 'packageVersion' ` 'Release manifest') - if ($version -notmatch - '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + if (-not (Test-SharpProofReleaseVersionSyntax -Version $version)) { throw "Release manifest package version is invalid: '$version'." } Test-SharpProofReleaseVersion ` @@ -650,7 +649,9 @@ function Get-V3PackageBaseAddress { $baseUri.Scheme -ne 'https') { throw 'NuGet PackageBaseAddress must resolve to HTTPS.' } - return $baseUri.AbsoluteUri.TrimEnd('/') + return (Resolve-SharpProofPublicationHttpsDestination ` + -Value $baseUri.AbsoluteUri ` + -Owner 'NuGet PackageBaseAddress').TrimEnd('/') } function Get-RemotePackageState { @@ -723,6 +724,71 @@ function Invoke-NuGetPush { } } +function New-SharpProofPublicationStage { + param( + [Parameter(Mandatory = $true)][object]$Plan, + [Parameter(Mandatory = $true)][object]$InputSnapshot, + [Parameter(Mandatory = $true)][string]$RepositoryCommit + ) + + Test-SharpProofPublicationInputSnapshot -Snapshot $InputSnapshot + $stageRoot = Join-Path ` + ([IO.Path]::GetTempPath()) ` + ('sharpproof-publish-' + [Guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($stageRoot) | Out-Null + & chmod 0700 -- $stageRoot + if ($LASTEXITCODE -ne 0) { + throw 'Could not protect the private publication staging directory.' + } + + foreach ($entry in @($InputSnapshot.entries)) { + $destination = Join-Path ` + $stageRoot ` + ([IO.Path]::GetFileName([string]$entry.path)) + [IO.File]::Copy([string]$entry.path, $destination, $false) + & chmod 0400 -- $destination + if ($LASTEXITCODE -ne 0) { + throw "Could not protect staged publication input '$destination'." + } + } + + $stagedRelease = Get-ValidatedRelease ` + -Directory $stageRoot ` + -RepositoryCommit $RepositoryCommit + $stagedArtifacts = @(New-SharpProofPublicationPlanIdentities ` + -Packages @($stagedRelease.packages) ` + -Directory $stageRoot ` + -Version $stagedRelease.version ` + -RepositoryCommit $RepositoryCommit) + $stagedPlan = $Plan.PSObject.Copy() + $stagedPlan.artifacts = $stagedArtifacts + Test-SharpProofPublicationPlanIdentity -Plan $stagedPlan + + $identityProperties = @( + 'fileName','bytes','sha256','role','version','repositoryCommit') + $expectedIdentities = @($Plan.artifacts | Select-Object $identityProperties) | + ConvertTo-Json -Compress + $stagedIdentities = @($stagedArtifacts | Select-Object $identityProperties) | + ConvertTo-Json -Compress + if ($stagedIdentities -cne $expectedIdentities) { + throw 'Staged publication bytes do not match the certified release plan.' + } + + return [pscustomobject]@{ + Root = $stageRoot + Release = $stagedRelease + Plan = $stagedPlan + } + } + catch { + if (Test-Path -LiteralPath $stageRoot -PathType Container) { + Remove-Item -LiteralPath $stageRoot -Recurse -Force + } + throw + } +} + function Write-PublicationPlan { param( [Parameter(Mandatory = $true)] @@ -805,6 +871,7 @@ $baseAddress = $null if (-not $PlanOnly) { $baseAddress = Get-V3PackageBaseAddress ` -ServiceIndex $publicationDestination.mainDestination + $publicationDestination.packageBaseAddress = $baseAddress } $entries = [Collections.Generic.List[object]]::new() $fixtureCatalog = if ($publicationDestination.mode -ceq 'fixture') { @@ -890,8 +957,8 @@ $plan = [pscustomobject][ordered]@{ -Version $release.version ` -RepositoryCommit $repositoryHead) } +Test-SharpProofPublicationPlanIdentity -Plan $plan if ($PlanOnly) { - Test-SharpProofPublicationPlanIdentity -Plan $plan Write-PublicationPlan ` -Plan $plan ` -OutputPath $resolvedPlanOutputPath ` @@ -910,23 +977,38 @@ $effectiveSymbolApiKey = if ( else { $SymbolApiKey } -for ($index = 0; $index -lt $release.packages.Count; $index++) { - $package = $release.packages[$index] - Write-Host ( - "Publishing $($package.packageId) $($package.version) " + - "main package.") - Invoke-NuGetPush ` - -Path $package.mainPath ` - -Destination $publicationDestination.mainDestination ` - -Key $ApiKey ` - -NoSymbols $true - Write-Host ( - "Publishing $($package.packageId) $($package.version) " + - "symbol package.") - Invoke-NuGetPush ` - -Path $package.symbolsPath ` - -Destination $publicationDestination.symbolDestination ` - -Key $effectiveSymbolApiKey ` - -NoSymbols $false +$publicationStage = New-SharpProofPublicationStage ` + -Plan $plan ` + -InputSnapshot $publicationInputSnapshot ` + -RepositoryCommit $repositoryHead +try { + for ($index = 0; $index -lt $publicationStage.Release.packages.Count; $index++) { + $package = $publicationStage.Release.packages[$index] + Test-SharpProofPublicationPlanIdentity ` + -Plan $publicationStage.Plan + Write-Host ( + "Publishing $($package.packageId) $($package.version) " + + "main package.") + Invoke-NuGetPush ` + -Path $package.mainPath ` + -Destination $publicationDestination.mainDestination ` + -Key $ApiKey ` + -NoSymbols $true + Test-SharpProofPublicationPlanIdentity ` + -Plan $publicationStage.Plan + Write-Host ( + "Publishing $($package.packageId) $($package.version) " + + "symbol package.") + Invoke-NuGetPush ` + -Path $package.symbolsPath ` + -Destination $publicationDestination.symbolDestination ` + -Key $effectiveSymbolApiKey ` + -NoSymbols $false + } +} +finally { + if (Test-Path -LiteralPath $publicationStage.Root -PathType Container) { + Remove-Item -LiteralPath $publicationStage.Root -Recurse -Force + } } Write-PublicationPlan -Plan $plan diff --git a/scripts/Resolve-SharpProofContainedPath.ps1 b/scripts/Resolve-SharpProofContainedPath.ps1 index 729abf668..563956bb1 100644 --- a/scripts/Resolve-SharpProofContainedPath.ps1 +++ b/scripts/Resolve-SharpProofContainedPath.ps1 @@ -1,3 +1,53 @@ +function Resolve-SharpProofPhysicalPath { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$Path + ) + + $fullPath = [IO.Path]::GetFullPath($Path) + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrEmpty($pathRoot)) { + throw "Path has no filesystem root: $fullPath" + } + $relativePath = $fullPath.Substring($pathRoot.Length) + $components = @($relativePath.Split( + [char[]]@( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar), + [StringSplitOptions]::RemoveEmptyEntries)) + $current = $pathRoot + for ($index = 0; $index -lt $components.Count; $index++) { + $next = Join-Path $current $components[$index] + try { + $item = Get-Item -LiteralPath $next -Force -ErrorAction Stop + } + catch [Management.Automation.ItemNotFoundException] { + for ($remainder = $index; + $remainder -lt $components.Count; + $remainder++) { + $current = Join-Path $current $components[$remainder] + } + return [IO.Path]::GetFullPath($current) + } + + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + $target = $item.ResolveLinkTarget($true) + if ($null -eq $target -or -not $target.Exists) { + throw "Path contains an unresolved link: $next" + } + $current = [IO.Path]::GetFullPath($target.FullName) + } + else { + $current = [IO.Path]::GetFullPath($item.FullName) + } + if ($index -lt $components.Count - 1 -and + -not [IO.Directory]::Exists($current)) { + throw "Path traverses a non-directory component: $current" + } + } + return [IO.Path]::GetFullPath($current) +} + function Resolve-SharpProofContainedPath { [CmdletBinding()] param( @@ -7,11 +57,18 @@ function Resolve-SharpProofContainedPath { ) $canonicalRoot = [IO.Path]::GetFullPath($Root) + $pathComparison = if ( + [IO.Path]::DirectorySeparatorChar -eq [char]'\') { + [StringComparison]::OrdinalIgnoreCase + } + else { + [StringComparison]::Ordinal + } $rootPath = [IO.Path]::GetPathRoot($canonicalRoot) if (-not [string]::Equals( $canonicalRoot, $rootPath, - [StringComparison]::Ordinal)) { + $pathComparison)) { $canonicalRoot = $canonicalRoot.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) @@ -26,8 +83,41 @@ function Resolve-SharpProofContainedPath { $prefix = $canonicalRoot + [IO.Path]::DirectorySeparatorChar if (-not $canonicalPath.StartsWith( $prefix, - [StringComparison]::Ordinal)) { + $pathComparison)) { throw "$ParameterName must be a child of '$canonicalRoot': $canonicalPath" } - return $canonicalPath + if (-not [IO.Directory]::Exists($canonicalRoot)) { + throw "Containment root does not exist: $canonicalRoot" + } + # The disposable task workspace intentionally symlinks its "artifacts" + # directory back to the host-mounted repository root so that evidence + # written there survives the container. Physical containment for paths + # under that known, infrastructure-created redirect is checked against + # the symlink's own resolved target rather than the task root; every + # other path is still required to physically resolve inside the task + # root, which catches an unexpected symlink anywhere else in the tree. + $artifactsRoot = Join-Path $canonicalRoot 'artifacts' + $artifactsPrefix = $artifactsRoot + [IO.Path]::DirectorySeparatorChar + $rootForContainment = if ( + [IO.Directory]::Exists($artifactsRoot) -and + ([string]::Equals($canonicalPath, $artifactsRoot, $pathComparison) -or + $canonicalPath.StartsWith($artifactsPrefix, $pathComparison))) { + $artifactsRoot + } + else { + $canonicalRoot + } + $physicalRoot = Resolve-SharpProofPhysicalPath -Path $rootForContainment + $physicalPath = Resolve-SharpProofPhysicalPath -Path $canonicalPath + $physicalPrefix = $physicalRoot + [IO.Path]::DirectorySeparatorChar + if (-not [string]::Equals( + $physicalPath, + $physicalRoot, + [StringComparison]::Ordinal) -and + -not $physicalPath.StartsWith( + $physicalPrefix, + [StringComparison]::Ordinal)) { + throw "$ParameterName must resolve to a child of '$physicalRoot': $physicalPath" + } + return $physicalPath } diff --git a/scripts/SharpProof.FuzzEvidenceLifecycle.ps1 b/scripts/SharpProof.FuzzEvidenceLifecycle.ps1 index 205619c25..4a48f6765 100644 --- a/scripts/SharpProof.FuzzEvidenceLifecycle.ps1 +++ b/scripts/SharpProof.FuzzEvidenceLifecycle.ps1 @@ -1,5 +1,143 @@ Set-StrictMode -Version Latest +function Assert-SharpProofFuzzCaseBudget { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][object]$Value, + [Parameter(Mandatory = $true)][string]$Name + ) + + if (($Value -isnot [int] -and $Value -isnot [int64]) -or + [int64]$Value -le 0 -or [int64]$Value -gt 1000000) { + throw "$Name must be an integer from 1 through 1000000." + } + return [int]$Value +} + +function Assert-SharpProofFuzzCampaignBudget { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][int]$RotatingCases, + [Parameter(Mandatory = $true)][int]$RetainedCases, + [Parameter(Mandatory = $true)][int]$RetainedRunCount, + [Parameter(Mandatory = $true)][int]$MaximumCases + ) + + if ($RotatingCases -le 0 -or $RetainedCases -le 0 -or + $RetainedRunCount -lt 0 -or $RetainedRunCount -gt 1024 -or + $MaximumCases -le 0) { + throw 'Fuzz campaign budget inputs are invalid.' + } + [long]$requestedCases = [long]$RotatingCases + + [long]$RetainedCases * [long]$RetainedRunCount + if ($requestedCases -gt $MaximumCases) { + throw "Fuzz campaign requests $requestedCases cases; maximum is $MaximumCases." + } + return [int]$requestedCases +} + +function Read-SharpProofRetainedFuzzSeedManifest { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [scriptblock]$AfterValidation + ) + + $document = $null + try { + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read) + try { + if ($stream.Length -eq 0 -or $stream.Length -gt 1048576) { + throw 'The retained fuzz seed manifest exceeds its byte limit.' + } + $bytes = [byte[]]::new([int]$stream.Length) + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read( + $bytes, + $offset, + $bytes.Length - $offset) + if ($read -eq 0) { + throw 'The retained fuzz seed manifest changed while read.' + } + $offset += $read + } + if ($stream.ReadByte() -ne -1) { + throw 'The retained fuzz seed manifest changed while read.' + } + } + finally { + $stream.Dispose() + } + $json = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + $document = [Text.Json.JsonDocument]::Parse( + $json) + $root = $document.RootElement + if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'The retained fuzz seed manifest must be an object.' + } + $expected = @('schemaVersion', 'casesPerSeed', 'seeds') + $names = @($root.EnumerateObject() | ForEach-Object { $_.Name }) + if ($names.Count -ne $expected.Count -or + @($names | Where-Object { $expected -cnotcontains $_ }).Count -ne 0) { + throw 'The retained fuzz seed manifest has unexpected properties.' + } + + [int]$schemaVersion = 0 + $schema = $root.GetProperty('schemaVersion') + if ($schema.ValueKind -ne [Text.Json.JsonValueKind]::Number -or + -not $schema.TryGetInt32([ref]$schemaVersion)) { + throw 'The retained fuzz seed schema version must be an exact Int32.' + } + [int]$casesPerSeed = 0 + $cases = $root.GetProperty('casesPerSeed') + if ($cases.ValueKind -ne [Text.Json.JsonValueKind]::Number -or + -not $cases.TryGetInt32([ref]$casesPerSeed)) { + throw 'Retained fuzz cases per seed must be an exact Int32.' + } + $seedValues = $root.GetProperty('seeds') + if ($seedValues.ValueKind -ne [Text.Json.JsonValueKind]::Array) { + throw 'Retained fuzz seeds must be an array.' + } + $seeds = [Collections.Generic.List[int]]::new() + foreach ($element in $seedValues.EnumerateArray()) { + [int]$seed = 0 + if ($element.ValueKind -ne [Text.Json.JsonValueKind]::Number -or + -not $element.TryGetInt32([ref]$seed)) { + throw 'Every retained fuzz seed must be an exact Int32.' + } + $seeds.Add($seed) + } + if ($schemaVersion -ne 1 -or $casesPerSeed -le 0 -or + $casesPerSeed -gt 1000000 -or + $seeds.Count -eq 0 -or $seeds.Count -gt 1024) { + throw 'Invalid retained fuzz seed manifest.' + } + if (@($seeds | Select-Object -Unique).Count -ne $seeds.Count) { + throw 'The retained fuzz seed manifest contains duplicate seeds.' + } + if ($null -ne $AfterValidation) { + & $AfterValidation $Path + } + + return [pscustomobject]@{ + SchemaVersion = $schemaVersion + CasesPerSeed = $casesPerSeed + Seeds = @($seeds) + Sha256 = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + } + } + finally { + if ($null -ne $document) { $document.Dispose() } + } +} + function Initialize-SharpProofFuzzEvidence { [CmdletBinding()] param( @@ -11,9 +149,23 @@ function Initialize-SharpProofFuzzEvidence { foreach ($file in [IO.Directory]::EnumerateFiles($OutputDirectory)) { $name = [IO.Path]::GetFileName($file) if ($name -ceq 'campaign.json' -or - $name -ceq '.campaign.json.tmp' -or - $name -cmatch '^(?:rotating|retained)-[0-9]+\.(?:stdout\.json|stderr\.txt)$') { + $name -ceq '.campaign.json.tmp') { [IO.File]::Delete($file) + continue + } + if ($name -cmatch ` + '^(?:rotating|retained)-(?-?[0-9]+)\.(?:stdout\.json|stderr\.txt)$') { + $seed = 0 + $seedToken = $Matches.seed + if ([int]::TryParse( + $seedToken, + [Globalization.NumberStyles]::Integer, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$seed) -and + $seedToken -ceq $seed.ToString( + [Globalization.CultureInfo]::InvariantCulture)) { + [IO.File]::Delete($file) + } } } } diff --git a/scripts/SharpProof.PublicationDestination.ps1 b/scripts/SharpProof.PublicationDestination.ps1 index 2f11cf61a..bd90ffe83 100644 --- a/scripts/SharpProof.PublicationDestination.ps1 +++ b/scripts/SharpProof.PublicationDestination.ps1 @@ -1,3 +1,8 @@ +if (-not (Get-Command Test-SharpProofReleaseVersionSyntax ` + -CommandType Function -ErrorAction SilentlyContinue)) { + . (Join-Path $PSScriptRoot 'Get-SharpProofReleaseVersion.ps1') +} + function Resolve-SharpProofPublicationHttpsDestination { param( [Parameter(Mandatory = $true)][string]$Value, @@ -103,8 +108,8 @@ function Get-SharpProofPublicationFixtureArchiveCatalog { $id = [string]$ids[0].InnerText $version = [string]$versions[0].InnerText if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or - $version -notmatch - '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + -not (Test-SharpProofReleaseVersionSyntax ` + -Version $version)) { throw "Fixture archive nuspec identity is invalid: '$($file.FullName)'." } $hasDll = @($archive.Entries | Where-Object { @@ -185,6 +190,7 @@ function New-SharpProofPublicationDestinationAuthority { mode = 'fixture' mainDestination = $null symbolDestination = $null + packageBaseAddress = $null fixture = Get-SharpProofPublicationFixtureAuthority ` -FixtureDirectory $FixtureDirectory ` -InputSnapshot $InputSnapshot @@ -196,6 +202,7 @@ function New-SharpProofPublicationDestinationAuthority { mode = 'targetless' mainDestination = $null symbolDestination = $null + packageBaseAddress = $null fixture = $null } } @@ -211,6 +218,7 @@ function New-SharpProofPublicationDestinationAuthority { mode = 'registry' mainDestination = $main symbolDestination = $symbols + packageBaseAddress = $null fixture = $null } } @@ -335,10 +343,13 @@ function Get-SharpProofRemoteMainPackageUrl { [Parameter(Mandatory = $true)][string]$Version ) + $normalizedBaseAddress = Resolve-SharpProofPublicationHttpsDestination ` + -Value $BaseAddress ` + -Owner 'NuGet PackageBaseAddress' $normalizedId = $PackageId.ToLowerInvariant() $normalizedVersion = $Version.ToLowerInvariant() return ( - $BaseAddress.TrimEnd('/') + '/' + + $normalizedBaseAddress.TrimEnd('/') + '/' + [Uri]::EscapeDataString($normalizedId) + '/' + [Uri]::EscapeDataString($normalizedVersion) + '/' + [Uri]::EscapeDataString( diff --git a/scripts/SharpProof.PublicationPlanIdentity.psm1 b/scripts/SharpProof.PublicationPlanIdentity.psm1 index 1b7adbfd0..2abe08ab7 100644 --- a/scripts/SharpProof.PublicationPlanIdentity.psm1 +++ b/scripts/SharpProof.PublicationPlanIdentity.psm1 @@ -1,5 +1,34 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'SharpProof.PublicationPlanTopology.ps1') +. (Join-Path $PSScriptRoot 'SharpProof.PublicationDestination.ps1') + +function Test-SharpProofPublicationVersionSyntax { + param([Parameter(Mandatory = $true)][string]$Version) + + return $Version -cmatch ( + '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.' + + '(0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|' + + '(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))' + + '(?:\.(?:(?:0|[1-9][0-9]*)|' + + '(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?\z') +} + +function Test-SharpProofPublicationCommitSyntax { + param([Parameter(Mandatory = $true)][string]$Commit) + + return $Commit -cmatch '^[0-9a-f]{40}\z' +} + +function Test-SharpProofExactProperties { + param( + [Parameter(Mandatory = $true)][object]$Value, + [Parameter(Mandatory = $true)][string[]]$Expected + ) + + return (@($Value.PSObject.Properties.Name) -join '|') -ceq + ($Expected -join '|') +} function Test-SharpProofPublicationPlanIdentity { [CmdletBinding()] @@ -7,15 +36,170 @@ function Test-SharpProofPublicationPlanIdentity { [Parameter(Mandatory = $true)][object]$Plan ) - if ([int]$Plan.schemaVersion -ne 2) { + if (($Plan.schemaVersion -isnot [int] -and + $Plan.schemaVersion -isnot [int64]) -or + [int64]$Plan.schemaVersion -ne 2) { throw 'Publication plan schema version is unsupported.' } + if (-not (Test-SharpProofExactProperties -Value $Plan -Expected @( + 'schemaVersion','planOnly','packageVersion', + 'versionAuthority','repositoryCommit', + 'publicationDestination','packages','artifacts')) -or + $Plan.planOnly -isnot [bool]) { + throw 'Publication plan schema is invalid.' + } + if ($Plan.packageVersion -isnot [string] -or + $Plan.repositoryCommit -isnot [string]) { + throw 'Publication plan version or commit identity is invalid.' + } $version = [string]$Plan.packageVersion $commit = [string]$Plan.repositoryCommit - if ($version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$' -or - $commit -notmatch '^[0-9a-f]{40}$') { + if (-not (Test-SharpProofPublicationVersionSyntax -Version $version) -or + -not (Test-SharpProofPublicationCommitSyntax -Commit $commit)) { throw 'Publication plan version or commit identity is invalid.' } + $versionAuthority = $Plan.versionAuthority + if (-not (Test-SharpProofExactProperties ` + -Value $versionAuthority -Expected @( + 'schemaVersion','path','property','version','sha256')) -or + ($versionAuthority.schemaVersion -isnot [int] -and + $versionAuthority.schemaVersion -isnot [int64]) -or + [int64]$versionAuthority.schemaVersion -ne 1 -or + $versionAuthority.path -isnot [string] -or + $versionAuthority.path -cne 'SharpProof.Release.props' -or + $versionAuthority.property -isnot [string] -or + $versionAuthority.property -cne 'SharpProofPackageVersion' -or + $versionAuthority.version -isnot [string] -or + $versionAuthority.version -cne $version -or + $versionAuthority.sha256 -isnot [string] -or + $versionAuthority.sha256 -cnotmatch '^[0-9a-f]{64}\z') { + throw 'Publication plan version authority is invalid.' + } + $destination = $Plan.publicationDestination + $fixtureArchives = @() + if (-not (Test-SharpProofExactProperties -Value $destination -Expected @( + 'schemaVersion','mode','mainDestination', + 'symbolDestination','packageBaseAddress','fixture')) -or + ($destination.schemaVersion -isnot [int] -and + $destination.schemaVersion -isnot [int64]) -or + [int64]$destination.schemaVersion -ne 1 -or + $destination.mode -isnot [string] -or + $destination.mode -cnotin @('targetless','fixture','registry')) { + throw 'Publication destination schema is invalid.' + } + switch ([string]$destination.mode) { + 'targetless' { + if (-not $Plan.planOnly -or + $null -ne $destination.mainDestination -or + $null -ne $destination.symbolDestination -or + $null -ne $destination.packageBaseAddress -or + $null -ne $destination.fixture) { + throw 'Targetless publication destination is invalid.' + } + } + 'registry' { + if ($destination.mainDestination -isnot [string] -or + $destination.symbolDestination -isnot [string] -or + $null -ne $destination.fixture) { + throw 'Registry publication destination is invalid.' + } + foreach ($value in @( + $destination.mainDestination, + $destination.symbolDestination)) { + $uri = $null + if (-not [Uri]::TryCreate( + $value, [UriKind]::Absolute, [ref]$uri) -or + $uri.Scheme -cne 'https' -or + [string]::IsNullOrWhiteSpace($uri.Host) -or + -not [string]::IsNullOrEmpty($uri.UserInfo) -or + -not [string]::IsNullOrEmpty($uri.Query) -or + -not [string]::IsNullOrEmpty($uri.Fragment)) { + throw 'Registry publication destination is invalid.' + } + } + if ($Plan.planOnly) { + if ($null -ne $destination.packageBaseAddress) { + throw 'Registry publication destination is invalid.' + } + } + elseif ($destination.packageBaseAddress -isnot [string]) { + throw 'Registry publication destination is invalid.' + } + else { + $baseUri = $null + if (-not [Uri]::TryCreate( + $destination.packageBaseAddress, + [UriKind]::Absolute, + [ref]$baseUri) -or + $baseUri.Scheme -cne 'https' -or + [string]::IsNullOrWhiteSpace($baseUri.Host) -or + -not [string]::IsNullOrEmpty($baseUri.UserInfo) -or + -not [string]::IsNullOrEmpty($baseUri.Query) -or + -not [string]::IsNullOrEmpty($baseUri.Fragment) -or + $baseUri.AbsoluteUri.TrimEnd('/') -cne + $destination.packageBaseAddress) { + throw 'Registry publication destination is invalid.' + } + } + } + 'fixture' { + if (-not $Plan.planOnly -or + $null -ne $destination.mainDestination -or + $null -ne $destination.symbolDestination -or + $null -ne $destination.packageBaseAddress -or + $null -eq $destination.fixture) { + throw 'Fixture publication destination is invalid.' + } + $fixture = $destination.fixture + if (-not (Test-SharpProofExactProperties ` + -Value $fixture -Expected @( + 'path','fileIdentity','entryCount', + 'entriesSha256','archives')) -or + $fixture.path -isnot [string] -or + -not [IO.Path]::IsPathFullyQualified($fixture.path) -or + [IO.Path]::GetFullPath($fixture.path) -cne $fixture.path -or + $fixture.fileIdentity -isnot [string] -or + $fixture.fileIdentity -cnotmatch '^[0-9]+:[0-9]+\z' -or + ($fixture.entryCount -isnot [int] -and + $fixture.entryCount -isnot [int64]) -or + [int64]$fixture.entryCount -lt 0 -or + $fixture.entriesSha256 -isnot [string] -or + $fixture.entriesSha256 -cnotmatch '^[0-9a-f]{64}\z') { + throw 'Fixture publication authority is invalid.' + } + $fixturePrefix = $fixture.path.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) + + [IO.Path]::DirectorySeparatorChar + $fixtureArchiveIdentities = + [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + $fixtureArchives = @($fixture.archives) + foreach ($archive in $fixtureArchives) { + if (-not (Test-SharpProofExactProperties ` + -Value $archive -Expected @( + 'path','packageId','version','role')) -or + $archive.path -isnot [string] -or + -not [IO.Path]::IsPathFullyQualified($archive.path) -or + [IO.Path]::GetFullPath($archive.path) -cne $archive.path -or + -not $archive.path.StartsWith( + $fixturePrefix, [StringComparison]::Ordinal) -or + $archive.packageId -isnot [string] -or + $archive.packageId -cnotmatch + '^[A-Za-z0-9][A-Za-z0-9._-]*\z' -or + $archive.version -isnot [string] -or + -not (Test-SharpProofPublicationVersionSyntax ` + -Version $archive.version) -or + $archive.role -isnot [string] -or + $archive.role -cnotin @('main','symbols') -or + -not $fixtureArchiveIdentities.Add( + $archive.packageId + "`0" + + $archive.version + "`0" + $archive.role)) { + throw 'Fixture publication archive authority is invalid.' + } + } + } + } $artifacts = @($Plan.artifacts) $expectedRoles = @( 'main','symbols','main','symbols','main','symbols', @@ -32,6 +216,13 @@ function Test-SharpProofPublicationPlanIdentity { 'path|fileName|bytes|sha256|role|version|repositoryCommit') { throw 'Publication plan artifact schema is invalid.' } + foreach ($property in @( + 'path','fileName','sha256','role','version', + 'repositoryCommit')) { + if ($artifact.$property -isnot [string]) { + throw 'Publication plan artifact schema is invalid.' + } + } $path = [string]$artifact.path if (-not [IO.Path]::IsPathFullyQualified($path) -or [IO.Path]::GetFullPath($path) -cne $path -or @@ -45,23 +236,186 @@ function Test-SharpProofPublicationPlanIdentity { } $file = Get-Item -LiteralPath $path $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() - if ([int64]$artifact.bytes -ne [int64]$file.Length -or + if ($artifact.bytes -isnot [int64] -or + $artifact.bytes -ne [int64]$file.Length -or [string]$artifact.sha256 -cne $hash) { throw "Publication plan artifact bytes changed: '$path'." } } + if ($destination.mode -ceq 'fixture') { + $packageSource = [IO.Path]::GetDirectoryName( + [string]$artifacts[0].path) + $currentSnapshot = New-SharpProofPublicationInputSnapshot ` + -PackageSource $packageSource ` + -FixtureDirectory ([string]$destination.fixture.path) + $currentFixture = Get-SharpProofPublicationFixtureAuthority ` + -FixtureDirectory ([string]$destination.fixture.path) ` + -InputSnapshot $currentSnapshot + $plannedFixtureJson = ConvertTo-Json ` + -InputObject $destination.fixture -Depth 6 -Compress + $currentFixtureJson = ConvertTo-Json ` + -InputObject $currentFixture -Depth 6 -Compress + if ($currentFixtureJson -cne $plannedFixtureJson) { + throw 'Fixture publication authority changed after plan creation.' + } + } + + $packageIds = @( + 'SharpProof.Attributes','SharpProof','SharpProof.Verifier') + $packages = @($Plan.packages) + if ($packages.Count -ne $packageIds.Count) { + throw 'Publication plan package decisions are incomplete.' + } + for ($index = 0; $index -lt $packages.Count; $index++) { + $package = $packages[$index] + if (-not (Test-SharpProofExactProperties -Value $package -Expected @( + 'packageId','version','mainFileName','symbolsFileName', + 'availabilityMode','remoteState','fixtureState','remoteUrl', + 'mainState','mainAction','symbolsState','symbolsAction'))) { + throw 'Publication plan package decision schema is invalid.' + } + foreach ($property in @( + 'packageId','version','mainFileName','symbolsFileName', + 'availabilityMode','mainState','mainAction', + 'symbolsState','symbolsAction')) { + if ($package.$property -isnot [string]) { + throw 'Publication plan package decision schema is invalid.' + } + } + if ($package.packageId -cne $packageIds[$index] -or + $package.version -cne $version -or + $package.mainFileName -cne $artifacts[$index * 2].fileName -or + $package.symbolsFileName -cne + $artifacts[$index * 2 + 1].fileName -or + $package.availabilityMode -cne $destination.mode) { + throw 'Publication plan package decision identity is invalid.' + } + switch ([string]$destination.mode) { + 'targetless' { + if ($null -ne $package.remoteState -or + $null -ne $package.fixtureState -or + $null -ne $package.remoteUrl -or + $package.mainState -cne 'NotTargeted' -or + $package.mainAction -cne 'None' -or + $package.symbolsState -cne 'NotTargeted' -or + $package.symbolsAction -cne 'None') { + throw 'Targetless package decision is invalid.' + } + } + 'registry' { + $expectedRemote = if ($Plan.planOnly) { + 'Unchecked' + } else { 'Absent' } + $remoteUrlValid = $Plan.planOnly -and + $null -eq $package.remoteUrl + if (-not $Plan.planOnly -and + $package.remoteUrl -is [string]) { + $normalizedId = $package.packageId.ToLowerInvariant() + $normalizedVersion = $package.version.ToLowerInvariant() + $expectedSuffix = '/' + + [Uri]::EscapeDataString($normalizedId) + '/' + + [Uri]::EscapeDataString($normalizedVersion) + '/' + + [Uri]::EscapeDataString( + "$normalizedId.$normalizedVersion.nupkg") + $remoteUrlValid = $package.remoteUrl -ceq + ($destination.packageBaseAddress + $expectedSuffix) + } + if ($package.remoteState -isnot [string] -or + $package.remoteState -cne $expectedRemote -or + $null -ne $package.fixtureState -or + -not $remoteUrlValid -or + $package.mainState -cne $expectedRemote -or + $package.mainAction -cne $(if ($Plan.planOnly) { + 'PreflightThenPush' + } else { 'Push' }) -or + $package.symbolsState -cne 'Unchecked' -or + $package.symbolsAction -cne 'CollisionOnPush') { + throw 'Registry package decision is invalid.' + } + } + 'fixture' { + $matchingFixtureArchives = @($fixtureArchives | Where-Object { + [string]::Equals( + [string]$_.packageId, + [string]$package.packageId, + [StringComparison]::OrdinalIgnoreCase) -and + [string]::Equals( + [string]$_.version, + [string]$package.version, + [StringComparison]::OrdinalIgnoreCase) + }) + $expectedMainState = if (@( + $matchingFixtureArchives | Where-Object { + $_.role -ceq 'main' + }).Count -eq 1) { + 'FixturePresent' + } else { 'FixtureAbsent' } + $expectedSymbolsState = if (@( + $matchingFixtureArchives | Where-Object { + $_.role -ceq 'symbols' + }).Count -eq 1) { + 'FixturePresent' + } else { 'FixtureAbsent' } + if ($null -ne $package.remoteState -or + $null -ne $package.remoteUrl -or + $package.fixtureState -isnot [string] -or + $package.fixtureState -cne $expectedMainState -or + $package.mainState -cne $package.fixtureState -or + $package.mainAction -cne $( + if ($package.fixtureState -ceq 'FixturePresent') { + 'Collision' + } else { 'Push' }) -or + $package.symbolsState -cne $expectedSymbolsState -or + $package.symbolsAction -cne $( + if ($package.symbolsState -ceq 'FixturePresent') { + 'Collision' + } else { 'Push' })) { + throw 'Fixture package decision is invalid.' + } + } + } + } $manifest = Get-Content -LiteralPath $artifacts[6].path -Raw | ConvertFrom-Json - if ([string]$manifest.packageVersion -cne $version -or + $manifestVersionAuthority = $manifest.versionAuthority + if (-not (Test-SharpProofExactProperties ` + -Value $manifestVersionAuthority -Expected @( + 'schemaVersion','path','property','version','sha256')) -or + ($manifestVersionAuthority.schemaVersion -isnot [int] -and + $manifestVersionAuthority.schemaVersion -isnot [int64]) -or + [int64]$manifestVersionAuthority.schemaVersion -ne + [int64]$versionAuthority.schemaVersion -or + $manifestVersionAuthority.path -isnot [string] -or + [string]$manifestVersionAuthority.path -cne + [string]$versionAuthority.path -or + $manifestVersionAuthority.property -isnot [string] -or + [string]$manifestVersionAuthority.property -cne + [string]$versionAuthority.property -or + $manifestVersionAuthority.version -isnot [string] -or + [string]$manifestVersionAuthority.version -cne + [string]$versionAuthority.version -or + $manifestVersionAuthority.sha256 -isnot [string] -or + [string]$manifestVersionAuthority.sha256 -cne + [string]$versionAuthority.sha256 -or + $manifest.packageVersion -isnot [string] -or + $manifest.repository.commit -isnot [string] -or + [string]$manifest.packageVersion -cne $version -or [string]$manifest.repository.commit -cne $commit) { throw 'Publication plan release manifest identity is stale.' } $manifestArtifacts = @($manifest.artifacts) + foreach ($artifact in $manifestArtifacts) { + if ($artifact.fileName -isnot [string] -or + $artifact.sha256 -isnot [string]) { + throw 'Publication plan release manifest schema is invalid.' + } + } foreach ($artifact in @($artifacts[0..5]) + @($artifacts[7])) { $row = @($manifestArtifacts | Where-Object { [string]$_.fileName -ceq [string]$artifact.fileName }) if ($row.Count -ne 1 -or - [int64]$row[0].bytes -ne [int64]$artifact.bytes -or + $row[0].bytes -isnot [int64] -or + $row[0].bytes -ne $artifact.bytes -or [string]$row[0].sha256 -cne [string]$artifact.sha256) { throw 'Publication plan does not agree with the release manifest.' } diff --git a/scripts/Test-SharpProofContainedPathFixtures.ps1 b/scripts/Test-SharpProofContainedPathFixtures.ps1 index ed8be2ad4..5525011bc 100644 --- a/scripts/Test-SharpProofContainedPathFixtures.ps1 +++ b/scripts/Test-SharpProofContainedPathFixtures.ps1 @@ -32,10 +32,39 @@ try { -Path $exact -ParameterName absolute if ($absolute -cne $exact) { throw 'Absolute contained child was rejected.' } Require-Rejection $root root-equality - Require-Rejection (Join-Path $fixture 'repo/out.json') case-distinct-sibling + $caseVariant = Join-Path $fixture 'repo/out.json' + if ([IO.Path]::DirectorySeparatorChar -eq [char]'\') { + $caseResolved = Resolve-SharpProofContainedPath -Root $root ` + -Path $caseVariant -ParameterName case-variant-child + if (-not [string]::Equals( + $caseResolved, + $caseVariant, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Windows case-variant child did not retain filesystem identity.' + } + } + else { + Require-Rejection $caseVariant case-distinct-sibling + } Require-Rejection (Join-Path $fixture 'RepoSibling/out.json') prefix-sibling Require-Rejection '../outside.json' traversal-escape + $insideTarget = Join-Path $root 'artifacts/linked-target' + $outsideTarget = Join-Path $fixture 'Outside' + [IO.Directory]::CreateDirectory($insideTarget) | Out-Null + [IO.Directory]::CreateDirectory($outsideTarget) | Out-Null + $insideLink = Join-Path $root 'inside-link' + $outsideLink = Join-Path $root 'outside-link' + [IO.Directory]::CreateSymbolicLink($insideLink, $insideTarget) | Out-Null + [IO.Directory]::CreateSymbolicLink($outsideLink, $outsideTarget) | Out-Null + $linkedInside = Resolve-SharpProofContainedPath -Root $root ` + -Path 'inside-link/report.json' -ParameterName inside-link + $expectedLinkedInside = Join-Path $insideTarget 'report.json' + if ($linkedInside -cne $expectedLinkedInside) { + throw 'Contained symbolic link did not resolve to its physical target.' + } + Require-Rejection 'outside-link/report.json' symbolic-link-escape + $consumers = @( 'eng/acceptance/Verify.ps1', 'scripts/Generate-DiagnosticDescriptors.ps1', diff --git a/scripts/Test-SharpProofCoverage.ps1 b/scripts/Test-SharpProofCoverage.ps1 index 8be78a35c..aff3e7d00 100644 --- a/scripts/Test-SharpProofCoverage.ps1 +++ b/scripts/Test-SharpProofCoverage.ps1 @@ -556,10 +556,26 @@ function Measure-Coverage { } $projects = [Collections.Generic.List[object]]::new() +$productionPathSet = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal) foreach ($property in $baseline.projects.PSObject.Properties | Sort-Object Name) { $projectName = $property.Name - $prefix = $projectName + '/' + $authorityProjects = @( + $recomputedAuthority.projects | + Where-Object { $_.name -ceq $projectName }) + if ($authorityProjects.Count -ne 1) { + throw ( + "Coverage authority expected exactly one production project " + + "named '$projectName', but found $($authorityProjects.Count).") + } + $projectPath = [string]$authorityProjects[0].projectPath + $projectDirectory = + [IO.Path]::GetDirectoryName($projectPath).Replace('\', '/') + if ([string]::IsNullOrWhiteSpace($projectDirectory)) { + throw "Coverage authority project has no directory: '$projectPath'." + } + $prefix = $projectDirectory.TrimEnd('/') + '/' $paths = @( $lineHits.Keys | Where-Object { @@ -572,6 +588,7 @@ foreach ($property in $baseline.projects.PSObject.Properties | if ($paths.Count -eq 0) { throw "Coverage did not contain production project '$projectName'." } + foreach ($path in $paths) { [void]$productionPathSet.Add($path) } $measurement = Measure-Coverage -Paths $paths $minimum = [double]$property.Value $projects.Add([pscustomobject][ordered]@{ @@ -584,18 +601,6 @@ foreach ($property in $baseline.projects.PSObject.Properties | }) } -$productionPathSet = [Collections.Generic.HashSet[string]]::new( - [StringComparer]::Ordinal) -foreach ($project in $projects) { - $prefix = $project.name + '/' - foreach ($path in $lineHits.Keys) { - if ($path.StartsWith( - $prefix, - [StringComparison]::Ordinal)) { - [void]$productionPathSet.Add($path) - } - } -} $productionPaths = @(ConvertTo-OrdinalSortedArray ` -Values @($productionPathSet)) $aggregate = Measure-Coverage -Paths $productionPaths diff --git a/scripts/Test-SharpProofFuzzEvidenceLifecycle.ps1 b/scripts/Test-SharpProofFuzzEvidenceLifecycle.ps1 index 930666514..29739cc28 100644 --- a/scripts/Test-SharpProofFuzzEvidenceLifecycle.ps1 +++ b/scripts/Test-SharpProofFuzzEvidenceLifecycle.ps1 @@ -16,20 +16,133 @@ try { [IO.File]::WriteAllText((Join-Path $root 'rotating-1.stderr.txt'), 'old') [IO.File]::WriteAllText((Join-Path $root 'retained-2.stdout.json'), 'old') [IO.File]::WriteAllText((Join-Path $root 'retained-2.stderr.txt'), 'old') + [IO.File]::WriteAllText((Join-Path $root 'rotating--1.stdout.json'), 'old') + [IO.File]::WriteAllText((Join-Path $root 'retained--2.stderr.txt'), 'old') + $noncanonical = @( + 'rotating-2147483648.stdout.json', + 'retained--2147483649.stderr.txt', + 'retained-0007.stdout.json') + foreach ($name in $noncanonical) { + [IO.File]::WriteAllText((Join-Path $root $name), 'keep') + } [IO.File]::WriteAllText($unrelated, 'keep') Initialize-SharpProofFuzzEvidence -OutputDirectory $root if ([IO.File]::Exists($campaign) -or - @([IO.Directory]::EnumerateFiles($root) | Where-Object { - [IO.Path]::GetFileName($_) -cmatch - '^(?:rotating|retained)-[0-9]+\.(?:stdout\.json|stderr\.txt)$' - }).Count -ne 0) { + [IO.File]::Exists((Join-Path $root 'rotating-1.stdout.json')) -or + [IO.File]::Exists((Join-Path $root 'rotating-1.stderr.txt')) -or + [IO.File]::Exists((Join-Path $root 'retained-2.stdout.json')) -or + [IO.File]::Exists((Join-Path $root 'retained-2.stderr.txt')) -or + [IO.File]::Exists((Join-Path $root 'rotating--1.stdout.json')) -or + [IO.File]::Exists((Join-Path $root 'retained--2.stderr.txt'))) { throw 'Owned stale fuzz evidence survived initialization.' } + foreach ($name in $noncanonical) { + if ([IO.File]::ReadAllText((Join-Path $root $name)) -cne 'keep') { + throw "Noncanonical fuzz-like output was changed: $name" + } + } if ([IO.File]::ReadAllText($unrelated) -cne 'keep') { throw 'Unrelated fuzz output was changed.' } + $manifest = Join-Path $root 'retained-seeds.json' + [IO.File]::WriteAllText( + $manifest, + '{"schemaVersion":1,"casesPerSeed":5,"seeds":[-1,2]}') + $parsed = Read-SharpProofRetainedFuzzSeedManifest -Path $manifest + if ($parsed.CasesPerSeed -ne 5 -or + @($parsed.Seeds).Count -ne 2 -or $parsed.Seeds[0] -ne -1) { + throw 'A strict retained fuzz seed manifest was not preserved.' + } + $expectedManifestHash = (Get-FileHash -LiteralPath $manifest ` + -Algorithm SHA256).Hash.ToLowerInvariant() + if ($parsed.Sha256 -cne $expectedManifestHash) { + throw 'Retained fuzz seed parsing did not bind the exact input bytes.' + } + $manifestBytes = [IO.File]::ReadAllBytes($manifest) + $raced = Read-SharpProofRetainedFuzzSeedManifest ` + -Path $manifest ` + -AfterValidation { + param($validatedPath) + [IO.File]::WriteAllText( + $validatedPath, + '{"schemaVersion":1,"casesPerSeed":9,"seeds":[7]}') + } + $expectedRacedHash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData( + $manifestBytes)).ToLowerInvariant() + if ($raced.CasesPerSeed -ne 5 -or + $raced.Sha256 -cne $expectedRacedHash) { + throw 'Retained fuzz seed parsing changed after its validated read.' + } + foreach ($invalid in @( + '{"schemaVersion":1,"casesPerSeed":"5","seeds":[1]}', + '{"schemaVersion":1,"casesPerSeed":5,"seeds":[null]}', + '{"schemaVersion":1,"casesPerSeed":5,"seeds":[true]}', + '{"schemaVersion":1,"casesPerSeed":5,"seeds":[1.6]}', + '{"schemaVersion":1,"casesPerSeed":1000001,"seeds":[1]}', + '{"schemaVersion":1,"casesPerSeed":5,"seeds":["7"]}')) { + [IO.File]::WriteAllText($manifest, $invalid) + $rejected = $false + try { [void](Read-SharpProofRetainedFuzzSeedManifest -Path $manifest) } + catch { $rejected = $true } + if (-not $rejected) { + throw "Malformed retained seed manifest was accepted: $invalid" + } + } + foreach ($invalidBudget in @(0, 1000001, '5')) { + $rejected = $false + try { + [void](Assert-SharpProofFuzzCaseBudget ` + -Value $invalidBudget -Name 'fixture budget') + } + catch { $rejected = $true } + if (-not $rejected) { + throw "Invalid fuzz case budget was accepted: $invalidBudget" + } + } + $boundedManifest = + '{"schemaVersion":1,"casesPerSeed":1,"seeds":[1]}' + $encoding = [Text.UTF8Encoding]::new($false) + $exactManifest = $boundedManifest + + (' ' * (1048576 - $encoding.GetByteCount($boundedManifest))) + [IO.File]::WriteAllText($manifest, $exactManifest, $encoding) + $exactParsed = Read-SharpProofRetainedFuzzSeedManifest -Path $manifest + if ($exactParsed.CasesPerSeed -ne 1 -or + @($exactParsed.Seeds).Count -ne 1) { + throw 'The exact-limit retained manifest was not accepted.' + } + [IO.File]::AppendAllText($manifest, ' ', $encoding) + $rejected = $false + try { [void](Read-SharpProofRetainedFuzzSeedManifest -Path $manifest) } + catch { $rejected = $true } + if (-not $rejected) { + throw 'An oversized retained fuzz seed manifest was accepted.' + } + [void](Assert-SharpProofFuzzCampaignBudget ` + -RotatingCases 10000 -RetainedCases 1000 ` + -RetainedRunCount 1 -MaximumCases 1000000) + $rejected = $false + try { + [void](Assert-SharpProofFuzzCampaignBudget ` + -RotatingCases 1000000 -RetainedCases 1 ` + -RetainedRunCount 1 -MaximumCases 1000000) + } + catch { $rejected = $true } + if (-not $rejected) { + throw 'An aggregate fuzz campaign above the maximum was accepted.' + } + $tooManySeeds = '{"schemaVersion":1,"casesPerSeed":1,"seeds":[' + + ((1..1025) -join ',') + ']}' + [IO.File]::WriteAllText($manifest, $tooManySeeds) + $rejected = $false + try { [void](Read-SharpProofRetainedFuzzSeedManifest -Path $manifest) } + catch { $rejected = $true } + if (-not $rejected) { + throw 'A retained manifest above the seed-count limit was accepted.' + } + # A prerequisite or launcher failure after initialization publishes nothing. if ([IO.File]::Exists($campaign)) { throw 'A failed run retained stable campaign evidence.' @@ -51,7 +164,7 @@ try { throw 'Retry did not replace only the owned stable evidence.' } - Write-Host 'Fuzz evidence lifecycle fixtures: 6' + Write-Host 'Fuzz evidence lifecycle fixtures: 22' } finally { if ([IO.Directory]::Exists($root)) { diff --git a/scripts/Test-SharpProofFuzzRunnerResult.ps1 b/scripts/Test-SharpProofFuzzRunnerResult.ps1 index ef31081f0..b41e1574d 100644 --- a/scripts/Test-SharpProofFuzzRunnerResult.ps1 +++ b/scripts/Test-SharpProofFuzzRunnerResult.ps1 @@ -47,12 +47,17 @@ function Assert-Accepted( -ExpectedMaximumParallelism 4 | Out-Null } -function Assert-Rejected([object]$Value, [string]$Name) { +function Assert-Rejected( + [object]$Value, + [string]$Name, + [int]$Cases = 10, + [int]$Seed = 123, + [int]$MaximumParallelism = 4) { try { Assert-SharpProofFuzzRunnerResult ` -Path (Write-Result $Value $Name) ` - -ExpectedCases 10 -ExpectedSeed 123 ` - -ExpectedMaximumParallelism 4 | Out-Null + -ExpectedCases $Cases -ExpectedSeed $Seed ` + -ExpectedMaximumParallelism $MaximumParallelism | Out-Null } catch { return } throw "Fixture '$Name' was unexpectedly accepted." @@ -61,8 +66,64 @@ function Assert-Rejected([object]$Value, [string]$Name) { try { $canonical = New-CanonicalResult 10 123 Assert-Accepted $canonical 'canonical-rotating' - Assert-Accepted (New-CanonicalResult 3 23063) ` - 'canonical-retained' 3 23063 + $hashPath = Write-Result $canonical 'canonical-hash' + $hashed = Assert-SharpProofFuzzRunnerResult ` + -Path $hashPath -ExpectedCases 10 -ExpectedSeed 123 ` + -ExpectedMaximumParallelism 4 + $expectedHash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData( + [IO.File]::ReadAllBytes($hashPath))).ToLowerInvariant() + if ($hashed.ResultSha256 -cne $expectedHash) { + throw 'The fuzz runner result hash did not bind the validated bytes.' + } + $racePath = Write-Result $canonical 'canonical-race' + $raceBytes = [IO.File]::ReadAllBytes($racePath) + $replacement = New-CanonicalResult 10 456 + $raced = Assert-SharpProofFuzzRunnerResult ` + -Path $racePath -ExpectedCases 10 -ExpectedSeed 123 ` + -ExpectedMaximumParallelism 4 ` + -AfterValidation { + param($validatedPath) + $replacement | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $validatedPath + } + $expectedRaceHash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData( + $raceBytes)).ToLowerInvariant() + if ($raced.Seed -ne 123 -or + $raced.ResultSha256 -cne $expectedRaceHash) { + throw 'The fuzz runner result changed after its validated read.' + } + $encoding = [Text.UTF8Encoding]::new($false) + $boundedJson = $canonical | ConvertTo-Json -Depth 8 -Compress + $exactJson = $boundedJson + + (' ' * (1048576 - $encoding.GetByteCount($boundedJson))) + $boundedPath = Join-Path $temporaryRoot 'exact-byte-limit.json' + [IO.File]::WriteAllText($boundedPath, $exactJson, $encoding) + $bounded = Assert-SharpProofFuzzRunnerResult ` + -Path $boundedPath -ExpectedCases 10 -ExpectedSeed 123 ` + -ExpectedMaximumParallelism 4 + if ($bounded.Cases -ne 10 -or $bounded.Seed -ne 123) { + throw 'The exact-limit fuzz runner result was not preserved.' + } + [IO.File]::AppendAllText($boundedPath, ' ', $encoding) + $oversizedRejected = $false + try { + [void](Assert-SharpProofFuzzRunnerResult ` + -Path $boundedPath -ExpectedCases 10 -ExpectedSeed 123 ` + -ExpectedMaximumParallelism 4) + } + catch { $oversizedRejected = $true } + if (-not $oversizedRejected) { + throw 'An oversized fuzz runner result was accepted.' + } + Assert-Accepted (New-CanonicalResult 5 23063) ` + 'canonical-retained' 5 23063 + $small = New-CanonicalResult 1 7 + foreach ($name in @($small.FrontendCoverage.Keys)) { + $small.FrontendCoverage[$name] = 0 + } + Assert-Accepted $small 'canonical-small-budget' 1 7 $fixture = Copy-Result $canonical; $fixture.Cases = '10' Assert-Rejected $fixture 'numeric-string' @@ -76,6 +137,14 @@ try { Assert-Rejected $fixture 'extra-field' $fixture = Copy-Result $canonical; $fixture.SchemaVersion = 3 Assert-Rejected $fixture 'wrong-schema' + $fixture = New-CanonicalResult 0 123 + $fixture.MaximumParallelism = 0 + foreach ($name in @($fixture.FrontendCoverage.Keys)) { + $fixture.FrontendCoverage[$name] = 0 + } + Assert-Rejected $fixture 'zero-domain' 0 123 0 + $fixture = Copy-Result $canonical; $fixture.MaximumParallelism = 5 + Assert-Rejected $fixture 'parallelism-above-domain' 10 123 5 $fixture = Copy-Result $canonical; $fixture.Passed = $false Assert-Rejected $fixture 'false-status' $fixture = Copy-Result $canonical; $fixture.CoverageSatisfied = $false @@ -91,8 +160,16 @@ try { Assert-Rejected $fixture 'extra-coverage-field' $fixture = Copy-Result $canonical; $fixture.FrontendCoverage.ArrayIndexes = '1' Assert-Rejected $fixture 'coverage-numeric-string' - $fixture = Copy-Result $canonical; $fixture.FrontendCoverage.ArrayIndexes = 0 - Assert-Rejected $fixture 'empty-coverage-category' + $expanded = New-CanonicalResult 1000 123 + $expanded.FrontendCoverage.ArrayIndexes = 0 + Assert-Rejected $expanded 'empty-expanded-coverage-category' 1000 + $fixture = Copy-Result $canonical + $fixture.FrontendCoverage.DivideByZeroExceptions = 3 + $fixture.FrontendCoverage.OverflowExceptions = 3 + $fixture.FrontendCoverage.NullReferenceExceptions = 3 + $fixture.FrontendCoverage.IndexOutOfRangeExceptions = 3 + $fixture.FrontendCoverage.InvalidCastExceptions = 3 + Assert-Rejected $fixture 'impossible-exception-total' $fixture = Copy-Result $canonical $fixture.Failures = [object[]]@([ordered]@{ Case = 1; Seed = 123; Oracle = 'frontend'; Original = 'a' @@ -107,4 +184,4 @@ finally { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force } -Write-Host 'Strict fuzz runner result fixtures passed.' +Write-Host 'Strict fuzz runner result fixtures: 26' diff --git a/scripts/Test-SharpProofMutationEvidence.ps1 b/scripts/Test-SharpProofMutationEvidence.ps1 index a92f1366d..908b3cd3c 100644 --- a/scripts/Test-SharpProofMutationEvidence.ps1 +++ b/scripts/Test-SharpProofMutationEvidence.ps1 @@ -375,6 +375,96 @@ function Test-MutationReuseValidation { -Name valid-complete ` -Evidence (New-CompleteEvidence) ` -ExpectSuccess $true + + Remove-Item -LiteralPath $evidencePath -Force + $shardRoot = Join-Path $evidenceDirectory ( + "shards/$commit/release-weighted-v3-focused-baseline-1") + New-Item -ItemType Directory -Path $shardRoot -Force | Out-Null + + $baselineTrx = Join-Path $shardRoot 'baseline.trx' + Copy-Item -LiteralPath ( + Join-Path $receiptDirectory 'first-mutation-baseline.trx') ` + -Destination $baselineTrx + $baselineInvocation = Get-SharpProofMutationBaselineInvocation ` + -Project $catalog[0].Project ` + -Filter $catalog[0].Filter ` + -Configuration Release + [pscustomobject][ordered]@{ + schemaVersion = 2 + commit = $commit + configuration = 'Release' + selection = 'full' + catalogCount = $catalog.Count + catalogSha256 = $catalogSha256 + testCount = 1 + tests = @([pscustomobject][ordered]@{ + project = $catalog[0].Project + filter = $catalog[0].Filter + configuration = 'Release' + invocationSha256 = $baselineInvocation.Sha256 + ledger = @($results[0].baselineSelectedTests) + trx = 'baseline.trx' + trxSha256 = (Get-FileHash ` + -LiteralPath $baselineTrx ` + -Algorithm SHA256).Hash.ToLowerInvariant() + }) + timing = [ordered]@{ + restoreElapsedMilliseconds = 1 + baselineElapsedMilliseconds = 1 + baselineInvocationCount = 1 + } + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath ( + Join-Path $shardRoot 'baseline.json') -Encoding utf8NoBOM + + $syntheticRows = @($results | ForEach-Object { + $_ | ConvertTo-Json -Depth 5 | ConvertFrom-Json + }) + for ($index = 0; $index -lt $syntheticRows.Count; $index++) { + $syntheticRows[$index].name = "synthetic-mutation-$index" + $syntheticRows[$index].file = "Synthetic/Source-$index.cs" + $syntheticRows[$index].project = "Synthetic.Test/Project-$index.csproj" + $syntheticRows[$index].test = "FullyQualifiedName~SyntheticTest$index" + $syntheticRows[$index].original = "synthetic-before-$index" + $syntheticRows[$index].mutated = "synthetic-after-$index" + $syntheticRows[$index] | Add-Member ` + -NotePropertyName catalogOrdinal ` + -NotePropertyValue $index + } + [pscustomobject][ordered]@{ + schemaVersion = 2 + commit = $commit + configuration = 'Release' + selection = 'selected' + catalogCount = $catalog.Count + catalogSha256 = $catalogSha256 + mutationCount = $syntheticRows.Count + killedCount = $syntheticRows.Count + mutations = $syntheticRows + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath ( + Join-Path $shardRoot 'shard-01.json') -Encoding utf8NoBOM + + Remove-Item -LiteralPath $campaignSentinel ` + -Force -ErrorAction SilentlyContinue + $caseOutput = & pwsh -NoLogo -NoProfile -File ( + Join-Path $scripts 'Invoke-SharpProofTrustedMutationsParallel.ps1') ` + -Configuration Release ` + -OutputPath 'artifacts/mutation/trusted-mutations.json' ` + -ExpectedCommit $commit ` + -Parallelism 1 2>&1 + $exitCode = $LASTEXITCODE + if ($exitCode -eq 0) { + throw 'Synthetic cached mutation shard rows were accepted as full evidence.' + } + if ([string]::Join("`n", @($caseOutput)) -notlike ` + '*do not cover the exact mutation catalog*') { + throw "Unexpected cached-shard rejection: $caseOutput" + } + if (Test-Path -LiteralPath $evidencePath) { + throw 'Rejected cached mutation shards published full evidence.' + } + if (Test-Path -LiteralPath $campaignSentinel) { + throw 'Mutation campaign launched while validating cached shards.' + } } $zeroInfrastructure = 'error="0" timeout="0" aborted="0" inconclusive="0" notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="0" inProgress="0" pending="0" passedButRunAborted="0"' diff --git a/scripts/Test-SharpProofPublicationDestinationFixtures.ps1 b/scripts/Test-SharpProofPublicationDestinationFixtures.ps1 index aa71b4b6d..fc22a213c 100644 --- a/scripts/Test-SharpProofPublicationDestinationFixtures.ps1 +++ b/scripts/Test-SharpProofPublicationDestinationFixtures.ps1 @@ -9,7 +9,8 @@ param( 'actions-registry-unchecked','actions-registry-absent', 'actions-symbol-preflight','actions-swapped', 'actions-removed-projection','mocked-main-missing', - 'mocked-main-exists','mocked-main-error','zero-symbol-preflight', + 'mocked-main-exists','mocked-main-error','mocked-main-query-base', + 'zero-symbol-preflight', 'fixture-empty','fixture-foreign','fixture-main-case-collision', 'fixture-symbol-case-collision','fixture-arbitrary-name', 'fixture-wrong-id','fixture-wrong-version','fixture-nested-collision', @@ -235,9 +236,13 @@ try { packageId = 'SharpProof' version = '1.0.0-preview.1' } + $baseAddress = if ($Mutation -eq 'mocked-main-query-base') { + 'https://packages.example.test/v3-flatcontainer?q=1' + } + else { 'https://packages.example.test/v3-flatcontainer' } $result = Invoke-SharpProofMainPackagePreflight ` -Package $package ` - -BaseAddress 'https://packages.example.test/v3-flatcontainer' ` + -BaseAddress $baseAddress ` -Get { param($uri, $outputPath) $script:preflightCalls.Add([string]$uri) diff --git a/scripts/Test-SharpProofPublicationPlanIdentityFixtures.ps1 b/scripts/Test-SharpProofPublicationPlanIdentityFixtures.ps1 index 1fedfed15..e0b9327d3 100644 --- a/scripts/Test-SharpProofPublicationPlanIdentityFixtures.ps1 +++ b/scripts/Test-SharpProofPublicationPlanIdentityFixtures.ps1 @@ -2,18 +2,69 @@ param( [Parameter(Mandatory = $true)] [ValidateSet('canonical','changed-symbol','stale-manifest','stale-sbom', - 'stale-checksums','missing-identity','duplicate-identity','two-bundle')] + 'stale-checksums','missing-identity','duplicate-identity', + 'version-syntax','commit-syntax','string-schema','decimal-bytes', + 'array-version','array-commit','array-artifact-text', + 'destination-tamper','package-action-tamper','fixture-canonical', + 'fixture-authority-tamper','fixture-nonexistent-archive', + 'registry-canonical', + 'registry-url-tamper','targetless-publish-tamper', + 'version-authority-hash-tamper','json-roundtrip','two-bundle')] [string]$Mutation ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'SharpProof.PublicationPlanIdentity.psm1') -Force +. (Join-Path $PSScriptRoot 'SharpProof.PublicationPlanTopology.ps1') +. (Join-Path $PSScriptRoot 'SharpProof.PublicationDestination.ps1') $root = Join-Path ([IO.Path]::GetTempPath()) ( 'sharpproof-plan-identity-' + [Guid]::NewGuid().ToString('N')) $version = '1.0.0-preview.1' $commit = '0123456789abcdef0123456789abcdef01234567' try { + if ($Mutation -eq 'version-syntax') { + $module = Get-Module SharpProof.PublicationPlanIdentity + $canonicalAccepted = & $module { + param($Candidate) + Test-SharpProofPublicationVersionSyntax -Version $Candidate + } $version + $lineFeedAccepted = & $module { + param($Candidate) + Test-SharpProofPublicationVersionSyntax -Version $Candidate + } "$version`n" + $unicodeAccepted = & $module { + param($Candidate) + Test-SharpProofPublicationVersionSyntax -Version $Candidate + } ('1.2.3-' + [char]0x212a) + if (-not $canonicalAccepted -or $lineFeedAccepted -or + $unicodeAccepted) { + throw 'Publication version syntax is not strictly anchored.' + } + Write-Host 'Publication plan identity fixture passed: version-syntax' + return + } + if ($Mutation -eq 'commit-syntax') { + $module = Get-Module SharpProof.PublicationPlanIdentity + $canonicalAccepted = & $module { + param($Candidate) + Test-SharpProofPublicationCommitSyntax -Commit $Candidate + } $commit + $lineFeedAccepted = & $module { + param($Candidate) + Test-SharpProofPublicationCommitSyntax -Commit $Candidate + } "$commit`n" + $uppercaseAccepted = & $module { + param($Candidate) + Test-SharpProofPublicationCommitSyntax -Commit $Candidate + } ('A' * 40) + if (-not $canonicalAccepted -or $lineFeedAccepted -or + $uppercaseAccepted) { + throw 'Publication commit syntax is not strictly anchored.' + } + Write-Host 'Publication plan identity fixture passed: commit-syntax' + return + } [IO.Directory]::CreateDirectory($root) | Out-Null $packages = [Collections.Generic.List[object]]::new() $artifactRows = [Collections.Generic.List[object]]::new() @@ -43,6 +94,13 @@ try { $manifestPath = Join-Path $root 'SharpProof.release.json' $manifest = [pscustomobject][ordered]@{ packageVersion = $version + versionAuthority = [pscustomobject][ordered]@{ + schemaVersion = 1 + path = 'SharpProof.Release.props' + property = 'SharpProofPackageVersion' + version = $version + sha256 = '0' * 64 + } repository = [pscustomobject][ordered]@{ commit = $commit } artifacts = @($artifactRows) } @@ -60,10 +118,88 @@ try { -RepositoryCommit $commit) $plan = [pscustomobject][ordered]@{ schemaVersion = 2 + planOnly = $true packageVersion = $version + versionAuthority = [pscustomobject][ordered]@{ + schemaVersion = 1 + path = 'SharpProof.Release.props' + property = 'SharpProofPackageVersion' + version = $version + sha256 = '0' * 64 + } repositoryCommit = $commit + publicationDestination = [pscustomobject][ordered]@{ + schemaVersion = 1 + mode = 'targetless' + mainDestination = $null + symbolDestination = $null + packageBaseAddress = $null + fixture = $null + } + packages = @($packages | ForEach-Object -Begin { $index = 0 } -Process { + $id = @('SharpProof.Attributes','SharpProof','SharpProof.Verifier')[$index] + $index++ + [pscustomobject][ordered]@{ + packageId = $id + version = $version + mainFileName = [IO.Path]::GetFileName($_.mainPath) + symbolsFileName = [IO.Path]::GetFileName($_.symbolsPath) + availabilityMode = 'targetless' + remoteState = $null + fixtureState = $null + remoteUrl = $null + mainState = 'NotTargeted' + mainAction = 'None' + symbolsState = 'NotTargeted' + symbolsAction = 'None' + } + }) artifacts = $identities } + if ($Mutation -in @( + 'fixture-canonical','fixture-authority-tamper', + 'fixture-nonexistent-archive')) { + $fixtureRoot = Join-Path $root 'fixture' + [IO.Directory]::CreateDirectory($fixtureRoot) | Out-Null + $fixtureSnapshot = New-SharpProofPublicationInputSnapshot ` + -PackageSource $root -FixtureDirectory $fixtureRoot + $plan.publicationDestination.mode = 'fixture' + $plan.publicationDestination.fixture = + Get-SharpProofPublicationFixtureAuthority ` + -FixtureDirectory $fixtureRoot ` + -InputSnapshot $fixtureSnapshot + foreach ($package in $plan.packages) { + $package.availabilityMode = 'fixture' + $package.fixtureState = 'FixtureAbsent' + $package.mainState = 'FixtureAbsent' + $package.mainAction = 'Push' + $package.symbolsState = 'FixtureAbsent' + $package.symbolsAction = 'Push' + } + } + if ($Mutation -in @('registry-canonical','registry-url-tamper')) { + $plan.planOnly = $false + $plan.publicationDestination.mode = 'registry' + $plan.publicationDestination.mainDestination = + 'https://api.example.test/v3/index.json' + $plan.publicationDestination.symbolDestination = + 'https://api.example.test/v3/index.json' + $plan.publicationDestination.packageBaseAddress = + 'https://api.example.test/v3-flatcontainer' + foreach ($package in $plan.packages) { + $normalizedId = $package.packageId.ToLowerInvariant() + $package.availabilityMode = 'registry' + $package.remoteState = 'Absent' + $package.remoteUrl = + 'https://api.example.test/v3-flatcontainer/' + + "$normalizedId/$version/" + + "$normalizedId.$version.nupkg" + $package.mainState = 'Absent' + $package.mainAction = 'Push' + $package.symbolsState = 'Unchecked' + $package.symbolsAction = 'CollisionOnPush' + } + } switch ($Mutation) { 'changed-symbol' { [IO.File]::AppendAllText($packages[0].symbolsPath, 'changed') } 'stale-manifest' { [IO.File]::AppendAllText($manifestPath, 'changed') } @@ -71,6 +207,52 @@ try { 'stale-checksums' { [IO.File]::AppendAllText($sums, 'changed') } 'missing-identity' { $plan.artifacts = @($plan.artifacts | Select-Object -Skip 1) } 'duplicate-identity' { $plan.artifacts[1].path = $plan.artifacts[0].path } + 'string-schema' { $plan.schemaVersion = '2' } + 'array-version' { $plan.packageVersion = @($version) } + 'array-commit' { $plan.repositoryCommit = @($commit) } + 'array-artifact-text' { + $plan.artifacts[0].sha256 = @($plan.artifacts[0].sha256) + } + 'version-authority-hash-tamper' { + $plan.versionAuthority.sha256 = '1' * 64 + } + 'destination-tamper' { + $plan.publicationDestination.mode = 'registry' + } + 'package-action-tamper' { + $plan.packages[0].mainAction = 'Push' + } + 'fixture-authority-tamper' { + $plan.publicationDestination.fixture = 'tampered' + } + 'fixture-nonexistent-archive' { + $fixture = $plan.publicationDestination.fixture + $fixture.archives = @([pscustomobject][ordered]@{ + path = Join-Path $fixture.path 'missing.nupkg' + packageId = $plan.packages[0].packageId + version = $version + role = 'main' + }) + $plan.packages[0].fixtureState = 'FixturePresent' + $plan.packages[0].mainState = 'FixturePresent' + $plan.packages[0].mainAction = 'Collision' + } + 'registry-url-tamper' { + $id = $plan.packages[0].packageId.ToLowerInvariant() + $plan.packages[0].remoteUrl = + 'https://attacker.invalid/v3-flatcontainer/' + + "$id/$version/$id.$version.nupkg" + } + 'targetless-publish-tamper' { + $plan.planOnly = $false + } + 'json-roundtrip' { + $plan = $plan | ConvertTo-Json -Depth 8 | ConvertFrom-Json + } + 'decimal-bytes' { + $plan.artifacts[0].bytes = + [double]$plan.artifacts[0].bytes + 0.4 + } 'two-bundle' { $first = ($plan.artifacts | ConvertTo-Json -Depth 4) [IO.File]::AppendAllText($packages[0].mainPath, 'other') @@ -82,7 +264,25 @@ try { return } } - Test-SharpProofPublicationPlanIdentity -Plan $plan + try { + Test-SharpProofPublicationPlanIdentity -Plan $plan + if ($Mutation -eq 'fixture-nonexistent-archive') { + throw 'Fixture replay accepted a nonexistent archive.' + } + } + catch { + if ($Mutation -eq 'fixture-nonexistent-archive') { + if ($_.Exception.Message -notlike + '*Fixture publication authority changed*') { + throw "Fixture replay failed for the wrong reason: $($_.Exception.Message)" + } + Write-Host ( + 'Publication plan identity fixture passed: ' + + 'fixture-nonexistent-archive rejected') + return + } + throw + } Write-Host "Publication plan identity fixture passed: $Mutation" } finally { diff --git a/scripts/Test-SharpProofReleaseArtifacts.ps1 b/scripts/Test-SharpProofReleaseArtifacts.ps1 index 66dec369c..5bb3abe52 100644 --- a/scripts/Test-SharpProofReleaseArtifacts.ps1 +++ b/scripts/Test-SharpProofReleaseArtifacts.ps1 @@ -44,10 +44,12 @@ $resolvedSource = (Resolve-Path ` if (-not (Test-Path -LiteralPath $resolvedSource -PathType Container)) { throw "PackageSource is not a directory: $resolvedSource" } -if ($ExpectedTag -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$') { +if (-not $ExpectedTag.StartsWith('v', [StringComparison]::Ordinal) -or + -not (Test-SharpProofReleaseVersionSyntax ` + -Version $ExpectedTag.Substring(1))) { throw "Release tag must be v: $ExpectedTag" } -$expectedVersion = $Matches['version'] +$expectedVersion = $ExpectedTag.Substring(1) Test-SharpProofReleaseVersion ` -ExpectedVersion $releaseVersion ` -ActualVersion $expectedVersion ` diff --git a/scripts/Test-SharpProofReleaseTagFixtures.ps1 b/scripts/Test-SharpProofReleaseTagFixtures.ps1 index 95f25ed72..9c1c8b034 100644 --- a/scripts/Test-SharpProofReleaseTagFixtures.ps1 +++ b/scripts/Test-SharpProofReleaseTagFixtures.ps1 @@ -55,6 +55,9 @@ try { Copy-Item -LiteralPath ( Join-Path $repositoryRoot 'scripts/Get-SharpProofReleaseVersion.ps1') ` -Destination (Join-Path $checkout 'scripts/Get-SharpProofReleaseVersion.ps1') + Copy-Item -LiteralPath ( + Join-Path $repositoryRoot 'scripts/Resolve-SharpProofContainedPath.ps1') ` + -Destination (Join-Path $checkout 'scripts/Resolve-SharpProofContainedPath.ps1') Copy-Item -LiteralPath (Join-Path $repositoryRoot 'SharpProof.Release.props') ` -Destination (Join-Path $checkout 'SharpProof.Release.props') & git -C $checkout add -- . diff --git a/scripts/Test-SharpProofTrustedMutations.ps1 b/scripts/Test-SharpProofTrustedMutations.ps1 index 112c9c435..7d086be08 100644 --- a/scripts/Test-SharpProofTrustedMutations.ps1 +++ b/scripts/Test-SharpProofTrustedMutations.ps1 @@ -240,8 +240,8 @@ $mutations = @( [pscustomobject]@{ Name = 'smt-strict-less-than' File = 'SharpProof.Smt\IrSmtBackend.cs' - Original = '_context.MkLt(Integer(left), Integer(right)),' - Mutated = '_context.MkLe(Integer(left), Integer(right)),' + Original = '_context.MkLt(Integer(left), Integer(right))), defined)' + Mutated = '_context.MkLe(Integer(left), Integer(right))), defined)' Project = 'SharpProof.Smt.Test\SharpProof.Smt.Test.csproj' Filter = 'FullyQualifiedName~StrictComparisonDoesNotAcceptEqualityBoundary' }, @@ -336,7 +336,7 @@ $mutations = @( [pscustomobject]@{ Name = 'frontend-delegate-reference-equality' File = 'SharpProof.Frontend\CSharpScalarSemantics.generated.cs' - Original = ' type is null or ({ IsReferenceType: true, TypeKind: not TypeKind.Delegate } and not INamedTypeSymbol { IsAbstract: true }) ||' + Original = ' type is null or { IsReferenceType: true, TypeKind: not TypeKind.Delegate, SpecialType: not (SpecialType.System_Delegate or SpecialType.System_MulticastDelegate) } ||' Mutated = ' type is null or { IsReferenceType: true } ||' Project = 'SharpProof.Frontend.Test\SharpProof.Frontend.Test.csproj' Filter = 'FullyQualifiedName~UnsupportedValueDomainsCannotMasqueradeAsReferenceEquality' @@ -672,8 +672,8 @@ $mutations = @( [pscustomobject]@{ Name = 'effect-authority-source-tree-binding' File = 'SharpProof.CompilerArtifact\CompilerEffectAuthority.cs' - Original = ' authority.SourceTreeSha256 == tree.Sha256;' - Mutated = ' true;' + Original = ' authority.SourceTreeSha256 == tree.Sha256 &&' + Mutated = ' true &&' Project = 'SharpProof.Worker.Test\SharpProof.Worker.Test.csproj' Filter = 'FullyQualifiedName~EffectAuthorityBindsConstraintsEvidenceAndSourceTreeOrigin' }, @@ -768,15 +768,15 @@ $mutations = @( [pscustomobject]@{ Name = 'cache-read-lock-coordination' File = 'SharpProof.Worker\VerificationCache.cs' - Original = " using var cacheLock = AcquireLock(_directory);`n ValidatePath(path);`n var json = await WorkerProtocolJson.ReadUtf8FileAsync(path, cancellationToken)" - Mutated = ' var json = await WorkerProtocolJson.ReadUtf8FileAsync(path, cancellationToken)' + Original = " cacheLock = AcquireLock(_directory);`n ValidatePath(path);" + Mutated = ' ValidatePath(path);' Project = 'SharpProof.Worker.Test\SharpProof.Worker.Test.csproj' Filter = 'FullyQualifiedName~CacheDirectoryLockMakesReadMissAndWriteUnavailable' }, [pscustomobject]@{ Name = 'cache-write-lock-coordination' File = 'SharpProof.Worker\VerificationCache.cs' - Original = " using var cacheLock = AcquireLock(_directory);`n var payload = JsonSerializer.Serialize(new CachePayload(" + Original = " cacheLock = AcquireLock(_directory);`n var payload = JsonSerializer.Serialize(new CachePayload(" Mutated = ' var payload = JsonSerializer.Serialize(new CachePayload(' Project = 'SharpProof.Worker.Test\SharpProof.Worker.Test.csproj' Filter = 'FullyQualifiedName~CacheDirectoryLockMakesReadMissAndWriteUnavailable' @@ -1024,8 +1024,8 @@ $mutations = @( [pscustomobject]@{ Name = 'launcher-checks-discovered-runtime-paths' File = 'SharpProof.Worker.Launcher\Program.cs' - Original = " runtimeSnapshot?.ComponentPaths.Any(path =>`n !runtimeRoots.Contains(path, StringComparer.Ordinal) &&`n !LauncherArguments.LauncherRuntimePaths.Contains(`n path, StringComparer.Ordinal) &&`n !paths.Add(path)) is true" - Mutated = ' runtimeSnapshot?.ComponentPaths.Any(path => path.Length == 0) == true' + Original = " .Concat(runtimeSnapshot?.ComponentPaths.Where(path =>`n !runtimeRoots.Contains(path, StringComparer.Ordinal) &&`n !LauncherArguments.LauncherRuntimePaths.Contains(`n path, StringComparer.Ordinal)) ?? [])" + Mutated = ' .Concat(runtimeSnapshot?.ComponentPaths.Where(path => path.Length == 0) ?? [])' Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' Filter = 'FullyQualifiedName~RequestProjectionRejectsDiscoveredRuntimeAssetCollisionBeforeManifestRead' }, @@ -1056,8 +1056,8 @@ $mutations = @( [pscustomobject]@{ Name = 'targets-protect-protocol-companion-path' File = 'SharpProof.BuildTasks\InvalidatePublishedResult.cs' - Original = ' WorkerProtocolPath)' - Mutated = ' InvocationManifestPath)' + Original = ' .Append(WorkerProtocolPath)' + Mutated = ' .Append(InvocationManifestPath)' Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' Filter = 'FullyQualifiedName~LauncherProtocolAssetRemainsProtectedByTargets' }, @@ -1072,8 +1072,8 @@ $mutations = @( [pscustomobject]@{ Name = 'launcher-rejects-cache-inside-worker-tree' File = 'SharpProof.Worker.Launcher\Program.cs' - Original = " candidates`n .Skip(runtimeRoots.Length +`n LauncherArguments.LauncherRuntimePaths.Length)`n .OfType()`n .Any(path => LinuxPathIdentity.IsSameOrDescendant(`n path,`n Path.GetDirectoryName(workerPath)!))" - Mutated = ' false' + Original = " if (writablePaths.Any(path => runtimeDirectories.Any(directory =>`n LinuxPathIdentity.IsSameOrDescendant(path, directory))))" + Mutated = ' if (false)' Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' Filter = 'FullyQualifiedName~DirectLauncherRejectsCacheInsideWorkerRuntimeDirectory' }, @@ -1112,11 +1112,83 @@ $mutations = @( [pscustomobject]@{ Name = 'build-task-cancel-active-process' File = 'SharpProof.BuildTasks\RunVerifier.cs' - Original = ' if (!process.HasExited)' - Mutated = ' if (process.HasExited)' + Original = ' if (!ReferenceEquals(_process, process) ||' + Mutated = ' if (ReferenceEquals(_process, process) ||' Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' Filter = 'FullyQualifiedName~ActiveVerifierTaskCancellationStopsTheProcess' }, + [pscustomobject]@{ + Name = 'verifier-supervisor-requires-subreaper' + File = 'SharpProof.BuildTasks\VerifierProcessSupervisor.cs' + Original = " ChildSubreaper,`n 1," + Mutated = " ChildSubreaper,`n 0," + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~VerifierSupervisorStopsSessionEscapingDescendants' + }, + [pscustomobject]@{ + Name = 'verifier-supervisor-reports-incomplete-cleanup' + File = 'SharpProof.BuildTasks\VerifierProcessSupervisor.cs' + Original = ' Complete: DescendantProcessIds(supervisorId).Count == 0);' + Mutated = ' Complete: true);' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~VerifierSupervisorReportsBoundedCleanupFailure' + }, + [pscustomobject]@{ + Name = 'verifier-task-retains-incomplete-cleanup-anchor' + File = 'SharpProof.BuildTasks\RunVerifier.cs' + Original = ' if (retainCleanupAnchor && process != null)' + Mutated = ' if ($false && retainCleanupAnchor && process != null)' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~VerifierExecutionRetainsLiveIncompleteCleanupAnchor' + }, + [pscustomobject]@{ + Name = 'verifier-task-requires-authenticated-cleanup-receipt' + File = 'SharpProof.BuildTasks\RunVerifier.cs' + Original = ' if (!authenticationRequired || cleanupAuthenticated)' + Mutated = ' if (true || !authenticationRequired || cleanupAuthenticated)' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~MissingCleanupReceiptInvokesContainmentFailureDecision' + }, + [pscustomobject]@{ + Name = 'verifier-output-drain-detects-capture-limit' + File = 'SharpProof.BuildTasks\RunVerifier.cs' + Original = ' if (count > remaining)' + Mutated = ' if (false && count > remaining)' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~VerifierOutputDrainIsBoundedAndStillAuthenticatesCleanup' + }, + [pscustomobject]@{ + Name = 'verifier-output-limit-interrupts-foreground-wait' + File = 'SharpProof.BuildTasks\RunVerifier.cs' + Original = ' while (!_cancellationSignal.IsSet && !_outputLimitSignal.IsSet)' + Mutated = ' while (!_cancellationSignal.IsSet)' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~OversizedVerifierOutputTriggersPromptBoundedContainment' + }, + [pscustomobject]@{ + Name = 'verifier-output-limit-interrupts-output-wait' + File = 'SharpProof.BuildTasks\RunVerifier.cs' + Original = '_outputLimitSignal.IsSet;' + Mutated = 'false;' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~OversizedOutputWithIncompleteCleanupReturnsPromptly' + }, + [pscustomobject]@{ + Name = 'verifier-armed-state-precedes-output-completion' + File = 'SharpProof.BuildTasks\RunVerifier.cs' + Original = ' supervisorArmedSignal?.TrySetResult(true);' + Mutated = ' _ = supervisorArmedSignal;' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~VerifierArmedStateIsPublishedIndependentlyOfOutputCompletion' + }, + [pscustomobject]@{ + Name = 'verifier-interrupted-authentication-defers-protocol-drain' + File = 'SharpProof.BuildTasks\RunVerifier.cs' + Original = ' return authenticationRequired && !outputCompleted;' + Mutated = ' return false;' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~InterruptedAuthenticationWaitDefersIncompleteProtocolDrain' + }, [pscustomobject]@{ Name = 'compiler-linked-module-closure' File = 'SharpProof.CompilerCollector\CompilerArtifact\CompilerCompilationCapture.cs' @@ -1208,8 +1280,8 @@ $mutations = @( [pscustomobject]@{ Name = 'release-authority-contained-path-case-sensitivity' File = 'scripts\Resolve-SharpProofContainedPath.ps1' - Original = " if (-not `$canonicalPath.StartsWith(`n `$prefix,`n [StringComparison]::Ordinal)) {" - Mutated = " if (-not `$canonicalPath.StartsWith(`n `$prefix,`n [StringComparison]::OrdinalIgnoreCase)) {" + Original = " if (-not `$physicalPath.StartsWith(`n `$physicalPrefix,`n [StringComparison]::Ordinal)) {" + Mutated = " if (-not `$physicalPath.StartsWith(`n `$physicalPrefix,`n [StringComparison]::OrdinalIgnoreCase)) {" Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' Filter = 'FullyQualifiedName~LinuxEvidencePathsUseOrdinalCanonicalContainment' }, @@ -1240,8 +1312,8 @@ $mutations = @( [pscustomobject]@{ Name = 'publication-reset-removes-owned-markers' File = 'SharpProof.Host\LinuxPathIdentity.cs' - Original = ' File.Delete(markerPath);' - Mutated = ' _ = markerPath;' + Original = " foreach (var markerPath in markerPaths)`n {`n cancellationToken.ThrowIfCancellationRequested();`n File.Delete(markerPath);`n }" + Mutated = " foreach (var markerPath in markerPaths)`n {`n cancellationToken.ThrowIfCancellationRequested();`n _ = markerPath;`n }" Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' Filter = 'FullyQualifiedName~PublicationResetRemovesOnlyCompleteOwnedSet' }, @@ -1336,8 +1408,8 @@ $mutations = @( [pscustomobject]@{ Name = 'fuzz-result-requires-positive-coverage' File = 'scripts\Assert-SharpProofFuzzRunnerResult.ps1' - Original = '(Get-ExactJsonInt32 $coverage $name) -le 0' - Mutated = '(Get-ExactJsonInt32 $coverage $name) -lt 0' + Original = '($cases -ge 1000 -and $count -eq 0)' + Mutated = '$false' Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' Filter = 'FullyQualifiedName~FuzzRunnerEvidenceUsesStrictSchemaFourDecoder' }, @@ -1540,6 +1612,7 @@ $mutations = @( prepared.Origin, prepared.EvidenceSha256, prepared.EvidenceIdentity, + prepared.DependencyEvidence, guard, relation)); '@).Trim() @@ -1549,6 +1622,7 @@ $mutations = @( prepared.Origin, prepared.EvidenceSha256, prepared.EvidenceIdentity, + prepared.DependencyEvidence, guard, factory.Boolean(true))); '@).Trim() @@ -1757,8 +1831,8 @@ $mutations = @( [pscustomobject]@{ Name = 'release-exact-spdx-checksum-row' File = 'scripts\Test-SharpProofPackageDependencies.ps1' - Original = ' if ($rows.Count -ne 1 -or $null -eq $rows[0]) {' - Mutated = ' if ($null -eq $rows[0]) {' + Original = " `$rows = @(`$checksumProperty.Value)`n if (`$rows.Count -ne 1 -or `$null -eq `$rows[0]) {" + Mutated = " `$rows = @(`$checksumProperty.Value)`n if (`$null -eq `$rows[0]) {" Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' Filter = 'FullyQualifiedName~SpdxChecksumRowsAreExact' }, @@ -1781,8 +1855,8 @@ $mutations = @( [pscustomobject]@{ Name = 'compiler-diagnostic-one-based-location' File = 'SharpProof.CompilerCollector\CompilerArtifact\CompilerManifestArtifactProducer.cs' - Original = ' Line = source ? span.StartLinePosition.Line + 1 : 0,' - Mutated = ' Line = source ? span.StartLinePosition.Line : 0,' + Original = ' Line = source ? span.StartLinePosition.Line + 1 : 0,' + Mutated = ' Line = source ? span.StartLinePosition.Line : 0,' Project = 'SharpProof.Analyzer.Test\SharpProof.Analyzer.Test.csproj' Filter = 'FullyQualifiedName~CompilerDiagnosticLocationsUseOneBasedMappedCoordinates' }, @@ -1810,11 +1884,27 @@ $mutations = @( [pscustomobject]@{ Name = 'release-publication-plan-identity-replay' File = 'scripts\Publish-SharpProofRelease.ps1' - Original = ' Test-SharpProofPublicationPlanIdentity -Plan $plan' - Mutated = ' # Test-SharpProofPublicationPlanIdentity -Plan $plan' + Original = "}`nTest-SharpProofPublicationPlanIdentity -Plan `$plan`nif (`$PlanOnly) {" + Mutated = "}`n# Test-SharpProofPublicationPlanIdentity -Plan `$plan`nif (`$PlanOnly) {" Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' Filter = 'FullyQualifiedName~PublisherValidatesCurrentIdentitiesBeforeAndAfterWritingPlan' }, + [pscustomobject]@{ + Name = 'release-publication-plan-manifest-version-authority' + File = 'scripts\SharpProof.PublicationPlanIdentity.psm1' + Original = ' [string]$manifestVersionAuthority.sha256 -cne' + Mutated = ' $false -and' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~ReplayRehashesEveryImmutablePlanInput' + }, + [pscustomobject]@{ + Name = 'release-publication-plan-fixture-authority-replay' + File = 'scripts\SharpProof.PublicationPlanIdentity.psm1' + Original = ' if ($currentFixtureJson -cne $plannedFixtureJson) {' + Mutated = ' if ($false) {' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~ReplayRehashesEveryImmutablePlanInput' + }, [pscustomobject]@{ Name = 'release-publication-destination-mode-exclusivity' File = 'scripts\SharpProof.PublicationDestination.ps1' @@ -1919,8 +2009,8 @@ $mutations = @( [pscustomobject]@{ Name = 'documentation-resource-concurrency-claim-count' File = 'scripts\Generate-Readme.ps1' - Original = ' if ($claimCount -cne 1) {' - Mutated = ' if ($false) {' + Original = " `$resourceText,`n [regex]::Escape(`$claim)).Count`n if (`$claimCount -cne 1) {" + Mutated = " `$resourceText,`n [regex]::Escape(`$claim)).Count`n if (`$false) {" Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' Filter = 'FullyQualifiedName~DocumentationSupportContractRejectsDrift' }, @@ -1983,8 +2073,28 @@ $mutations = @( [pscustomobject]@{ Name = 'publication-complete-topology-preflight' File = 'SharpProof.Host\LinuxPathIdentity.cs' - Original = ' ValidatePublicationTopology(canonicalPaths);' - Mutated = ' _ = canonicalPaths;' + Original = (@' + if (requestedPaths.Length == 0) + { + throw new ArgumentException( + "At least one publication path is required.", + nameof(publicationPaths)); + } + + var canonicalPaths = CanonicalPublicationPaths(requestedPaths); + ValidatePublicationTopology(canonicalPaths); +'@).Trim() + Mutated = (@' + if (requestedPaths.Length == 0) + { + throw new ArgumentException( + "At least one publication path is required.", + nameof(publicationPaths)); + } + + var canonicalPaths = CanonicalPublicationPaths(requestedPaths); + _ = canonicalPaths; +'@).Trim() Project = 'SharpProof.Worker.Test\SharpProof.Worker.Test.csproj' Filter = 'FullyQualifiedName~NestedPublicationSetsFailBeforeAnyFilesystemMutation' }, @@ -2057,6 +2167,134 @@ $mutations = @( Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' Filter = 'FullyQualifiedName~FuzzCampaignEvidenceLifecycleIsFailClosedAndAtomic' }, + [pscustomobject]@{ + Name = 'fuzz-result-hash-uses-validated-bytes' + File = 'scripts\Assert-SharpProofFuzzRunnerResult.ps1' + Original = ' [Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant())' + Mutated = ' [Security.Cryptography.SHA256]::HashData([IO.File]::ReadAllBytes($Path))).ToLowerInvariant())' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~FuzzResultFixtureIsStrictAndFailClosed' + }, + [pscustomobject]@{ + Name = 'fuzz-result-byte-upper-bound' + File = 'scripts\Assert-SharpProofFuzzRunnerResult.ps1' + Original = '$stream.Length -eq 0 -or $stream.Length -gt 1048576' + Mutated = '$stream.Length -eq 0 -or $false' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~FuzzResultFixtureIsStrictAndFailClosed' + }, + [pscustomobject]@{ + Name = 'fuzz-runner-case-upper-bound' + File = 'Tools\SharpProof.Fuzz\FuzzRunner.cs' + Original = 'options.Cases <= 0 || options.Cases > FuzzOptions.MaximumCases' + Mutated = 'options.Cases <= 0 || false' + Project = 'SharpProof.Fuzz.Test\SharpProof.Fuzz.Test.csproj' + Filter = 'FullyQualifiedName~DirectRunnerRejectsInvalidOptions' + }, + [pscustomobject]@{ + Name = 'fuzz-runner-failure-retention-upper-bound' + File = 'Tools\SharpProof.Fuzz\FuzzRunner.cs' + Original = 'failed && keys.Count < MaximumRetainedFailures' + Mutated = 'failed' + Project = 'SharpProof.Fuzz.Test\SharpProof.Fuzz.Test.csproj' + Filter = 'FullyQualifiedName~FailureEvidenceRetentionUsesDeterministicBoundedKeys' + }, + [pscustomobject]@{ + Name = 'fuzz-runner-partial-abstention-classification' + File = 'Tools\SharpProof.Fuzz\FuzzRunner.cs' + Original = 'partialStatus == FuzzOracleStatus.Mismatch;' + Mutated = 'partialStatus != FuzzOracleStatus.Agreement;' + Project = 'SharpProof.Fuzz.Test\SharpProof.Fuzz.Test.csproj' + Filter = 'FullyQualifiedName~PartialAbstentionIsNotClassifiedAsMismatchEvidence' + }, + [pscustomobject]@{ + Name = 'frontend-fuzz-batch-compile-failure-isolation' + File = 'Tools\SharpProof.Fuzz\FrontendFuzzing.cs' + Original = ' if (generatedCases.Count == 1)' + Mutated = ' if (true)' + Project = 'SharpProof.Fuzz.Test\SharpProof.Fuzz.Test.csproj' + Filter = 'FullyQualifiedName~FrontendBatchCompileFailureIsIsolatedToInvalidCase' + }, + [pscustomobject]@{ + Name = 'frontend-semantic-edge-compile-failure-isolation' + File = 'Tools\SharpProof.Fuzz\FrontendFuzzing.cs' + Original = ' if (cases.Count == 1)' + Mutated = ' if (true)' + Project = 'SharpProof.Fuzz.Test\SharpProof.Fuzz.Test.csproj' + Filter = 'FullyQualifiedName~CompileInvalidSemanticEdgeDoesNotPoisonValidPeer' + }, + [pscustomobject]@{ + Name = 'frontend-semantic-edge-shape-failure-isolation' + File = 'Tools\SharpProof.Fuzz\FrontendFuzzing.cs' + Original = ' if (methods.Length != cases.Count ||' + Mutated = ' if (true ||' + Project = 'SharpProof.Fuzz.Test\SharpProof.Fuzz.Test.csproj' + Filter = 'FullyQualifiedName~CompileSuccessfulSemanticEdgeInjectionDoesNotPoisonValidPeer' + }, + [pscustomobject]@{ + Name = 'verifier-output-drain-rechecks-interruption' + File = 'SharpProof.BuildTasks\RunVerifier.cs' + Original = 'if (isInterrupted())' + Mutated = 'if (false)' + Project = 'SharpProof.Package.Test\SharpProof.Package.Test.csproj' + Filter = 'FullyQualifiedName~OutputDrainWaitRechecksInterruptionsBetweenBoundedSlices' + }, + [pscustomobject]@{ + Name = 'retained-fuzz-manifest-hash-uses-validated-bytes' + File = 'scripts\SharpProof.FuzzEvidenceLifecycle.ps1' + Original = ' [Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()' + Mutated = ' [Security.Cryptography.SHA256]::HashData([IO.File]::ReadAllBytes($Path))).ToLowerInvariant()' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~FuzzCampaignEvidenceLifecycleIsFailClosedAndAtomic' + }, + [pscustomobject]@{ + Name = 'retained-fuzz-manifest-cases-upper-bound' + File = 'scripts\SharpProof.FuzzEvidenceLifecycle.ps1' + Original = ' $casesPerSeed -gt 1000000 -or' + Mutated = ' $false -or' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~FuzzCampaignEvidenceLifecycleIsFailClosedAndAtomic' + }, + [pscustomobject]@{ + Name = 'retained-fuzz-manifest-byte-upper-bound' + File = 'scripts\SharpProof.FuzzEvidenceLifecycle.ps1' + Original = '$stream.Length -eq 0 -or $stream.Length -gt 1048576' + Mutated = '$stream.Length -eq 0 -or $false' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~FuzzCampaignEvidenceLifecycleIsFailClosedAndAtomic' + }, + [pscustomobject]@{ + Name = 'compiler-manifest-opened-handle-nonempty' + File = 'SharpProof.CompilerArtifact\CompilerManifestArtifact.cs' + Original = ' if (stream.Length <= 0)' + Mutated = ' if (false)' + Project = 'SharpProof.Worker.Test\SharpProof.Worker.Test.csproj' + Filter = 'FullyQualifiedName~CompilerManifestReaderRejectsEmptyOpenedFile' + }, + [pscustomobject]@{ + Name = 'retained-fuzz-manifest-seed-count-upper-bound' + File = 'scripts\SharpProof.FuzzEvidenceLifecycle.ps1' + Original = '$seeds.Count -eq 0 -or $seeds.Count -gt 1024' + Mutated = '$seeds.Count -eq 0 -or $false' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~FuzzCampaignEvidenceLifecycleIsFailClosedAndAtomic' + }, + [pscustomobject]@{ + Name = 'fuzz-campaign-aggregate-case-upper-bound' + File = 'scripts\SharpProof.FuzzEvidenceLifecycle.ps1' + Original = ' if ($requestedCases -gt $MaximumCases) {' + Mutated = ' if ($false) {' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~FuzzCampaignEvidenceLifecycleIsFailClosedAndAtomic' + }, + [pscustomobject]@{ + Name = 'fuzz-contract-case-budget-upper-bound' + File = 'scripts\SharpProof.FuzzEvidenceLifecycle.ps1' + Original = '[int64]$Value -le 0 -or [int64]$Value -gt 1000000' + Mutated = '[int64]$Value -le 0 -or $false' + Project = 'SharpProof.ArchitectureTest\SharpProof.ArchitectureTest.csproj' + Filter = 'FullyQualifiedName~FuzzCampaignEvidenceLifecycleIsFailClosedAndAtomic' + }, [pscustomobject]@{ Name = 'compiler-source-location-exact-mapped-geometry' File = 'SharpProof.CompilerArtifact\CompilerSourceLocationAuthority.cs' diff --git a/scripts/Write-SharpProofQualificationReceipt.ps1 b/scripts/Write-SharpProofQualificationReceipt.ps1 index c25b7f0e7..403b9176d 100644 --- a/scripts/Write-SharpProofQualificationReceipt.ps1 +++ b/scripts/Write-SharpProofQualificationReceipt.ps1 @@ -89,6 +89,7 @@ $valid = switch -Regex ($Gate) { $packageArtifacts.Count -eq 6 } 'pilots' { + [string]$evidence.reviewStatus -ceq 'Reviewed' -and (Test-SharpProofPilotReport -Report $evidence -ExpectedCommit $commit ` -RepositoryRoot $repositoryRoot) -and $packageArtifacts.Count -eq 6