diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39371efa..7b40e20d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,7 +207,12 @@ jobs: python-version: "3.13" - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: - dotnet-version: "8.0.x" + # 8.0.x runs the pinned net8.0 extractor/probe; 9.0.x is only needed to BUILD the + # deliberately-incompatible net9 wrapper fixture in the step 11 Tier B suite (which + # then proves it is refused WRAPPER_RUNTIME_UNSUPPORTED under the fixed net8 probe). + dotnet-version: | + 8.0.x + 9.0.x - name: Extract OwnIR facts from sample C# run: | dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ @@ -260,6 +265,10 @@ jobs: env: OWN_TIERB_REQUIRED: "1" run: python tests/test_verify_delta_tierb.py + - name: S2 step 11 verified-target-wrapper gate (Tier B, full public CLI) + env: + OWN_TIERB_REQUIRED: "1" + run: python tests/test_verify_target_tierb.py - name: Check facts through the core run: | out=$(python -m ownlang ownir "$RUNNER_TEMP/facts.json" || true) diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/OwnSharp.WeakTargetProbe.csproj b/frontend/roslyn/OwnSharp.WeakTargetProbe/OwnSharp.WeakTargetProbe.csproj new file mode 100644 index 00000000..93f934d3 --- /dev/null +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/OwnSharp.WeakTargetProbe.csproj @@ -0,0 +1,24 @@ + + + + + Exe + net8.0 + enable + enable + OwnSharp.WeakTargetProbe + true + + + + + + + diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs new file mode 100644 index 00000000..b90d2916 --- /dev/null +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs @@ -0,0 +1,760 @@ +// S2 Step 11 — OwnSharp.WeakTargetProbe. See the .csproj for the two-mode contract. +using System.ComponentModel; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using System.Text; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +internal static class Program +{ + private static int Main(string[] args) + { + try + { + if (args.Length >= 1 && args[0] == "bind") return BindMode.Run(args); + if (args.Length >= 1 && args[0] == "probe") return ProbeMode.Run(args); + Console.Error.WriteLine("weak-target-probe: usage: (bind|probe) ..."); + return 2; + } + catch (Exception e) + { + Console.Error.WriteLine($"weak-target-probe: internal error ({e.GetType().Name}: {e.Message})"); + return 2; + } + } + + // --- deterministic canonical JSON (sorted keys, compact, trailing LF) for a restricted + // value domain (bool / non-negative long / printable-ASCII string / array / object) so the + // bytes byte-match Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False). + internal static void WriteCanonical(string path, object value) + { + var sb = new StringBuilder(); + Emit(sb, value); + sb.Append('\n'); + File.WriteAllBytes(path, Encoding.UTF8.GetBytes(sb.ToString())); + } + + private static void Emit(StringBuilder sb, object? v) + { + switch (v) + { + case null: sb.Append("null"); break; + case bool b: sb.Append(b ? "true" : "false"); break; + case int i: sb.Append(i.ToString(System.Globalization.CultureInfo.InvariantCulture)); break; + case long l: sb.Append(l.ToString(System.Globalization.CultureInfo.InvariantCulture)); break; + case string s: EmitString(sb, s); break; + case IReadOnlyList arr: + sb.Append('['); + for (var k = 0; k < arr.Count; k++) { if (k > 0) sb.Append(','); Emit(sb, arr[k]); } + sb.Append(']'); + break; + case IReadOnlyDictionary obj: + sb.Append('{'); + var keys = obj.Keys.ToList(); + keys.Sort(StringComparer.Ordinal); + for (var k = 0; k < keys.Count; k++) + { + if (k > 0) sb.Append(','); + EmitString(sb, keys[k]); + sb.Append(':'); + Emit(sb, obj[keys[k]]); + } + sb.Append('}'); + break; + default: throw new InvalidOperationException($"non-canonical value type {v.GetType()}"); + } + } + + private static void EmitString(StringBuilder sb, string s) + { + sb.Append('"'); + foreach (var ch in s) + { + if (ch == '"' || ch == '\\') { sb.Append('\\').Append(ch); } + else if (ch < 0x20) throw new InvalidOperationException("control char in canonical string"); + else sb.Append(ch); + } + sb.Append('"'); + } + + // The single canonical signature form used by BOTH bind (Roslyn) and probe (reflection). + internal static string Sig(string returnFull, string declFull, string method, IEnumerable paramFull) + => $"{returnFull} {declFull}.{method}({string.Join(", ", paramFull)})"; +} + +// --- BIND MODE (G1, G2) ------------------------------------------------------------------ +internal static class BindMode +{ + public static int Run(string[] args) + { + var a = Args.Parse(args, 1); + var preText = SourceText.From(File.ReadAllText(a["preimage"]), Encoding.UTF8); + var postText = SourceText.From(File.ReadAllText(a["postimage"]), Encoding.UTF8); + var target = a["target"]; + var selectedType = a["selected-class"]; + var sourceFile = a["source-file"]; + var slotsDir = a["slots-dir"]; + using var bp = JsonDocument.Parse(File.ReadAllBytes(a["bind-params"])); + + var parse = new CSharpParseOptions(LanguageVersion.CSharp12, DocumentationMode.None); + var preTree = CSharpSyntaxTree.ParseText(preText, parse, sourceFile); + var postTree = CSharpSyntaxTree.ParseText(postText, parse, sourceFile); + var (refs, slotByPath) = References.Build(slotsDir); + // only the POSTIMAGE is compiled (semantic model); the preimage is used syntactically + // (span location + identity + the frozen replacement text), so its class type does not + // clash with the postimage's. + var comp = CSharpCompilation.Create("weaktargetbind", + new[] { postTree }, refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, deterministic: true)); + var postModel = comp.GetSemanticModel(postTree); + + var preRoot = preTree.GetRoot(); + var postRoot = postTree.GetRoot(); + + // 1-3: locate every converted preimage AddAssignment at its hash-bound span; revalidate + // identity; reproduce the frozen Step 8 replacement text; record (start, len, replacement). + var edits = new List(); + foreach (var cand in bp.RootElement.GetProperty("converted").EnumerateArray()) + { + var fid = cand.GetProperty("finding_id").GetString()!; + var span = new TextSpan(cand.GetProperty("acquire_span").GetProperty("start").GetInt32(), + cand.GetProperty("acquire_span").GetProperty("length").GetInt32()); + var node = preRoot.FindNode(span, getInnermostNodeForTie: true); + if (node is not AssignmentExpressionSyntax asg || asg.Span != span + || !asg.IsKind(SyntaxKind.AddAssignmentExpression)) + return Refuse("CALLSITE_BINDING", $"{fid}: preimage span is not an event += acquire"); + var replacement = ReplacementText(asg, target, cand, out var err); + if (replacement is null) return Refuse("CALLSITE_BINDING", $"{fid}: {err}"); + if ((cand.GetProperty("file").GetString() ?? "") != sourceFile) + return Refuse("CALLSITE_BINDING", $"{fid}: candidate file is not the target file"); + edits.Add(new Edit(fid, span.Start, span.Length, replacement, + cand.GetProperty("source").GetString()!, + cand.GetProperty("normalized_handler").GetString()!)); + } + if (edits.Count == 0) return Refuse("CALLSITE_BINDING", "no converted candidates to bind"); + + // 4: ordered non-overlapping edits, cumulative length deltas -> each derived postimage span. + edits.Sort((x, y) => x.PreStart.CompareTo(y.PreStart)); + for (var i = 1; i < edits.Count; i++) + if (edits[i].PreStart < edits[i - 1].PreStart + edits[i - 1].PreLen) + return Refuse("CALLSITE_BINDING", "overlapping converted acquire spans"); + // every converted finding id appears exactly once. + var convertedFids = new HashSet(StringComparer.Ordinal); + foreach (var e in edits) + if (!convertedFids.Add(e.Fid)) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: converted finding id appears more than once"); + long delta = 0; + var callsites = new List>(); + IMethodSymbol? firstSym = null; + int derivedOrdinal = -1; + string? asmName = null, mvid = null, token = null, sig = null; + foreach (var e in edits) + { + var postStart = (int)(e.PreStart + delta); + var postSpan = new TextSpan(postStart, e.Replacement.Length); + delta += e.Replacement.Length - e.PreLen; + + // 5-6: exactly one invocation node exactly filling the derived postimage span. + var pnode = postRoot.FindNode(postSpan, getInnermostNodeForTie: true); + if (pnode is not InvocationExpressionSyntax inv || inv.Span != postSpan) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: no invocation at the derived postimage span"); + if (inv.Expression.ToString() != target) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation target is not plan.target_api.subscribe"); + var argList = inv.ArgumentList.Arguments; + if (argList.Count != 2 + || argList[0].Expression.ToString() != e.Source + || Rewrite.NormalizeHandler(argList[1].Expression).ToString() != e.NormalizedHandler) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation arguments do not match the candidate"); + + // the invocation must sit in the accepted source tree/file and inside the selected class. + if (inv.SyntaxTree != postTree || inv.SyntaxTree.FilePath != sourceFile) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation is not in the accepted source file"); + var enclosingType = postModel.GetEnclosingSymbol(inv.SpanStart)?.ContainingType; + if (enclosingType is null || FQ(enclosingType) != selectedType) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation is not inside the selected class"); + + // 7: a converted callsite may pass ONLY on a cleanly bound Symbol that is an + // IMethodSymbol. Symbol == null is ALWAYS a refusal — CandidateSymbols are inspected only + // to choose the category: exactly one candidate wrapper slot of the wrong frozen shape is + // WRAPPER_BINDING; anything else (ambiguous / multiple / otherwise unresolved) is + // CALLSITE_BINDING. A candidate method is never promoted to the bound symbol. + var si = postModel.GetSymbolInfo(inv); + if (si.Symbol is not IMethodSymbol sym) + return RefuseUnbound(e.Fid, si, comp, slotByPath, target); + if (SymbolEqualityComparer.Default.Equals(sym.ContainingAssembly, comp.Assembly)) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: target is source-defined, not a reference wrapper"); + if (firstSym is null) + { + firstSym = sym; + var mref = comp.GetMetadataReference(sym.ContainingAssembly) as PortableExecutableReference; + var path = mref?.FilePath ?? ""; + if (!slotByPath.TryGetValue(path, out var slot)) + return Refuse("WRAPPER_BINDING", $"{e.Fid}: resolved assembly is not a materialized slot"); + // TARGET_BINDING: resolve plan.target_api.subscribe (SimpleType.Method) inside the + // selected wrapper assembly and enforce the exact required shape (exactly one public + // type, exactly one public static non-generic void(INPC, PCEH) method, no other + // overload, no ref/optional/params/modopt) — any mismatch is WRAPPER_BINDING. + var terr = TargetBinding.Validate(sym.ContainingAssembly, target); + if (terr is not null) return Refuse("WRAPPER_BINDING", $"{e.Fid}: {terr}"); + // even a CLEANLY BOUND wrapper may target an incompatible runtime family (e.g. a + // .NET Framework mscorlib wrapper whose types retarget onto the net8 refs) — the + // positive runtime-family predicate refuses it before probing (L1). + if (RuntimeIncompatible(comp, sym.ContainingAssembly)) + return Refuse("WRAPPER_RUNTIME_UNSUPPORTED", + $"{e.Fid}: the wrapper targets a runtime family/version incompatible with the probe runtime"); + derivedOrdinal = slot.Ordinal; + asmName = sym.ContainingAssembly.Name; + mvid = ReadMvid(path); + token = "0x" + sym.MetadataToken.ToString("x8"); + sig = Program.Sig(FQ(sym.ReturnType), FQ(sym.ContainingType), sym.Name, + sym.Parameters.Select(p => FQ(p.Type))); + } + else if (!SymbolEqualityComparer.Default.Equals(sym, firstSym)) + { + return Refuse("CALLSITE_BINDING", $"{e.Fid}: converted callsites resolve to different methods"); + } + callsites.Add(new Dictionary + { + ["finding_id"] = e.Fid, + ["preimage_span"] = new List { (long)e.PreStart, (long)e.PreLen }, + ["postimage_span"] = new List { (long)postSpan.Start, (long)postSpan.Length }, + ["assembly_simple_name"] = asmName!, + ["module_mvid"] = mvid!, + ["metadata_token"] = token!, + ["resolved_signature"] = sig!, + }); + } + callsites.Sort((x, y) => string.CompareOrdinal((string)x["finding_id"], (string)y["finding_id"])); + + // the emitted callsites are a total bijection onto the converted findings with distinct + // postimage spans (defence in depth; the Python parent re-checks the canonical bytes). + var emitted = callsites.Select(c => (string)c["finding_id"]).ToList(); + if (emitted.Count != edits.Count || !convertedFids.SetEquals(emitted) + || new HashSet(emitted, StringComparer.Ordinal).Count != emitted.Count) + return Refuse("CALLSITE_BINDING", "callsites are not a bijection onto the converted findings"); + var spanKeys = callsites.Select(c => + { + var s = (List)c["postimage_span"]; + return $"{s[0]}:{s[1]}"; + }).ToList(); + if (new HashSet(spanKeys, StringComparer.Ordinal).Count != spanKeys.Count) + return Refuse("CALLSITE_BINDING", "two callsites share a derived postimage span"); + + var outObj = new Dictionary + { + ["version"] = 1L, + ["operation"] = "weak-target-bind", + ["converted_callsites"] = (long)edits.Count, + ["derived_wrapper_ordinal"] = (long)derivedOrdinal, + ["resolved_wrapper"] = new Dictionary + { + ["assembly_simple_name"] = asmName!, + ["module_mvid"] = mvid!, + ["metadata_token"] = token!, + ["resolved_signature"] = sig!, + }, + ["callsite_binding"] = new Dictionary + { + ["all_callsites_same_symbol"] = true, + ["target_is_source_defined"] = false, + }, + ["callsites"] = callsites.Cast().ToList(), + }; + Program.WriteCanonical(a["out"], outObj); + return 0; + } + + private static string? ReplacementText(AssignmentExpressionSyntax asg, string target, + JsonElement cand, out string err) + { + err = ""; + ExpressionSyntax receiver; + string eventName; + if (asg.Left is MemberAccessExpressionSyntax lhs + && lhs.IsKind(SyntaxKind.SimpleMemberAccessExpression) + && lhs.Name is IdentifierNameSyntax ev) + { receiver = lhs.Expression; eventName = ev.Identifier.Text; } + else if (asg.Left is IdentifierNameSyntax bare) + { receiver = Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ThisExpression(); eventName = bare.Identifier.Text; } + else { err = "LHS is not an event member access"; return null; } + if (eventName != cand.GetProperty("event").GetString()) { err = "event name mismatch"; return null; } + var eventFull = asg.Left.ToString(); + var dot = eventFull.LastIndexOf('.'); + var srcDisplay = dot >= 0 ? eventFull[..dot] : "this"; + if (srcDisplay != cand.GetProperty("source").GetString()) { err = "receiver mismatch"; return null; } + if (asg.Right.ToString() != cand.GetProperty("handler").GetString()) { err = "handler mismatch"; return null; } + var handler = Rewrite.NormalizeHandler(asg.Right); + if (Rewrite.NormWs(handler.ToString()) != cand.GetProperty("normalized_handler").GetString()) + { err = "normalized handler mismatch"; return null; } + var comma = Microsoft.CodeAnalysis.CSharp.SyntaxFactory + .Token(SyntaxKind.CommaToken) + .WithTrailingTrivia(Microsoft.CodeAnalysis.CSharp.SyntaxFactory.Space); + return Microsoft.CodeAnalysis.CSharp.SyntaxFactory.InvocationExpression( + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseExpression(target), + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ArgumentList( + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.SeparatedList( + new[] + { + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.Argument(receiver.WithoutTrivia()), + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.Argument(handler.WithoutTrivia()), + }, + new[] { comma }))).ToString(); + } + + // fully-qualified metadata type name (no `global::`, no C# keyword aliasing), matching the + // reflection FullName the probe uses, so bind and probe signatures are byte-identical (G3). + private static readonly SymbolDisplayFormat FQFmt = SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions + & ~SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static string FQ(ITypeSymbol t) => t.ToDisplayString(FQFmt).Replace("global::", ""); + + private static string ReadMvid(string dllPath) + { + using var fs = File.OpenRead(dllPath); + using var pe = new PEReader(fs); + var mr = pe.GetMetadataReader(); + return mr.GetGuid(mr.GetModuleDefinition().Mvid).ToString("D"); + } + + // Symbol == null: choose the refusal category WITHOUT ever promoting a candidate to the bound + // symbol. With exactly one candidate wrapper slot: a wrong frozen shape is WRAPPER_BINDING; + // WRAPPER_RUNTIME_UNSUPPORTED is returned ONLY when a POSITIVE predicate establishes the wrapper + // targets a core framework NEWER than the selected probe runtime (metadata/runtime + // incompatibility) — never inferred from "one candidate + correct textual shape". Every other + // unresolved callsite (argument conversion failure, method-group ambiguity, multiple / ambiguous + // assemblies, source-compilation failure) is CALLSITE_BINDING. + private static int RefuseUnbound(string fid, SymbolInfo si, CSharpCompilation comp, + Dictionary slotByPath, string target) + { + var asms = new List(); + foreach (var m in si.CandidateSymbols.OfType()) + { + if (SymbolEqualityComparer.Default.Equals(m.ContainingAssembly, comp.Assembly)) continue; + if (!asms.Any(x => SymbolEqualityComparer.Default.Equals(x, m.ContainingAssembly))) + asms.Add(m.ContainingAssembly); + } + if (asms.Count == 1) + { + var path = (comp.GetMetadataReference(asms[0]) as PortableExecutableReference)?.FilePath ?? ""; + if (slotByPath.ContainsKey(path)) + { + if (TargetBinding.Validate(asms[0], target) is not null) + return Refuse("WRAPPER_BINDING", $"{fid}: the wrapper target has the wrong frozen shape"); + if (RuntimeIncompatible(comp, asms[0])) + return Refuse("WRAPPER_RUNTIME_UNSUPPORTED", + $"{fid}: the wrapper targets a core framework newer than the selected probe runtime"); + } + } + return Refuse("CALLSITE_BINDING", $"{fid}: the invocation does not bind to a single wrapper symbol"); + } + + // The POSITIVE runtime-compatibility predicate (K3 / L1): a deterministic metadata comparison, + // never an inference from a failed overload. It positively establishes BOTH an incompatible + // runtime FAMILY (a .NET-Framework `mscorlib` wrapper under a CoreCLR probe, or a CoreCLR + // `System.Private.CoreLib` wrapper under a non-CoreCLR probe) AND a newer SAME-family runtime + // contract (a core contract at a version strictly newer than the selected runtime supplies), plus + // a TargetFrameworkAttribute that positively declares an incompatible family. A netstandard + // reference is NEVER a reason to reject (netstandard is a compatibility contract, not a family). + private static readonly HashSet SameFamilyContract = new(StringComparer.OrdinalIgnoreCase) + { "System.Runtime", "System.Private.CoreLib" }; + + private static bool RuntimeIncompatible(CSharpCompilation comp, IAssemblySymbol wrapperAsm) + { + var coreAsm = comp.GetSpecialType(SpecialType.System_Object).ContainingAssembly; + var runtimeVersion = coreAsm.Identity.Version; + var runtimeIsCoreClr = string.Equals(coreAsm.Name, "System.Private.CoreLib", + StringComparison.OrdinalIgnoreCase); + foreach (var mod in wrapperAsm.Modules) + foreach (var r in mod.ReferencedAssemblies) + { + if (string.Equals(r.Name, "mscorlib", StringComparison.OrdinalIgnoreCase) + && runtimeIsCoreClr) + return true; // .NET Framework wrapper under a CoreCLR probe + if (string.Equals(r.Name, "System.Private.CoreLib", StringComparison.OrdinalIgnoreCase) + && !runtimeIsCoreClr) + return true; // CoreCLR wrapper under a non-CoreCLR probe + if (SameFamilyContract.Contains(r.Name) && r.Version > runtimeVersion) + return true; // a same-family core contract strictly newer than the runtime + } + foreach (var attr in wrapperAsm.GetAttributes()) + if (attr.AttributeClass?.ToDisplayString() + == "System.Runtime.Versioning.TargetFrameworkAttribute" + && attr.ConstructorArguments.Length > 0 + && attr.ConstructorArguments[0].Value is string moniker + && MonikerFamilyIncompatible(moniker, runtimeIsCoreClr)) + return true; + return false; + } + + private static bool MonikerFamilyIncompatible(string moniker, bool runtimeIsCoreClr) + { + var isFramework = moniker.StartsWith(".NETFramework", StringComparison.OrdinalIgnoreCase); + var isCoreApp = moniker.StartsWith(".NETCoreApp", StringComparison.OrdinalIgnoreCase); + return (runtimeIsCoreClr && isFramework) || (!runtimeIsCoreClr && isCoreApp); + } + + private static int Refuse(string category, string message) + { + Console.Error.WriteLine($"{category}: {message}"); + return category switch + { + "CALLSITE_BINDING" => 11, + "WRAPPER_BINDING" => 12, + "TOOLCHAIN_BINDING" => 13, + "WRAPPER_RUNTIME_UNSUPPORTED" => 14, + _ => 2, + }; + } + + private readonly record struct Edit(string Fid, int PreStart, int PreLen, string Replacement, + string Source, string NormalizedHandler); +} + +// The frozen Step 8 handler peel + whitespace normalization (copied grammar; the rewriter stays frozen). +internal static class Rewrite +{ + public static ExpressionSyntax NormalizeHandler(ExpressionSyntax e) + { + while (e is BaseObjectCreationExpressionSyntax { ArgumentList.Arguments: { Count: 1 } args }) + e = args[0].Expression; + return e; + } + + public static string NormWs(string s) => string.Join(" ", s.Split((char[]?)null, + StringSplitOptions.RemoveEmptyEntries)); +} + +internal static class References +{ + public static (List, Dictionary) Build(string slotsDir) + { + var refs = new List(); + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + // framework references = the SELECTED runtime the probe is pinned to (TPA), first. + var tpa = ((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var p in tpa) { seen.Add(Path.GetFileNameWithoutExtension(p)); refs.Add(MetadataReference.CreateFromFile(p)); } + // reference slots in exact ordinal order; first simple-name wins (framework wins over a slot). + if (Directory.Exists(slotsDir)) + foreach (var slot in Directory.GetDirectories(slotsDir).OrderBy(d => d, StringComparer.Ordinal)) + { + var dll = Directory.GetFiles(slot, "*.dll").Single(); + var name = Path.GetFileNameWithoutExtension(dll); + var ordinal = int.Parse(Path.GetFileName(slot)); + var full = Path.GetFullPath(dll); + byPath[full] = new Slot(ordinal, full, name); + if (seen.Add(name)) refs.Add(MetadataReference.CreateFromFile(full)); + } + return (refs, byPath); + } + + public readonly record struct Slot(int Ordinal, string Path, string SimpleName); +} + +// TARGET_BINDING (WRAPPER_BINDING on any mismatch): the plan.target_api.subscribe method, resolved +// by name ONLY inside the selected wrapper assembly, must have the exact accepted shape. This is +// independent of whether the postimage callsite's arguments bind, so a wrong-signature or overloaded +// wrapper is refused here rather than surfacing as an unresolved callsite or a runtime load failure. +internal static class TargetBinding +{ + private static readonly SymbolDisplayFormat FQFmt = SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions + & ~SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static string FQ(ITypeSymbol t) => t.ToDisplayString(FQFmt).Replace("global::", ""); + + public static string? Validate(IAssemblySymbol asm, string target) + { + var dot = target.LastIndexOf('.'); + if (dot <= 0) return "target_api.subscribe is not SimpleType.Method"; + var typeName = target[..dot]; + var methodName = target[(dot + 1)..]; + + var types = new List(); + Collect(asm.GlobalNamespace, types); + var matches = types.Where(t => t.DeclaredAccessibility == Accessibility.Public + && (t.Name == typeName || FQ(t) == typeName)).ToList(); + if (matches.Count != 1) return $"expected exactly one public type '{typeName}'"; + var declType = matches[0]; + if (declType.IsGenericType) return "the wrapper type is generic"; + + var overloads = declType.GetMembers(methodName).OfType() + .Where(m => m.IsStatic && m.DeclaredAccessibility == Accessibility.Public).ToList(); + if (overloads.Count != 1) return $"expected exactly one public static '{methodName}'"; + var m = overloads[0]; + if (m.IsGenericMethod) return "the wrapper method is generic"; + if (m.ReturnType.SpecialType != SpecialType.System_Void) return "return type is not System.Void"; + if (m.Parameters.Length != 2) return "parameter count is not 2"; + if (FQ(m.Parameters[0].Type) != "System.ComponentModel.INotifyPropertyChanged" + || FQ(m.Parameters[1].Type) != "System.ComponentModel.PropertyChangedEventHandler") + return "parameters are not (INotifyPropertyChanged, PropertyChangedEventHandler)"; + foreach (var p in m.Parameters) + if (p.RefKind != RefKind.None || p.IsOptional || p.IsParams + || p.CustomModifiers.Any() || p.RefCustomModifiers.Any()) + return "a parameter uses ref/optional/params/custom-modifier shape"; + return null; + } + + private static void Collect(INamespaceSymbol ns, List acc) + { + acc.AddRange(ns.GetTypeMembers()); + foreach (var child in ns.GetNamespaceMembers()) Collect(child, acc); + } +} + +internal static class Args +{ + public static Dictionary Parse(string[] args, int start) + { + var d = new Dictionary(); + for (var i = start; i < args.Length; i++) + if (args[i].StartsWith("--") && i + 1 < args.Length) { d[args[i][2..]] = args[i + 1]; i++; } + return d; + } +} + +// --- PROBE MODE (G3, G4, F4) ------------------------------------------------------------- +internal static class ProbeMode +{ + private const int CollectionRounds = 5; + private const int AllocPerRound = 4194304; + + public static int Run(string[] args) + { + var a = Args.Parse(args, 1); + var ordinal = int.Parse(a["wrapper-ordinal"]); + var attempt = int.Parse(a["attempt"]); + var target = a["target"]; + var slotsDir = a["slots-dir"]; + var runtimeDir = a["runtime-dir"]; + var outPath = a["out"]; + + var slot = Path.Combine(slotsDir, ordinal.ToString("D6")); + var rootPath = Path.GetFullPath(Directory.GetFiles(slot, "*.dll").Single()); + var slotSha = Sha256(rootPath); + + var dot = target.LastIndexOf('.'); + var typeName = target[..dot]; + var methodName = target[(dot + 1)..]; + + // the closed load context: the wrapper root loads only from its exact derived slot; its + // non-framework dependencies resolve ONLY from the ordered materialized slots (first + // filename wins); framework assemblies resolve ONLY from the selected probe runtime; a + // non-framework dependency absent from the slots is a controlled loader failure. The probe + // deployment / default context can NEVER satisfy a wrapper dependency. + var framework = FrameworkNames(runtimeDir); + var alc = new WrapperLoadContext(slotsDir, framework); + MethodInfo method; + Action invoke; + string asmName, mvid, token, sig; + try + { + // G4 preflight: load-by-path, resolve type + exact method, build delegate, prepare. + var root = alc.LoadFromAssemblyPath(rootPath); + var type = root.GetTypes().Single(t => t.IsPublic && !t.IsGenericType && t.Name == typeName); + var cands = type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == methodName).ToList(); + method = cands.Single(m => !m.IsGenericMethod && m.ReturnType == typeof(void) + && Params(m).SequenceEqual(new[] { typeof(INotifyPropertyChanged), typeof(PropertyChangedEventHandler) })); + invoke = (Action) + method.CreateDelegate(typeof(Action)); + RuntimeHelpers.PrepareMethod(method.MethodHandle); + asmName = root.GetName().Name!; + mvid = method.Module.ModuleVersionId.ToString("D"); + token = "0x" + method.MetadataToken.ToString("x8"); + sig = Program.Sig("System.Void", FullName(type), method.Name, Params(method).Select(FullName)); + } + catch (Exception e) when (IsLoaderFailure(e)) + { + var obj = new Dictionary + { + ["version"] = 1L, ["operation"] = "weak-target-probe", ["attempt"] = (long)attempt, + ["runtime_unsupported"] = true, + ["reason"] = Inner(e).GetType().Name, + }; + Program.WriteCanonical(outPath, obj); + return 10; // WRAPPER_RUNTIME_UNSUPPORTED + } + + var strongSource = new ProbeSource(); + var strongRef = RunStrong(strongSource, out var strongDelivered); + var weakRef = RunCollectable(); + var targetSource = new ProbeSource(); + var wref = RunTarget(targetSource, invoke, out var delivered, out var threwSub, out var threwFirst); + + for (var r = 0; r < CollectionRounds; r++) CollectRound(strongSource, targetSource); + GC.KeepAlive(strongSource); + GC.KeepAlive(targetSource); + + var strongRetained = strongRef.TryGetTarget(out _); + var weakCollected = !weakRef.TryGetTarget(out _); + var subscriberCollected = !wref.TryGetTarget(out _); + + bool threwPost = false; + try { targetSource.Raise(); } catch { threwPost = true; } + GC.KeepAlive(targetSource); + + var result = new Dictionary + { + ["version"] = 1L, + ["operation"] = "weak-target-probe", + ["attempt"] = (long)attempt, + ["strong_delivered_once"] = strongDelivered == 1, + ["strong_retained"] = strongRetained, + ["weak_control_collected"] = weakCollected, + ["delivered_count"] = (long)delivered, + ["threw_on_subscribe"] = threwSub, + ["threw_on_first_raise"] = threwFirst, + ["subscriber_collected"] = subscriberCollected, + ["threw_on_post_collection_raise"] = threwPost, + ["resolved_wrapper"] = new Dictionary + { + ["ordinal"] = (long)ordinal, + ["slot_sha256"] = "sha256:" + slotSha, + ["assembly_simple_name"] = asmName, + ["module_mvid"] = mvid, + ["metadata_token"] = token, + ["resolved_signature"] = sig, + }, + }; + Program.WriteCanonical(outPath, result); + return 0; + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static WeakReference RunTarget(ProbeSource source, + Action invoke, + out int delivered, out bool threwSub, out bool threwFirst) + { + threwSub = false; threwFirst = false; delivered = 0; + var sub = new ProbeSubscriber(); + var handler = new PropertyChangedEventHandler(sub.OnChanged); + try { invoke(source, handler); } catch { threwSub = true; } + if (!threwSub) { try { source.Raise(); } catch { threwFirst = true; } } + delivered = sub.Count; + return new WeakReference(sub, trackResurrection: false); + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static WeakReference RunStrong(ProbeSource source, out int delivered) + { + var sub = new ProbeSubscriber(); + source.PropertyChanged += sub.OnChanged; + source.Raise(); + delivered = sub.Count; + return new WeakReference(sub, trackResurrection: false); + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static WeakReference RunCollectable() + => new(new ProbeSubscriber(), trackResurrection: false); + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static void CollectRound(ProbeSource keepA, ProbeSource keepB) + { + var pressure = new byte[AllocPerRound]; + for (var i = 0; i < pressure.Length; i += 4096) pressure[i] = 1; + pressure = null!; + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true, true); + GC.WaitForPendingFinalizers(); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true, true); + GC.KeepAlive(keepA); + GC.KeepAlive(keepB); + } + + private static Type[] Params(MethodInfo m) => m.GetParameters().Select(p => p.ParameterType).ToArray(); + private static string FullName(Type t) => t.FullName ?? t.Name; + + private static bool IsLoaderFailure(Exception e) + { + var x = Inner(e); + return x is BadImageFormatException or FileNotFoundException or FileLoadException + or TypeLoadException or MissingMethodException or MissingMemberException + or ReflectionTypeLoadException or InvalidOperationException; + } + + private static Exception Inner(Exception e) + => e is TargetInvocationException { InnerException: { } inner } ? inner : e; + + private static string Sha256(string path) + { + using var s = File.OpenRead(path); + using var h = System.Security.Cryptography.SHA256.Create(); + return Convert.ToHexString(h.ComputeHash(s)).ToLowerInvariant(); + } + + // the framework assembly set = the simple names physically present in the SELECTED probe + // runtime directory. Only these may resolve from the default context; everything else must + // come from a materialized slot. + private static HashSet FrameworkNames(string runtimeDir) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Directory.Exists(runtimeDir)) + foreach (var dll in Directory.GetFiles(runtimeDir, "*.dll")) + set.Add(Path.GetFileNameWithoutExtension(dll)); + return set; + } +} + +internal sealed class ProbeSource : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + public void Raise() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Probe")); +} + +internal sealed class ProbeSubscriber +{ + public int Count; + public void OnChanged(object? sender, PropertyChangedEventArgs e) => Count++; +} + +internal sealed class WrapperLoadContext : AssemblyLoadContext +{ + private readonly string _slotsDir; + private readonly HashSet _framework; + + public WrapperLoadContext(string slotsDir, HashSet framework) + : base("weak-target", isCollectible: false) + { + _slotsDir = slotsDir; + _framework = framework; + } + + protected override Assembly? Load(AssemblyName name) + { + var simple = name.Name ?? ""; + // framework assemblies resolve ONLY from the selected probe runtime (the default context, + // which the probe pinned via --fx-version). Returning null delegates to that runtime. + if (_framework.Contains(simple)) return null; + // a non-framework dependency resolves ONLY from the ordered materialized slots (first + // filename wins) — never the probe deployment, the default context, the working directory, + // the user profile, or an arbitrary PATH entry. + var dll = ResolveSlot(simple); + if (dll != null) return LoadFromAssemblyPath(dll); + // absent from the slots -> a controlled loader failure -> WRAPPER_RUNTIME_UNSUPPORTED. + throw new FileNotFoundException( + $"the wrapper dependency '{simple}' is not in the materialized reference closure"); + } + + private string? ResolveSlot(string simple) + { + if (!Directory.Exists(_slotsDir)) return null; + foreach (var slot in Directory.GetDirectories(_slotsDir).OrderBy(d => d, StringComparer.Ordinal)) + { + var dll = Directory.GetFiles(slot, "*.dll").SingleOrDefault(); + if (dll != null && string.Equals(Path.GetFileNameWithoutExtension(dll), simple, + StringComparison.OrdinalIgnoreCase)) + return Path.GetFullPath(dll); + } + return null; + } +} diff --git a/ownlang/__main__.py b/ownlang/__main__.py index 3e58250d..87c87e93 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -672,8 +672,58 @@ def _cmd_verify_delta(rest: list[str]) -> int: return 0 +def _cmd_verify_target(rest: list[str]) -> int: + """S2 step 11: `own-fix subscriptions verify-target` — the fake-target gate. Binds the + accepted Step 8 bundle + Step 10 delta, runs the fixed OwnSharp.WeakTargetProbe (Roslyn + callsite bijection, then three isolated runtime probes) over the wrapper the postimage + actually calls, and proves it is a genuine non-retaining subscription.""" + from ownlang.fix_gate import GateError + from ownlang.fix_target import TargetError, run_verify_target + + flags = {"--bundle", "--root", "--plan", "--candidates", "--delta", "--out", + "--probe-dll", "--wrapper-ordinal"} + parsed = _own_fix_parse(rest, flags, {"--ref-dir"}) + if parsed is None: + return 2 + positional, opts = parsed + for f in flags: + if rest.count(f) > 1: + print(f"own-fix: {f} given more than once", file=sys.stderr) + return 2 + required = ("--bundle", "--root", "--plan", "--candidates", "--delta", "--out") + if positional or not all(opts.get(k) for k in required): + print("usage: own-fix subscriptions verify-target --bundle " + "--root --plan " + "--candidates --delta " + "--out [--ref-dir ]... " + "[--probe-dll ] [--wrapper-ordinal ]", file=sys.stderr) + return 2 + wrapper_ordinal = None + if opts.get("--wrapper-ordinal") is not None: + raw = opts["--wrapper-ordinal"] + if not raw.isdigit(): + print("own-fix: --wrapper-ordinal must be a non-negative integer", file=sys.stderr) + return 2 + wrapper_ordinal = int(raw) + try: + published = run_verify_target( + opts["--bundle"], opts["--root"], opts["--plan"], opts["--candidates"], + opts["--delta"], opts.get("--probe-dll"), opts["--out"], + opts.get("--ref-dir") or [], wrapper_ordinal) + except (TargetError, GateError) as exc: + print(f"own-fix: refuse: {exc.category}: {exc}", file=sys.stderr) + return 2 + except Exception as exc: # fail closed + print(f"own-fix: refuse: INFRASTRUCTURE: internal error " + f"({type(exc).__name__}: {exc})", file=sys.stderr) + return 2 + print(f"own-fix: wrote target-result.json -> {published}") + return 0 + + def cmd_own_fix(rest: list[str]) -> int: - """`own-fix subscriptions {candidates|render|validate-plan|apply|gate|verify-delta} ...`.""" + """`own-fix subscriptions {candidates|render|validate-plan|apply|gate|verify-delta| + verify-target} ...`.""" if len(rest) < 2 or rest[0] != "subscriptions": print("usage: python -m ownlang own-fix subscriptions " "{candidates|render|validate-plan|apply|gate|verify-delta} ...", file=sys.stderr) @@ -691,8 +741,10 @@ def cmd_own_fix(rest: list[str]) -> int: return _cmd_gate(args) if verb == "verify-delta": return _cmd_verify_delta(args) - print(f"own-fix: unknown subcommand {verb!r} " - "(candidates | render | validate-plan | apply | gate | verify-delta)", file=sys.stderr) + if verb == "verify-target": + return _cmd_verify_target(args) + print(f"own-fix: unknown subcommand {verb!r} (candidates | render | validate-plan | apply " + "| gate | verify-delta | verify-target)", file=sys.stderr) return 2 diff --git a/ownlang/fix_target.py b/ownlang/fix_target.py new file mode 100644 index 00000000..6ad885ac --- /dev/null +++ b/ownlang/fix_target.py @@ -0,0 +1,1009 @@ +"""S2 step 11 — the Verified Target Wrapper gate (the fake-target gate). + + python -m ownlang own-fix subscriptions verify-target \ + --bundle --root --plan \ + --candidates --delta \ + --probe-dll --out \ + [--ref-dir ]... --wrapper-ordinal + +Step 10 proves the analyzer stops reporting OWN001 for a converted subscription, but the +analyzer recognizes the replacement wrapper BY NAME only. Step 11 proves the wrapper the +accepted Step 8 postimage actually calls is a genuine non-retaining subscription: it runs a +fixed Roslyn `bind` over the pristine preimage + the accepted postimage (per-finding callsite +bijection), then a fixed runtime `probe` (three fresh isolated children) that loads the derived +wrapper from its exact materialized slot, runs a runtime-compatibility preflight, and proves +the subscriber becomes GC-collectable after a subscribe-then-drop. A wrapper that retains the +subscriber is a fake target and is refused TARGET_RETAINS. + +Step 11 reuses the frozen Step 8/9/10 helpers by import and touches NO frozen artifact. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import stat +import subprocess +import tempfile +import threading +from typing import Any, cast + +from ownlang.fix_delta import ( + _hash_resolved, + _manifest_sha, + _read_runtimeconfig, + _resolve_dotnet_host, + _runtime_manifest, + _select_runtime, + _walk_regular_files, +) +from ownlang.fix_gate import ( + GateError, + _canonical_bytes, + _canonical_json, + _claim_workdir, + _is_link, + _out_parent, + _same_or_inside, + _same_path, + _sha_bytes, + _snapshot, + validate_gate_authority, +) + +# --- failure taxonomy -------------------------------------------------------------- +INPUT_LAYOUT = "INPUT_LAYOUT" +AUTHORITY_BINDING = "AUTHORITY_BINDING" +DELTA_BINDING = "DELTA_BINDING" +REFERENCE_BINDING = "REFERENCE_BINDING" +TOOLCHAIN_BINDING = "TOOLCHAIN_BINDING" +CALLSITE_BINDING = "CALLSITE_BINDING" +WRAPPER_BINDING = "WRAPPER_BINDING" +WRAPPER_RUNTIME_UNSUPPORTED = "WRAPPER_RUNTIME_UNSUPPORTED" +HARNESS_INVALID = "HARNESS_INVALID" +TARGET_BEHAVIOR = "TARGET_BEHAVIOR" +TARGET_RETAINS = "TARGET_RETAINS" +HARNESS_NONDETERMINISM = "HARNESS_NONDETERMINISM" +ISOLATION = "ISOLATION" +PUBLICATION = "PUBLICATION" +INFRASTRUCTURE = "INFRASTRUCTURE" + +_CHECK_NAMES = ( + "input_layout", "authority_binding", "delta_binding", "reference_binding", + "probe_toolchain_binding", "wrapper_binding", "harness_controls", "target_behavior", + "target_nonretention", "harness_determinism", "publication", +) +_MANUAL_ONLY_NA = ( + "probe_toolchain_binding", "wrapper_binding", "harness_controls", "target_behavior", + "target_nonretention", "harness_determinism", +) +_STEP10_CHECKS = ( + "input_layout", "authority_binding", "gate_binding", "toolchain_binding", + "core_analyzer_binding", "analysis_scope", "baseline_authority", "baseline_analysis", + "postimage_analysis", "analysis_identity", "delta_subscription", "delta_core", + "new_own001", "new_own050", "semantic_idempotence", "isolation", "publication", +) +_ATTEMPT_COUNT = 3 +_COLLECTION_ROUNDS = 5 +_ALLOC_PER_ROUND = 4194304 +_CHILD_TIMEOUT_SECONDS = 30 +_OUT_LIMIT = 65536 + + +class TargetError(Exception): + """A controlled refusal carrying the stable category for regression assertions.""" + + def __init__(self, category: str, message: str) -> None: + super().__init__(message) + self.category = category + + +def _canonical(obj: dict[str, Any]) -> bytes: + return _canonical_bytes(obj) + + +# --- authority + delta + bundle + reference binding (F1, F2) ------------------------ + + +def load_authority(plan_bytes: bytes, candidates_bytes: bytes) -> tuple[Any, Any, Any]: + try: + plan = json.loads(plan_bytes) + candidates = json.loads(candidates_bytes) + except ValueError as exc: + raise TargetError(AUTHORITY_BINDING, f"plan/candidates not valid JSON ({exc})") from exc + try: + auth = validate_gate_authority(plan, candidates) + except GateError as exc: + raise TargetError(exc.category, str(exc)) from exc + for c in candidates["candidates"]: + if c.get("diagnostic_code") != "OWN001": + raise TargetError(AUTHORITY_BINDING, "candidates carry a non-OWN001 diagnostic") + return auth, plan, candidates + + +_DELTA_TOP_KEYS = frozenset({ + "schema", "operation", "status", "analysis_scope", "input_hashes", "gate_binding", + "toolchain_fingerprint", "reference_closure", "target_api", "expected", "baseline", + "postimage", "delta", "semantic_idempotence", "checks", +}) +_DELTA_IH_KEYS = ("input_bundle_sha256", "validated_plan_sha256", "candidates_sha256", + "apply_manifest_sha256", "patch_sha256", "pre_sha256", "post_sha256") +_DELTA_RID_KEYS = ("framework_name", "tfm", "requested_framework_version", + "selected_framework_version", "runtime_manifest_sha256") +_DELTA_SCOPE_KEYS = ("source_file", "selected_class", "closure_kind", "reference_dir_count", + "target_file_identity") +_DELTA_TAPI_KEYS = ("subscribe",) +_DELTA_EXP_KEYS = ("convert_acquire_ids", "manual_review_ids") +_DELTA_RC_KEYS = ("ordinal", "source_dir_ordinal", "relative_path", "sha256") + + +def _is_sha(v: Any) -> bool: + return isinstance(v, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", v) is not None + + +def _bind_delta_shapes(d: dict[str, Any], cat: str) -> None: + """Validate the EXACT frozen shapes of the Step 10 delta fields Step 11 consumes — closed key + sets, exact value types, exact-int (a bool is not an int) for every consumed integer, the + `sha256:<64 hex>` form for every consumed digest, and reference-closure entries in exact ordinal + order with no duplicate ordinal. Any violation is DELTA_BINDING, never a KeyError or an + accidental INFRASTRUCTURE (K2 / L2). This validates Step 10's actual frozen schema; it does not + modify the frozen producer.""" + ih = d["input_hashes"] + if not isinstance(ih, dict) or set(ih) != set(_DELTA_IH_KEYS) \ + or not all(_is_sha(ih[k]) for k in _DELTA_IH_KEYS): + raise TargetError(cat, "delta-result.json input_hashes shape is wrong") + scope = d["analysis_scope"] + if not isinstance(scope, dict) or set(scope) != set(_DELTA_SCOPE_KEYS) \ + or not all(isinstance(scope[k], str) for k in + ("source_file", "selected_class", "closure_kind", "target_file_identity")) \ + or not _is_int(scope["reference_dir_count"]) or scope["reference_dir_count"] < 0: + raise TargetError(cat, "delta-result.json analysis_scope shape is wrong") + tfp = d["toolchain_fingerprint"] + rid = tfp.get("resolved_runtime_identity") if isinstance(tfp, dict) else None + if not isinstance(rid, dict) or set(rid) != set(_DELTA_RID_KEYS) \ + or not all(isinstance(rid[k], str) for k in _DELTA_RID_KEYS) \ + or not _is_sha(rid["runtime_manifest_sha256"]): + raise TargetError(cat, "delta-result.json resolved_runtime_identity shape is wrong") + tapi = d["target_api"] + if not isinstance(tapi, dict) or set(tapi) != set(_DELTA_TAPI_KEYS) \ + or not isinstance(tapi["subscribe"], str): + raise TargetError(cat, "delta-result.json target_api shape is wrong") + exp = d["expected"] + if not isinstance(exp, dict) or set(exp) != set(_DELTA_EXP_KEYS) \ + or not all(isinstance(exp[k], list) and all(isinstance(x, str) for x in exp[k]) + for k in _DELTA_EXP_KEYS): + raise TargetError(cat, "delta-result.json expected shape is wrong") + rc = d["reference_closure"] + if not isinstance(rc, list): + raise TargetError(cat, "delta-result.json reference_closure shape is wrong") + for i, ent in enumerate(rc): + if not isinstance(ent, dict) or set(ent) != set(_DELTA_RC_KEYS) \ + or not _is_int(ent["ordinal"]) or ent["ordinal"] < 0 \ + or not _is_int(ent["source_dir_ordinal"]) or ent["source_dir_ordinal"] < 0 \ + or not isinstance(ent["relative_path"], str) or not _is_sha(ent["sha256"]): + raise TargetError(cat, "delta-result.json reference_closure entry is malformed") + if ent["ordinal"] != i: # exact ordinal order, no duplicate / gap / reorder + raise TargetError(cat, "delta-result.json reference_closure ordinal is out of order") + + +def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes, + candidates_bytes: bytes) -> dict[str, Any]: + """Bind the Step 10 delta-result.json as the upstream authority (canonical bytes, exact + schema, all seventeen checks pass, hashes/target/expected bound to THESE inputs).""" + cat = DELTA_BINDING + if not delta_bytes.endswith(b"\n"): + raise TargetError(cat, "delta-result.json is missing its trailing newline") + try: + d = json.loads(delta_bytes) + except ValueError as exc: + raise TargetError(cat, f"delta-result.json is not valid JSON ({exc})") from exc + if _canonical_bytes(d) != delta_bytes: + raise TargetError(cat, "delta-result.json is not canonical bytes") + if not isinstance(d, dict) or set(d) != _DELTA_TOP_KEYS: + raise TargetError(cat, "delta-result.json has unknown or missing top-level keys") + _bind_delta_shapes(d, cat) + if not _is_int(d["schema"]) or d["schema"] != 1 \ + or d.get("operation") != "verify-subscription-analyzer-delta" \ + or d.get("status") != "pass": + raise TargetError(cat, "delta-result.json schema/operation/status is wrong") + checks = d.get("checks") + if not isinstance(checks, dict) or set(checks) != set(_STEP10_CHECKS) \ + or set(checks.values()) != {"pass"}: + raise TargetError(cat, "delta-result.json checks are not the seventeen all-pass set") + ih = d.get("input_hashes", {}) + if ih.get("input_bundle_sha256") != auth.input_bundle_sha256 \ + or ih.get("validated_plan_sha256") != _sha_bytes(plan_bytes) \ + or ih.get("candidates_sha256") != _sha_bytes(candidates_bytes): + raise TargetError(cat, "delta-result.json is not bound to these plan/candidates") + if d.get("target_api", {}).get("subscribe") != auth.target_subscribe: + raise TargetError(cat, "delta-result.json target_api does not bind the plan") + if d.get("expected", {}).get("convert_acquire_ids") != sorted(auth.applied) \ + or d.get("expected", {}).get("manual_review_ids") != sorted(auth.manual): + raise TargetError(cat, "delta-result.json expected ids do not bind the plan") + return cast("dict[str, Any]", d) + + +def bind_bundle(bundle: str, root: str, rel: str, delta: dict[str, Any]) -> dict[str, str]: + """Validate the frozen Step 8 bundle layout and bind its four hashes to the delta (F1).""" + cat = DELTA_BINDING + try: + names = set(os.listdir(bundle)) + except OSError as exc: + raise TargetError(cat, f"cannot list --bundle ({exc})") from exc + if names != {"change.patch", "apply-manifest.json", "postimage"}: + raise TargetError(cat, f"--bundle holds {sorted(names)}, not the step 8 layout") + manifest = _snapshot(os.path.join(bundle, "apply-manifest.json"), cat, "apply-manifest.json") + patch = _snapshot(os.path.join(bundle, "change.patch"), cat, "change.patch") + postimage = _snapshot(os.path.join(bundle, "postimage", *rel.split("/")), cat, "postimage") + preimage = _snapshot(os.path.join(root, *rel.split("/")), cat, "preimage") + ih = delta["input_hashes"] + if _sha_bytes(manifest) != ih["apply_manifest_sha256"] \ + or _sha_bytes(patch) != ih["patch_sha256"] \ + or _sha_bytes(preimage) != ih["pre_sha256"] \ + or _sha_bytes(postimage) != ih["post_sha256"]: + raise TargetError(cat, "step 8 bundle hashes do not bind the delta") + scope = delta.get("analysis_scope", {}) + if scope.get("source_file") != rel or scope.get("target_file_identity") != rel: + raise TargetError(cat, "target rel does not equal the frozen Step 10 analysis scope") + return {"preimage": preimage.decode("utf-8"), "postimage": postimage.decode("utf-8"), + "apply_manifest_sha256": ih["apply_manifest_sha256"], + "patch_sha256": ih["patch_sha256"], + "pre_sha256": ih["pre_sha256"], "post_sha256": ih["post_sha256"]} + + +def reference_closure(work: str, ref_dirs: list[str], + delta: dict[str, Any]) -> tuple[list[str], list[dict[str, Any]]]: + """Materialize the ordered one-DLL-per-slot closure and require semantic equality with + delta.reference_closure (REFERENCE_BINDING).""" + from ownlang.fix_delta import snapshot_reference_closure + try: + slot_dirs, evidence = snapshot_reference_closure(work, ref_dirs) + except Exception as exc: # fix_delta raises DeltaError(ANALYSIS_SCOPE/INPUT_LAYOUT) + cat = getattr(exc, "category", REFERENCE_BINDING) + raise TargetError(REFERENCE_BINDING if cat == "ANALYSIS_SCOPE" else INPUT_LAYOUT, + str(exc)) from exc + if evidence != delta.get("reference_closure"): + raise TargetError(REFERENCE_BINDING, + "reconstructed closure != delta.reference_closure") + return slot_dirs, evidence + + +# --- probe toolchain + runtime (G2) ------------------------------------------------ + + +def snapshot_probe_deployment(work: str, probe_dll: str) -> tuple[str, dict[str, Any]]: + """Snapshot the whole probe deployment into WORK/probe and return the copied DLL path + + the probe fingerprint. Execute the COPY (TOCTOU-closed).""" + dll_abs = os.path.abspath(probe_dll) + src = os.path.dirname(dll_abs) + name = os.path.basename(dll_abs) + if _is_link(os.lstat(src)): + raise TargetError(TOOLCHAIN_BINDING, "the probe deployment root is a link") + dst_root = os.path.join(work, "probe") + os.makedirs(dst_root) + manifest: list[dict[str, str]] = [] + for rel in _walk_regular_files(src, TOOLCHAIN_BINDING): + data = _snapshot(os.path.join(src, rel.replace("/", os.sep)), + TOOLCHAIN_BINDING, f"probe {rel}") + dst = os.path.join(dst_root, rel.replace("/", os.sep)) + os.makedirs(os.path.dirname(dst), exist_ok=True) + with open(dst, "wb") as fh: + fh.write(data) + manifest.append({"path": rel, "sha256": _sha_bytes(data)}) + manifest.sort(key=lambda m: m["path"]) + if name not in {m["path"] for m in manifest}: + raise TargetError(TOOLCHAIN_BINDING, f"the probe DLL {name!r} is not in its deployment") + fingerprint = { + "probe_deployment_manifest_sha256": _sha_bytes(_canonical_json(manifest)), + "probe_runner_sha256": _sha_bytes(_snapshot(os.path.join(dst_root, name), + TOOLCHAIN_BINDING, "probe runner")), + "probe_files": manifest, + } + return os.path.join(dst_root, name), fingerprint + + +def resolve_probe_runtime(dll_dst: str, dotnet_host: str, + delta: dict[str, Any]) -> tuple[dict[str, Any], str, str, str, str]: + """Select the runtime with the accepted Step 10 policy and require it to MATCH the runtime + Step 10 recorded (G2.2). Returns (probe_runtime_identity, dotnet_version, dotnet_host_sha256, + selected_version, rt_dir).""" + tfm, fname, fver = _read_runtimeconfig(dll_dst) + dotnet_host_sha = _hash_resolved(dotnet_host, TOOLCHAIN_BINDING, "dotnet host") + from ownlang.fix_delta import _run_capture + dotnet_version = _run_capture([dotnet_host, "--version"], TOOLCHAIN_BINDING, + "dotnet --version").strip() + listing = _run_capture([dotnet_host, "--list-runtimes"], TOOLCHAIN_BINDING, + "dotnet --list-runtimes") + selected_ver, rt_dir = _select_runtime(listing, fname, fver) + if not os.path.isdir(rt_dir): + raise TargetError(TOOLCHAIN_BINDING, "the selected runtime directory does not exist") + identity = {"framework_name": fname, "tfm": tfm, "requested_framework_version": fver, + "selected_framework_version": selected_ver, + "selected_runtime_manifest_sha256": _runtime_manifest(rt_dir)} + step10 = delta.get("toolchain_fingerprint", {}).get("resolved_runtime_identity", {}) + for k in ("framework_name", "tfm", "requested_framework_version", + "selected_framework_version", "selected_runtime_manifest_sha256"): + s10 = step10.get("runtime_manifest_sha256" if k == "selected_runtime_manifest_sha256" + else k) + if identity[k] != s10: + raise TargetError(TOOLCHAIN_BINDING, + f"probe runtime {k} does not match the Step 10 runtime") + return identity, dotnet_version, dotnet_host_sha, selected_ver, rt_dir + + +# --- bind-params + the Roslyn bind subprocess (G1) --------------------------------- + + +def _peel_handler(handler: str) -> str: + """The frozen Step 8 handler peel + whitespace normalization, mirrored for bind-params: + `new H(M)` / `new(M)` -> M, then collapse whitespace.""" + s = handler.strip() + while True: + m = re.fullmatch(r"new\s+[^\s(]+\s*\(\s*(.*)\s*\)", s) or re.fullmatch( + r"new\s*\(\s*(.*)\s*\)", s) + if not m: + break + s = m.group(1).strip() + return " ".join(s.split()) + + +def build_bind_params(candidates: Any, convert_ids: list[str], rel: str) -> dict[str, Any]: + by_id = {c["finding_id"]: c for c in candidates["candidates"]} + conv: list[dict[str, Any]] = [] + for fid in convert_ids: + c = by_id[fid] + conv.append({ + "finding_id": fid, + "occurrence_ordinal": c["occurrence_ordinal"], + "file": rel, + "containing_type": c["containing_type"], + "event": c["event"], + "source": c["source"], + "handler": c["handler"], + "normalized_handler": _peel_handler(c["handler"]), + "acquire_span": {"start": c["acquire_span"]["start"], + "length": c["acquire_span"]["length"]}, + }) + return {"converted": conv} + + +_BIND_EXIT = {11: CALLSITE_BINDING, 12: WRAPPER_BINDING, 13: TOOLCHAIN_BINDING, + 14: WRAPPER_RUNTIME_UNSUPPORTED} + + +def _run_child(argv: list[str], cwd: str, env: dict[str, str], + timeout: int) -> tuple[int, bytes, bytes, str | None]: + """One shared bounded runner for the bind and probe children (H4). It enforces the fixed + timeout, caps each of stdout/stderr at _OUT_LIMIT bytes WHILE the child runs, kills the DIRECT + child on timeout or overflow (no descendant-containment claim), and returns bounded diagnostics. + Returns (returncode, stdout, stderr, reason) where reason is None on a clean exit, else + 'timeout' / 'stdout_overflow' / 'stderr_overflow'.""" + proc = subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + bufs = {"stdout": bytearray(), "stderr": bytearray()} + over: dict[str, str | None] = {"which": None} + lock = threading.Lock() + + def pump(name: str, pipe: Any) -> None: + try: + while True: + chunk = pipe.read(4096) + if not chunk: + return + with lock: + buf = bufs[name] + room = _OUT_LIMIT - len(buf) + if room > 0: + buf.extend(chunk[:room]) + if len(chunk) > room: + over["which"] = over["which"] or name + try: + proc.kill() + except OSError: + pass + return + except (OSError, ValueError): + return + + threads = [threading.Thread(target=pump, args=(n, p), daemon=True) + for n, p in (("stdout", proc.stdout), ("stderr", proc.stderr))] + for t in threads: + t.start() + reason: str | None = None + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + reason = "timeout" + try: + proc.kill() + except OSError: + pass + proc.wait() + for t in threads: + t.join(2) + if reason is None and over["which"]: + reason = f"{over['which']}_overflow" + return proc.returncode, bytes(bufs["stdout"]), bytes(bufs["stderr"]), reason + + +def run_bind(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, rel: str, + preimage: str, postimage: str, slots_dir: str, target: str, selected_class: str, + bind_params: dict[str, Any], convert_ids: list[str]) -> dict[str, Any]: + core = os.path.join(work, "bind") + os.makedirs(core, exist_ok=True) + pre_path = os.path.join(core, "pre.cs") + post_path = os.path.join(core, "post.cs") + params_path = os.path.join(core, "bind-params.json") + out_path = os.path.join(core, "binding-result.json") + with open(pre_path, "w", encoding="utf-8", newline="") as fh: + fh.write(preimage) + with open(post_path, "w", encoding="utf-8", newline="") as fh: + fh.write(postimage) + with open(params_path, "wb") as fh: + fh.write(_canonical_json(bind_params)) + argv = [dotnet_host, "exec", "--fx-version", selected_ver, "--roll-forward", "Disable", + probe_dll, "bind", "--preimage", pre_path, "--postimage", post_path, + "--slots-dir", slots_dir, "--target", target, "--selected-class", selected_class, + "--source-file", rel, "--bind-params", params_path, "--out", out_path] + rc, _out, err, reason = _run_child(argv, core, _probe_env(work, core), _CHILD_TIMEOUT_SECONDS) + if reason is not None: + raise TargetError(INFRASTRUCTURE, f"bind {reason}") + err_text = err.decode("utf-8", "replace").strip()[:300] + if rc in _BIND_EXIT: + raise TargetError(_BIND_EXIT[rc], f"bind: {err_text}") + if rc != 0: + raise TargetError(INFRASTRUCTURE, f"bind failed (rc={rc}): {err_text}") + try: + with open(out_path, "rb") as fh: + raw = fh.read() + except OSError as exc: + raise TargetError(INFRASTRUCTURE, f"binding-result.json unreadable ({exc})") from exc + if len(raw) > _OUT_LIMIT: + raise TargetError(INFRASTRUCTURE, "binding-result.json too large") + try: + binding = json.loads(raw) + except ValueError as exc: + raise TargetError(INFRASTRUCTURE, f"binding-result.json is not valid JSON ({exc})") from exc + if _canonical_bytes(binding) != raw: + raise TargetError(INFRASTRUCTURE, "binding-result.json is not canonical bytes") + _validate_binding_result(binding, convert_ids) + return cast("dict[str, Any]", binding) + + +_BINDING_KEYS = ("version", "operation", "converted_callsites", "derived_wrapper_ordinal", + "resolved_wrapper", "callsite_binding", "callsites") +_BINDING_RW_KEYS = ("assembly_simple_name", "module_mvid", "metadata_token", "resolved_signature") +_BINDING_CB_KEYS = ("all_callsites_same_symbol", "target_is_source_defined") +_BINDING_CS_KEYS = ("finding_id", "preimage_span", "postimage_span", "assembly_simple_name", + "module_mvid", "metadata_token", "resolved_signature") + + +def _is_int(x: Any) -> bool: + return isinstance(x, int) and not isinstance(x, bool) + + +def _validate_binding_result(obj: Any, convert_ids: list[str]) -> None: + """Strict canonical binding-result.json validation (H4). A malformed shape/type is + INFRASTRUCTURE; a well-formed but semantically incomplete / non-bijective binding is + CALLSITE_BINDING.""" + inf = INFRASTRUCTURE + if not isinstance(obj, dict) or set(obj) != set(_BINDING_KEYS): + raise TargetError(inf, "binding-result.json is not the exact schema") + if not _is_int(obj["version"]) or obj["version"] != 1 or obj["operation"] != "weak-target-bind": + raise TargetError(inf, "binding-result.json version/operation wrong") + if not _is_int(obj["converted_callsites"]): + raise TargetError(inf, "converted_callsites must be an int") + if not _is_int(obj["derived_wrapper_ordinal"]) or obj["derived_wrapper_ordinal"] < 0: + raise TargetError(inf, "derived_wrapper_ordinal must be a non-negative int") + rw = obj["resolved_wrapper"] + if not isinstance(rw, dict) or set(rw) != set(_BINDING_RW_KEYS) \ + or not all(isinstance(rw[k], str) for k in _BINDING_RW_KEYS): + raise TargetError(inf, "resolved_wrapper is not the exact schema") + cb = obj["callsite_binding"] + if not isinstance(cb, dict) or set(cb) != set(_BINDING_CB_KEYS) \ + or not all(isinstance(cb[k], bool) for k in _BINDING_CB_KEYS): + raise TargetError(inf, "callsite_binding is not the exact schema") + cs = obj["callsites"] + if not isinstance(cs, list): + raise TargetError(inf, "callsites must be a list") + fids, spans = [], [] + for c in cs: + if not isinstance(c, dict) or set(c) != set(_BINDING_CS_KEYS): + raise TargetError(inf, "a callsite is not the exact schema") + if not isinstance(c["finding_id"], str): + raise TargetError(inf, "callsite finding_id must be a string") + for sk in ("preimage_span", "postimage_span"): + sp = c[sk] + if not isinstance(sp, list) or len(sp) != 2 \ + or not all(_is_int(v) and v >= 0 for v in sp): + raise TargetError(inf, f"callsite {sk} must be two non-negative ints") + if any(c[k] != rw[k] for k in _BINDING_RW_KEYS): + raise TargetError(inf, "a callsite identity does not equal resolved_wrapper") + fids.append(c["finding_id"]) + spans.append((c["postimage_span"][0], c["postimage_span"][1])) + if fids != sorted(fids): + raise TargetError(inf, "callsites are not sorted by finding_id") + want = set(convert_ids) + if obj["converted_callsites"] != len(want) or len(cs) != len(want) \ + or set(fids) != want or len(set(fids)) != len(fids): + raise TargetError(CALLSITE_BINDING, "callsites are not a bijection onto the converted ids") + if len(set(spans)) != len(spans): + raise TargetError(CALLSITE_BINDING, "two callsites share a postimage span") + + +def _probe_env(work: str, cwd_dir: str) -> dict[str, str]: + env: dict[str, str] = {} + for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "LANG", "LC_ALL"): + if k in os.environ: + env[k] = os.environ[k] + home = os.path.join(work, "home") + env["HOME"] = home + env["XDG_CACHE_HOME"] = os.path.join(home, ".cache") + env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" + env["DOTNET_NOLOGO"] = "1" + env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" + env["TMPDIR"] = cwd_dir + env["TEMP"] = cwd_dir + env["TMP"] = cwd_dir + return env + + +# --- the probe subprocess + classification (G3, G4, F4, F5) ------------------------- + +_PROBE_KEYS = ("version", "operation", "attempt", "strong_delivered_once", "strong_retained", + "weak_control_collected", "delivered_count", "threw_on_subscribe", + "threw_on_first_raise", "subscriber_collected", "threw_on_post_collection_raise", + "resolved_wrapper") +_RESOLVED_KEYS = ("ordinal", "slot_sha256", "assembly_simple_name", "module_mvid", + "metadata_token", "resolved_signature") + + +def run_probe_attempt(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, + wrapper_ordinal: int, slots_dir: str, target: str, attempt: int, + runtime_dir: str) -> tuple[int, dict[str, Any] | None]: + adir = os.path.join(work, f"attempt-{attempt}") + os.makedirs(adir, exist_ok=True) + out_path = os.path.join(adir, "probe-result.json") + argv = [dotnet_host, "exec", "--fx-version", selected_ver, "--roll-forward", "Disable", + probe_dll, "probe", "--wrapper-ordinal", str(wrapper_ordinal), + "--slots-dir", slots_dir, "--runtime-dir", runtime_dir, "--attempt", str(attempt), + "--target", target, "--out", out_path] + rc, _out, _err, reason = _run_child(argv, os.path.join(work, "probe"), + _probe_env(work, adir), _CHILD_TIMEOUT_SECONDS) + if reason is not None: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} {reason}") + if rc not in (0, 10): + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} rc={rc}") + obj = _read_probe_json(out_path, attempt) + if rc == 10: + _validate_unsupported_result(obj, attempt) # exit 10 needs the exact unsupported schema + return 10, None + _validate_probe_result(obj, attempt) + return 0, obj + + +def _read_probe_json(out_path: str, attempt: int) -> Any: + try: + with open(out_path, "rb") as fh: + raw = fh.read() + except OSError as exc: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result unreadable ({exc})") \ + from exc + if len(raw) > _OUT_LIMIT: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result too large") + try: + obj = json.loads(raw) + except ValueError as exc: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result not JSON ({exc})") \ + from exc + if _canonical_bytes(obj) != raw: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result not canonical") + return obj + + +_UNSUPPORTED_KEYS = ("version", "operation", "attempt", "runtime_unsupported", "reason") + + +def _validate_unsupported_result(obj: Any, attempt: int) -> None: + if not isinstance(obj, dict) or set(obj) != set(_UNSUPPORTED_KEYS): + raise TargetError(INFRASTRUCTURE, "runtime-unsupported result is not the exact schema") + if not _is_int(obj["version"]) or obj["version"] != 1 \ + or obj["operation"] != "weak-target-probe" \ + or not _is_int(obj["attempt"]) or obj["attempt"] != attempt: + raise TargetError(INFRASTRUCTURE, "runtime-unsupported result version/operation/attempt") + if obj["runtime_unsupported"] is not True or not isinstance(obj["reason"], str): + raise TargetError(INFRASTRUCTURE, "runtime-unsupported result flag/reason wrong") + + +def _validate_probe_result(obj: Any, attempt: int) -> None: + if not isinstance(obj, dict) or set(obj) != set(_PROBE_KEYS): + raise TargetError(INFRASTRUCTURE, "probe-result.json is not the exact schema") + if not _is_int(obj["version"]) or obj["version"] != 1 \ + or obj["operation"] != "weak-target-probe" \ + or not _is_int(obj["attempt"]) or obj["attempt"] != attempt: + raise TargetError(INFRASTRUCTURE, "probe-result.json version/operation/attempt wrong") + for k in ("strong_delivered_once", "strong_retained", "weak_control_collected", + "threw_on_subscribe", "threw_on_first_raise", "subscriber_collected", + "threw_on_post_collection_raise"): + if not isinstance(obj[k], bool): + raise TargetError(INFRASTRUCTURE, f"probe-result.{k} must be a boolean") + if not _is_int(obj["delivered_count"]): + raise TargetError(INFRASTRUCTURE, "probe-result.delivered_count must be an int") + rw = obj["resolved_wrapper"] + if not isinstance(rw, dict) or set(rw) != set(_RESOLVED_KEYS): + raise TargetError(INFRASTRUCTURE, "probe-result.resolved_wrapper is not the exact schema") + if not _is_int(rw["ordinal"]) or rw["ordinal"] < 0: + raise TargetError(INFRASTRUCTURE, "resolved_wrapper.ordinal must be a non-negative int") + for k in ("slot_sha256", "assembly_simple_name", "module_mvid", "metadata_token", + "resolved_signature"): + if not isinstance(rw[k], str): + raise TargetError(INFRASTRUCTURE, f"resolved_wrapper.{k} must be a string") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", rw["slot_sha256"]): + raise TargetError(INFRASTRUCTURE, "resolved_wrapper.slot_sha256 is not a lowercase sha256") + if not re.fullmatch(r"0x[0-9a-f]{8}", rw["metadata_token"]): + raise TargetError(INFRASTRUCTURE, "resolved_wrapper.metadata_token is malformed") + if not re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + rw["module_mvid"]): + raise TargetError(INFRASTRUCTURE, "resolved_wrapper.module_mvid is not a GUID") + + +def _attempt_verdict(p: dict[str, Any]) -> str: + if p["threw_on_subscribe"] or p["threw_on_first_raise"] \ + or p["threw_on_post_collection_raise"] or p["delivered_count"] != 1: + return "TARGET_BEHAVIOR" + if not p["subscriber_collected"]: + return "TARGET_RETAINS" + return "pass" + + +def classify(attempts: list[dict[str, Any]], binding: dict[str, Any], + wrapper_ordinal: int, slot_evidence: list[dict[str, Any]]) -> str: + """Return the final target verdict ('pass') or raise the refusal per the exact F5 + precedence, after the G3 actually-loaded identity cross-check.""" + b = binding["resolved_wrapper"] + slot = slot_evidence[wrapper_ordinal] + for p in attempts: + rw = p["resolved_wrapper"] + if rw["ordinal"] != wrapper_ordinal or rw["slot_sha256"] != slot["sha256"] \ + or rw["assembly_simple_name"] != b["assembly_simple_name"] \ + or rw["module_mvid"] != b["module_mvid"] \ + or rw["metadata_token"] != b["metadata_token"] \ + or rw["resolved_signature"] != b["resolved_signature"]: + raise TargetError(WRAPPER_BINDING, "an attempt loaded a different wrapper identity") + # 2. controls + for p in attempts: + if not (p["strong_delivered_once"] and p["strong_retained"] + and p["weak_control_collected"]): + raise TargetError(HARNESS_INVALID, "a strong/collectability control failed") + # 3-7. target verdicts + verdicts = [_attempt_verdict(p) for p in attempts] + if len(set(verdicts)) != 1: + raise TargetError(HARNESS_NONDETERMINISM, f"attempts disagree: {verdicts}") + v = verdicts[0] + if v == "TARGET_BEHAVIOR": + raise TargetError(TARGET_BEHAVIOR, "the wrapper did not deliver exactly once / threw") + if v == "TARGET_RETAINS": + raise TargetError(TARGET_RETAINS, "the wrapper retained the subscriber (fake target)") + return "pass" + + +# --- publication (F7, G5) ---------------------------------------------------------- + + +def _publish_target(out: str, protected: list[str], evidence_bytes: bytes) -> str: + from ownlang.fix_gate import PUBLICATION as _P + try: + out_phys, parent_phys, _root_phys = _out_parent(out, out) # root==out: only the + # existence/off-parent checks matter; the protected-root exclusion is explicit below. + except GateError as exc: + raise TargetError(exc.category if exc.category != _P else PUBLICATION, str(exc)) from exc + for root in protected: + if _same_or_inside(os.path.realpath(root), parent_phys): + raise TargetError(PUBLICATION, + "the out-dir parent resolves inside a protected root") + workdir = _claim_workdir(parent_phys) + succeeded = False + try: + with open(os.path.join(workdir, "target-result.json"), "wb") as fh: + fh.write(evidence_bytes) + if not _same_path(os.path.realpath(os.path.dirname(out_phys)), parent_phys) \ + or os.path.exists(out_phys) or os.path.islink(out_phys): + raise TargetError(PUBLICATION, "the out-dir destination changed before publication") + _require_single(workdir) + try: + os.rename(workdir, out_phys) + except OSError as exc: + raise TargetError(PUBLICATION, f"cannot publish ({exc})") from exc + succeeded = True + finally: + if not succeeded: + try: + shutil.rmtree(workdir) + except OSError as exc: + raise TargetError(PUBLICATION, f"cannot remove staging ({exc})") from exc + return out_phys + + +def _require_single(workdir: str) -> None: + entries = list(os.scandir(workdir)) + if len(entries) != 1 or entries[0].name != "target-result.json": + raise TargetError(PUBLICATION, "staging is not exactly target-result.json") + st = entries[0].stat(follow_symlinks=False) + if _is_link(st) or not stat.S_ISREG(st.st_mode): + raise TargetError(PUBLICATION, "staged target-result.json is not a regular file") + + +# --- target-result serializers + orchestration ------------------------------------- + + +def _delta_binding_block(delta_bytes: bytes) -> dict[str, Any]: + return {"delta_result_sha256": _sha_bytes(delta_bytes), + "step10_operation": "verify-subscription-analyzer-delta", + "step10_status": "pass", "bound": True} + + +def build_manual_only_result(input_hashes: dict[str, Any], delta_bytes: bytes, + delta: dict[str, Any], target: str, + checks_passed: set[str]) -> dict[str, Any]: + if checks_passed != {"input_layout", "authority_binding", "delta_binding", + "reference_binding", "publication"}: + raise TargetError(INFRASTRUCTURE, "manual-only executed-check set is incomplete") + checks = {n: "not_applicable" if n in _MANUAL_ONLY_NA else "pass" for n in _CHECK_NAMES} + return {"schema": 1, "operation": "verify-target-wrapper", "status": "pass", + "input_hashes": input_hashes, "delta_binding": _delta_binding_block(delta_bytes), + "target_api": {"subscribe": target}, "reference_closure": delta["reference_closure"], + "checks": checks} + + +def build_converted_result(input_hashes: dict[str, Any], delta_bytes: bytes, + delta: dict[str, Any], target: str, slot_evidence: list[dict[str, Any]], + wrapper_ordinal: int, binding: dict[str, Any], probe_fp: dict[str, Any], + dotnet_host_sha: str, dotnet_version: str, + runtime_identity: dict[str, Any], attempts: list[dict[str, Any]], + checks_passed: set[str]) -> dict[str, Any]: + if checks_passed != set(_CHECK_NAMES): + raise TargetError(INFRASTRUCTURE, + f"refusing to publish unexecuted checks: " + f"{sorted(set(_CHECK_NAMES) - checks_passed)}") + b = binding["resolved_wrapper"] + slot = slot_evidence[wrapper_ordinal] + attempt_rows = [{ + "attempt": p["attempt"], "strong_delivered_once": p["strong_delivered_once"], + "strong_retained": p["strong_retained"], + "weak_control_collected": p["weak_control_collected"], + "delivered_count": p["delivered_count"], "threw_on_subscribe": p["threw_on_subscribe"], + "threw_on_first_raise": p["threw_on_first_raise"], + "subscriber_collected": p["subscriber_collected"], + "threw_on_post_collection_raise": p["threw_on_post_collection_raise"], + "verdict": _attempt_verdict(p), + } for p in attempts] + return { + "schema": 1, "operation": "verify-target-wrapper", "status": "pass", + "input_hashes": input_hashes, + "delta_binding": _delta_binding_block(delta_bytes), + "target_api": {"subscribe": target}, + "reference_closure": delta["reference_closure"], + "callsite_binding": { + "converted_callsites": binding["converted_callsites"], + "all_callsites_same_symbol": binding["callsite_binding"]["all_callsites_same_symbol"], + "target_is_source_defined": binding["callsite_binding"]["target_is_source_defined"], + "derived_wrapper_ordinal": binding["derived_wrapper_ordinal"], + "asserted_wrapper_ordinal": wrapper_ordinal, + }, + "selected_wrapper": { + "ordinal": wrapper_ordinal, "relative_path": slot["relative_path"], + "sha256": slot["sha256"], "assembly_simple_name": b["assembly_simple_name"], + "module_mvid": b["module_mvid"], "metadata_token": b["metadata_token"], + "resolved_signature": b["resolved_signature"], + }, + "probe_toolchain_fingerprint": {**probe_fp, "dotnet_host_sha256": dotnet_host_sha, + "dotnet_version": dotnet_version}, + "probe_runtime_identity": runtime_identity, + "probe_protocol": { + "attempt_count": _ATTEMPT_COUNT, "collection_rounds": _COLLECTION_ROUNDS, + "allocation_pressure_bytes_per_round": _ALLOC_PER_ROUND, + "child_timeout_seconds": _CHILD_TIMEOUT_SECONDS, "stdout_limit_bytes": _OUT_LIMIT, + "stderr_limit_bytes": _OUT_LIMIT, "probe_result_limit_bytes": _OUT_LIMIT, + "delivered_count_required": 1, + }, + "attempts": attempt_rows, + "checks": dict.fromkeys(_CHECK_NAMES, "pass"), + } + + +def _reval_slots(slot_evidence: list[dict[str, Any]], slot_dirs: list[str]) -> None: + for i, ev in enumerate(slot_evidence): + dll = os.path.join(slot_dirs[i], ev["relative_path"].rsplit("/", 1)[-1]) + if _sha_bytes(_snapshot(dll, REFERENCE_BINDING, "slot")) != ev["sha256"]: + raise TargetError(REFERENCE_BINDING, "a materialized reference slot changed") + + +def _reval_toolchain(work: str, probe_fp: dict[str, Any], slot_evidence: list[dict[str, Any]], + slot_dirs: list[str], rt_dir: str, runtime_identity: dict[str, Any], + dotnet_host: str, dotnet_host_sha: str) -> None: + """Revalidate the copied probe deployment, the materialized slots, the dotnet host, and the + selected runtime — before bind and before EVERY probe attempt (G5).""" + pdir = os.path.join(work, "probe") + if _manifest_sha(pdir, _walk_regular_files(pdir, TOOLCHAIN_BINDING), TOOLCHAIN_BINDING) \ + != probe_fp["probe_deployment_manifest_sha256"]: + raise TargetError(TOOLCHAIN_BINDING, "the materialized probe deployment changed") + _reval_slots(slot_evidence, slot_dirs) + if _hash_resolved(dotnet_host, TOOLCHAIN_BINDING, "dotnet host") != dotnet_host_sha: + raise TargetError(TOOLCHAIN_BINDING, "the dotnet host changed") + if _runtime_manifest(rt_dir) != runtime_identity["selected_runtime_manifest_sha256"]: + raise TargetError(TOOLCHAIN_BINDING, "the selected runtime changed") + + +def _reval_inputs(plan_path: str, candidates_path: str, delta_path: str, root: str, bundle: str, + rel: str, plan_bytes: bytes, candidates_bytes: bytes, delta_bytes: bytes, + input_hashes: dict[str, str]) -> None: + """Revalidate every authoritative input still equals what was bound — before constructing the + published evidence (G5). The locked drift categories: plan/candidates -> AUTHORITY_BINDING, the + delta -> DELTA_BINDING, and the pristine source / patch / manifest / accepted postimage changing + AFTER the initial binding -> ISOLATION (the initial hash mismatch, in bind_bundle, stays + DELTA_BINDING; here we are proving nothing drifted while we executed).""" + if _snapshot(plan_path, INPUT_LAYOUT, "--plan") != plan_bytes: + raise TargetError(AUTHORITY_BINDING, "--plan changed during verification") + if _snapshot(candidates_path, INPUT_LAYOUT, "--candidates") != candidates_bytes: + raise TargetError(AUTHORITY_BINDING, "--candidates changed during verification") + if _snapshot(delta_path, INPUT_LAYOUT, "--delta") != delta_bytes: + raise TargetError(DELTA_BINDING, "--delta changed during verification") + manifest_path = os.path.join(bundle, "apply-manifest.json") + post_path = os.path.join(bundle, "postimage", *rel.split("/")) + for path, key, label in ( + (os.path.join(root, *rel.split("/")), "pre_sha256", "the pristine source"), + (os.path.join(bundle, "change.patch"), "patch_sha256", "change.patch"), + (manifest_path, "apply_manifest_sha256", "apply-manifest.json"), + (post_path, "post_sha256", "the accepted postimage"), + ): + if _sha_bytes(_snapshot(path, ISOLATION, label)) != input_hashes[key]: + raise TargetError(ISOLATION, f"{label} changed after the initial binding") + + +def _execution_root(protected: list[str]) -> str: + """Create EXECUTION_WORK_ROOT under a temp parent PHYSICALLY resolved to be outside and not + equal to every protected root, and with no protected root inside it — else ISOLATION (G5).""" + parent = os.path.realpath(tempfile.gettempdir()) + prots = [os.path.realpath(p) for p in protected] + for pr in prots: + if _same_or_inside(pr, parent): + raise TargetError(ISOLATION, + "the execution temp parent resolves inside a protected root") + root = tempfile.mkdtemp(prefix="owen-target-", dir=parent) + rp = os.path.realpath(root) + for pr in prots: + if _same_or_inside(rp, pr): + _remove_root_strict(root) # cleanup failure -> PUBLICATION (chained); else ISOLATION + raise TargetError(ISOLATION, "a protected root resolves inside the execution root") + return root + + +def _remove_root_strict(work: str) -> None: + """The ONLY execution-root removal (L3) — used by the success path, the isolation-after-creation + path, and the failure path. It removes EXECUTION_WORK_ROOT without swallowing OSError; a cleanup + failure is PUBLICATION, chained from the cleanup error, with the out-dir left absent (private + residue may remain, but no public partial artifact exists). No ignore_errors / except pass.""" + try: + shutil.rmtree(work) + except OSError as exc: + raise TargetError(PUBLICATION, f"cannot remove the execution root ({exc})") from exc + + +def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: str, + delta_path: str, probe_dll: str | None, out: str, ref_dirs: list[str], + wrapper_ordinal: int | None) -> str: + passed: set[str] = set() + plan_bytes = _snapshot(plan_path, INPUT_LAYOUT, "--plan") + candidates_bytes = _snapshot(candidates_path, INPUT_LAYOUT, "--candidates") + delta_bytes = _snapshot(delta_path, INPUT_LAYOUT, "--delta") + auth, _plan, candidates = load_authority(plan_bytes, candidates_bytes) + passed.add("authority_binding") + convert_ids = list(auth.applied) + converted = bool(convert_ids) + if converted and (probe_dll is None or wrapper_ordinal is None): + raise TargetError(INPUT_LAYOUT, "converted plan needs --probe-dll and --wrapper-ordinal") + if not converted and (probe_dll is not None or wrapper_ordinal is not None): + raise TargetError(INPUT_LAYOUT, "manual-only plan forbids --probe-dll/--wrapper-ordinal") + + delta = bind_delta(delta_bytes, auth, plan_bytes, candidates_bytes) + rel = auth.rel + bundle_info = bind_bundle(bundle, root, rel, delta) + passed.add("input_layout") + passed.add("delta_binding") + target = auth.target_subscribe + input_hashes = { + "input_bundle_sha256": auth.input_bundle_sha256, + "validated_plan_sha256": _sha_bytes(plan_bytes), + "candidates_sha256": _sha_bytes(candidates_bytes), + "apply_manifest_sha256": bundle_info["apply_manifest_sha256"], + "patch_sha256": bundle_info["patch_sha256"], + "pre_sha256": bundle_info["pre_sha256"], "post_sha256": bundle_info["post_sha256"], + } + + # the protected roots the EXECUTION_WORK_ROOT must be created outside of (G5). `work` itself is + # added afterwards for the publication-staging exclusion. + out_parent = os.path.realpath(os.path.dirname(os.path.abspath(out))) + iso_protected = [root, bundle, out_parent, *ref_dirs] + if probe_dll is not None: + iso_protected.append(os.path.dirname(os.path.abspath(probe_dll))) + work = _execution_root(iso_protected) + publish_protected = [root, bundle, work, *ref_dirs] + if probe_dll is not None: + publish_protected.append(os.path.dirname(os.path.abspath(probe_dll))) + + work_removed = False + try: + slot_dirs, slot_evidence = reference_closure(work, ref_dirs, delta) + passed.add("reference_binding") + + if not converted: + _reval_inputs(plan_path, candidates_path, delta_path, root, bundle, rel, + plan_bytes, candidates_bytes, delta_bytes, input_hashes) + _reval_slots(slot_evidence, slot_dirs) + passed.add("publication") + evidence_bytes = _canonical( + build_manual_only_result(input_hashes, delta_bytes, delta, target, passed)) + _remove_root_strict(work) + work_removed = True + return _publish_target(out, publish_protected, evidence_bytes) + + assert probe_dll is not None and wrapper_ordinal is not None + probe_dll_dst, probe_fp = snapshot_probe_deployment(work, probe_dll) + try: + dotnet_host = _resolve_dotnet_host() + except Exception as exc: + raise TargetError(INFRASTRUCTURE, str(exc)) from exc + runtime_identity, dotnet_version, dotnet_host_sha, selected_ver, rt_dir = \ + resolve_probe_runtime(probe_dll_dst, dotnet_host, delta) + passed.add("probe_toolchain_binding") + + class_fqn = candidates["selection"]["allowed_types"][0]["full_name"] + bind_params = build_bind_params(candidates, convert_ids, rel) + slots_root = os.path.join(work, "references") + _reval_toolchain(work, probe_fp, slot_evidence, slot_dirs, rt_dir, runtime_identity, + dotnet_host, dotnet_host_sha) # before bind + binding = run_bind(work, dotnet_host, probe_dll_dst, selected_ver, rel, + bundle_info["preimage"], bundle_info["postimage"], slots_root, + target, class_fqn, bind_params, convert_ids) + if not (0 <= wrapper_ordinal < len(slot_evidence)): + raise TargetError(INPUT_LAYOUT, "--wrapper-ordinal is out of range") + if binding["derived_wrapper_ordinal"] != wrapper_ordinal: + raise TargetError(INPUT_LAYOUT, "--wrapper-ordinal != the derived ordinal") + + attempts: list[dict[str, Any]] = [] + for k in range(_ATTEMPT_COUNT): + _reval_toolchain(work, probe_fp, slot_evidence, slot_dirs, rt_dir, runtime_identity, + dotnet_host, dotnet_host_sha) # before EVERY attempt + rc, res = run_probe_attempt(work, dotnet_host, probe_dll_dst, selected_ver, + wrapper_ordinal, slots_root, target, k, rt_dir) + if rc == 10: + raise TargetError(WRAPPER_RUNTIME_UNSUPPORTED, + "the wrapper cannot execute under the fixed probe runtime") + assert res is not None + attempts.append(res) + classify(attempts, binding, wrapper_ordinal, slot_evidence) + passed.update({"wrapper_binding", "harness_controls", "target_behavior", + "target_nonretention", "harness_determinism"}) + + # before constructing evidence: revalidate every input and the whole toolchain again. + _reval_inputs(plan_path, candidates_path, delta_path, root, bundle, rel, + plan_bytes, candidates_bytes, delta_bytes, input_hashes) + _reval_toolchain(work, probe_fp, slot_evidence, slot_dirs, rt_dir, runtime_identity, + dotnet_host, dotnet_host_sha) + passed.add("publication") + evidence_bytes = _canonical(build_converted_result( + input_hashes, delta_bytes, delta, target, slot_evidence, wrapper_ordinal, binding, + probe_fp, dotnet_host_sha, dotnet_version, runtime_identity, attempts, passed)) + _remove_root_strict(work) # G5: remove EXECUTION_WORK_ROOT before publication + work_removed = True + # one atomic rename; NO filesystem operation runs after it succeeds. + return _publish_target(out, publish_protected, evidence_bytes) + except BaseException: + # L3: on any pre-publication failure, remove the work root strictly. If cleanup succeeds the + # original controlled refusal is re-raised; if cleanup FAILS, _remove_root_strict raises + # PUBLICATION (chained from the cleanup error) and the out-dir stays absent. No swallowing. + if not work_removed: + _remove_root_strict(work) + raise + diff --git a/spec/CLI.md b/spec/CLI.md index 9ba920ab..e66bcc15 100644 --- a/spec/CLI.md +++ b/spec/CLI.md @@ -12,6 +12,7 @@ | `config`| reads an explicit `own.toml` and prints the declared P-035 `[weak-subscription].subscribe` names, one per line (the minimal P-015 config carrier). `python -m ownlang config ` | non-zero on a **malformed** config (hard error) | | `own-fix subscriptions candidates`| S0 (analysis-only): reads a `--fix-candidates` facts file and, for one **exact** `--class `, emits a deterministic `candidates.json` — a selection-request safety envelope plus a candidate bundle per leaky subscription (line-independent `finding_id`, pinned `target_api`, `allowed_actions` = `convert_acquire` for a proven INotifyPropertyChanged contract else `manual_review`, per-file SHA-256). `python -m ownlang own-fix subscriptions candidates --config --class [--finding-id ]... --output [--root ]` | non-zero on a partial/nested/generated/unknown class, an unknown finding-id, an unpinnable target, or an unreadable source | | `own-fix subscriptions verify-delta`| S2 step 10 (analyzer-semantic gate): binds the mandatory step 9 `gate-result.json`, then re-runs Own.NET's real core analyzer — from a **snapshotted** `ownlang` package in a fresh isolated `python -S -B -E` subprocess, the extractor from a snapshotted deployment on the pinned runtime — over the pristine preimage and the accepted step 8 postimage, and proves the OWN001 delta matches the plan (converted candidates gone, manual-review preserved, no new OWN001 of any resource lane, no new OWN050), publishing a byte-deterministic `delta-result.json`. OWN001-only (an OWN014 candidate is `ANALYSIS_SCOPE`); no `--config`. `python -m ownlang own-fix subscriptions verify-delta --bundle --plan --candidates --root --gate --extractor-dll --out [--ref-dir ]...` | non-zero (exit 2) on any refusal (stable category: `INPUT_LAYOUT`/`AUTHORITY_BINDING`/`GATE_BINDING`/`TOOLCHAIN_BINDING`/`ANALYSIS_SCOPE`/`BASELINE_ANALYSIS`/`POSTIMAGE_ANALYSIS`/`ANALYSIS_IDENTITY`/`DELTA_MISMATCH`/`NEW_OWN001`/`NEW_OWN050`/`IDEMPOTENCE`/`ISOLATION`/`PUBLICATION`/`INFRASTRUCTURE`), no partial output | +| `own-fix subscriptions verify-target`| S2 step 11 (fake-target gate): binds the mandatory step 10 `delta-result.json` and the step 8 bundle, then proves the wrapper the accepted postimage actually calls is a genuine non-retaining subscription — a fixed Roslyn `bind` (SemanticModel over the pristine preimage + accepted postimage; per-finding callsite bijection; the `plan.target_api.subscribe` shape resolved inside the selected reference-slot wrapper) followed by a fixed runtime `probe` (three fresh isolated children that load the derived wrapper from its exact materialized slot via a dedicated AssemblyLoadContext, run a runtime-compatibility preflight, then a frozen GC harness) proving the subscriber becomes GC-collectable after a subscribe-then-drop. A wrapper that retains the subscriber is a fake target (`TARGET_RETAINS`). A converted plan needs `--probe-dll`/`--wrapper-ordinal`; a manual-only plan forbids them (the six probe checks publish `not_applicable`). Publishes a byte-deterministic `target-result.json`. `python -m ownlang own-fix subscriptions verify-target --bundle --root --plan --candidates --delta --out [--probe-dll --wrapper-ordinal ] [--ref-dir ]...` | non-zero (exit 2) on any refusal (stable category: `INPUT_LAYOUT`/`AUTHORITY_BINDING`/`DELTA_BINDING`/`REFERENCE_BINDING`/`TOOLCHAIN_BINDING`/`CALLSITE_BINDING`/`WRAPPER_BINDING`/`WRAPPER_RUNTIME_UNSUPPORTED`/`HARNESS_INVALID`/`TARGET_BEHAVIOR`/`TARGET_RETAINS`/`HARNESS_NONDETERMINISM`/`ISOLATION`/`PUBLICATION`/`INFRASTRUCTURE`), no partial output | Notes: - `check`'s non-zero exit on errors is what makes it usable as a CI gate. diff --git a/tests/test_verify_target.py b/tests/test_verify_target.py new file mode 100644 index 00000000..bdfbc075 --- /dev/null +++ b/tests/test_verify_target.py @@ -0,0 +1,828 @@ +#!/usr/bin/env python3 +"""S2 step 11 — the Verified Target Wrapper gate (Tier A, SDK-free). + +Drives ownlang/fix_target.py without dotnet: the manual-only path is a full public +end-to-end (authority, delta binding, Step 8 bundle binding, reference-closure equality, +conditional inputs, the manual-only serializer, and atomic publication) in pure Python; the +classify precedence, the converted/manual-only serializers, the publisher, and the handler +peel are exercised over synthetic inputs. The real bind/probe over dotnet is the Tier-B job. + +Run: python tests/test_verify_target.py +""" + +from __future__ import annotations + +import copy +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang import fix_target as ft +from ownlang.fix_gate import _bundle_sha256, _canonical_bytes, _sha_bytes, validate_gate_authority + +_EV = "System.ComponentModel.INotifyPropertyChanged.PropertyChanged" +_REL = "Own/Sample.cs" +_PRE = b"class A\n{\n void M()\n {\n p.PropertyChanged += OnX;\n }\n}\n" + + +def _cand(fid: str, start: int, dc: str = "OWN001", contract: str = "name_only", + actions: list | None = None) -> dict: + return {"finding_id": fid, "diagnostic_code": dc, "containing_type": "N.A", "file": _REL, + "enclosing_member": "N.A..ctor(N.IPub)", "event": "PropertyChanged", + "event_identity": _EV, "event_contract": contract, "source": "p", + "source_identity": "p", "source_identity_kind": "computed", "handler": "OnX", + "handler_identity": "N.A.OnX(object, ...)", "handler_identity_kind": "stable_symbol", + "occurrence_ordinal": 0, + "acquire_span": {"start": start, "length": 10, "start_line": 5, + "start_column": 9, "end_line": 5, "end_column": 19}, + "teardown": {"status": "none", "candidates": []}, + "allowed_actions": actions or ["manual_review"]} + + +def _cands(cs: list) -> dict: + return {"version": 1, "operation": "fix-subscriptions", + "target_api": {"subscribe": "WeakEvents.AddPropertyChanged"}, + "selection": {"allowed_types": [{"full_name": "N.A", "file": _REL}], + "selected_findings": None, + "constraints": {"max_types_changed": 1, "max_files_changed": 1, + "allow_helper_changes": False, + "allow_config_changes": False, + "allow_suppressions": False}}, + "source_files": [{"path": _REL, "sha256": _sha_bytes(_PRE)}], "candidates": cs} + + +def _plan(cands: dict, actions: list) -> dict: + return {"version": 1, "operation": "fix-subscriptions", + "input_bundle_sha256": _bundle_sha256(cands), + "target_api": {"subscribe": cands["target_api"]["subscribe"]}, + "selection": {"allowed_types": [dict(cands["selection"]["allowed_types"][0])], + "selected_findings": cands["selection"]["selected_findings"], + "constraints": dict(cands["selection"]["constraints"])}, + "source_files": [dict(cands["source_files"][0])], + "decisions": [{"finding_id": c["finding_id"], "action": actions[i], + "file": c["file"], "acquire_span": c["acquire_span"]} + for i, c in enumerate(cands["candidates"])]} + + +def _make_delta(cands_bytes: bytes, plan_bytes: bytes, auth, manifest_sha: str, patch_sha: str, + pre_sha: str, post_sha: str, ref_closure: list) -> bytes: + d = { + "schema": 1, "operation": "verify-subscription-analyzer-delta", "status": "pass", + "analysis_scope": {"source_file": _REL, "selected_class": "N.A", + "closure_kind": "single-file", "reference_dir_count": 0, + "target_file_identity": _REL}, + "input_hashes": { + "input_bundle_sha256": auth.input_bundle_sha256, + "validated_plan_sha256": _sha_bytes(plan_bytes), + "candidates_sha256": _sha_bytes(cands_bytes), + "apply_manifest_sha256": manifest_sha, "patch_sha256": patch_sha, + "pre_sha256": pre_sha, "post_sha256": post_sha, + }, + "toolchain_fingerprint": {"resolved_runtime_identity": { + "framework_name": "Microsoft.NETCore.App", "tfm": "net8.0", + "requested_framework_version": "8.0.0", "selected_framework_version": "8.0.28", + "runtime_manifest_sha256": "sha256:" + "0" * 64}}, + "target_api": {"subscribe": auth.target_subscribe}, + "expected": {"convert_acquire_ids": sorted(auth.applied), + "manual_review_ids": sorted(auth.manual)}, + "reference_closure": ref_closure, + # the remaining frozen Step 10 top-level keys (present but not consumed by Step 11). + "gate_binding": {"git_gates_status": "not_applicable"}, + "baseline": {}, "postimage": {}, "delta": {}, "semantic_idempotence": {"pass": True}, + "checks": dict.fromkeys(ft._STEP10_CHECKS, "pass"), + } + return _canonical_bytes(d) + + +def _manual_fixture(tmp: str): + """A manual-only chain (no dotnet): candidates all manual_review, an empty-patch bundle, + and a delta that binds. Returns paths + bytes.""" + cands = _cands([_cand("OWN001:sha256:" + "1" * 64, 40)]) + plan = _plan(cands, ["manual_review"]) + cands_bytes = json.dumps(cands).encode() + plan_bytes = json.dumps(plan).encode() + auth = validate_gate_authority(plan, cands) + root = os.path.join(tmp, "root") + os.makedirs(os.path.join(root, os.path.dirname(_REL))) + with open(os.path.join(root, *_REL.split("/")), "wb") as fh: + fh.write(_PRE) + bundle = os.path.join(tmp, "bundle") + os.makedirs(os.path.join(bundle, "postimage", os.path.dirname(_REL))) + with open(os.path.join(bundle, "postimage", *_REL.split("/")), "wb") as fh: + fh.write(_PRE) # manual-only: postimage == preimage + with open(os.path.join(bundle, "change.patch"), "wb") as fh: + fh.write(b"") + manifest = b'{"manual":true}\n' + with open(os.path.join(bundle, "apply-manifest.json"), "wb") as fh: + fh.write(manifest) + delta_bytes = _make_delta(cands_bytes, plan_bytes, auth, _sha_bytes(manifest), + _sha_bytes(b""), _sha_bytes(_PRE), _sha_bytes(_PRE), []) + paths = {} + for name, data in (("candidates.json", cands_bytes), ("plan.json", plan_bytes), + ("delta.json", delta_bytes)): + paths[name] = os.path.join(tmp, name) + with open(paths[name], "wb") as fh: + fh.write(data) + return root, bundle, paths, delta_bytes + + +def _attempt(**over) -> dict: + a = {"attempt": 0, "strong_delivered_once": True, "strong_retained": True, + "weak_control_collected": True, "delivered_count": 1, "threw_on_subscribe": False, + "threw_on_first_raise": False, "subscriber_collected": True, + "threw_on_post_collection_raise": False, + "resolved_wrapper": {"ordinal": 0, "slot_sha256": "sha256:" + "a" * 64, + "assembly_simple_name": "WeakEvents", + "module_mvid": "d94f6f4c-0000-4000-8000-00000000abcd", + "metadata_token": "0x06000001", + "resolved_signature": "System.Void WeakEvents.M()"}} + a.update(over) + return a + + +_BINDING = {"resolved_wrapper": {"assembly_simple_name": "WeakEvents", + "module_mvid": "d94f6f4c-0000-4000-8000-00000000abcd", + "metadata_token": "0x06000001", + "resolved_signature": "System.Void WeakEvents.M()"}, + "converted_callsites": 1, "derived_wrapper_ordinal": 0, + "callsite_binding": {"all_callsites_same_symbol": True, + "target_is_source_defined": False}} +_SLOTS = [{"ordinal": 0, "source_dir_ordinal": 0, "relative_path": "W.dll", + "sha256": "sha256:" + "a" * 64}] + + +def _raises(cat, fn, *a) -> bool: + try: + fn(*a) + except ft.TargetError as exc: + return exc.category == cat + return False + + +def run() -> int: + ok = 0 + bad = 0 + + def check(cond: bool, label: str) -> None: + nonlocal ok, bad + if cond: + ok += 1 + else: + bad += 1 + print(f" FAIL: {label}") + + # --- manual-only end-to-end (dotnet-free) --------------------------------- + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, delta_bytes = _manual_fixture(tmp) + outdir = os.path.join(tmp, "pub", "target") + os.makedirs(os.path.join(tmp, "pub")) + published = ft.run_verify_target(bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, + outdir, [], None) + with open(os.path.join(published, "target-result.json"), "rb") as fh: + res = json.loads(fh.read()) + check(res["status"] == "pass", "manual-only: status pass") + na = {k for k, v in res["checks"].items() if v == "not_applicable"} + check(na == set(ft._MANUAL_ONLY_NA), "manual-only: exactly the six not_applicable") + check("selected_wrapper" not in res and "attempts" not in res, + "manual-only: omits probe fields") + check(os.listdir(published) == ["target-result.json"], "manual-only: only the artifact") + + # a manual-only plan must forbid --probe-dll / --wrapper-ordinal + check(_raises(ft.INPUT_LAYOUT, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], "x.dll", + os.path.join(tmp, "o2"), [], None), + "manual-only + --probe-dll -> INPUT_LAYOUT") + + # tamper the delta -> DELTA_BINDING + bad_delta = os.path.join(tmp, "baddelta.json") + with open(bad_delta, "wb") as fh: + fh.write(delta_bytes.replace(b'"status":"pass"', b'"status":"fail"')) + check(_raises(ft.DELTA_BINDING, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], bad_delta, None, os.path.join(tmp, "o3"), [], None), + "delta status fail -> DELTA_BINDING") + + # tamper the bundle postimage -> DELTA_BINDING (hash mismatch) + with open(os.path.join(bundle, "postimage", *_REL.split("/")), "ab") as fh: + fh.write(b"// tamper\n") + check(_raises(ft.DELTA_BINDING, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, + os.path.join(tmp, "o4"), [], None), + "postimage hash mismatch -> DELTA_BINDING") + + # --- classify precedence matrix ------------------------------------------- + good = [_attempt(attempt=i) for i in range(3)] + check(ft.classify(good, _BINDING, 0, _SLOTS) == "pass", "classify: all pass") + retains = [_attempt(attempt=i, subscriber_collected=False) for i in range(3)] + check(_raises(ft.TARGET_RETAINS, ft.classify, retains, _BINDING, 0, _SLOTS), + "classify: retains -> TARGET_RETAINS") + behav = [_attempt(attempt=i, delivered_count=0) for i in range(3)] + check(_raises(ft.TARGET_BEHAVIOR, ft.classify, behav, _BINDING, 0, _SLOTS), + "classify: delivered!=1 -> TARGET_BEHAVIOR") + threw = [_attempt(attempt=i, threw_on_subscribe=True) for i in range(3)] + check(_raises(ft.TARGET_BEHAVIOR, ft.classify, threw, _BINDING, 0, _SLOTS), + "classify: threw on subscribe -> TARGET_BEHAVIOR") + ctrl = [_attempt(attempt=0, strong_retained=False), _attempt(attempt=1), _attempt(attempt=2)] + check(_raises(ft.HARNESS_INVALID, ft.classify, ctrl, _BINDING, 0, _SLOTS), + "classify: broken strong control -> HARNESS_INVALID") + wctrl = [_attempt(attempt=0, weak_control_collected=False), _attempt(attempt=1), + _attempt(attempt=2)] + check(_raises(ft.HARNESS_INVALID, ft.classify, wctrl, _BINDING, 0, _SLOTS), + "classify: broken collectability control -> HARNESS_INVALID") + disagree = [_attempt(attempt=0), _attempt(attempt=1, subscriber_collected=False), + _attempt(attempt=2)] + check(_raises(ft.HARNESS_NONDETERMINISM, ft.classify, disagree, _BINDING, 0, _SLOTS), + "classify: disagreement -> HARNESS_NONDETERMINISM") + ident = copy.deepcopy(good) + ident[1]["resolved_wrapper"]["module_mvid"] = "00000000-0000-0000-0000-000000000000" + check(_raises(ft.WRAPPER_BINDING, ft.classify, ident, _BINDING, 0, _SLOTS), + "classify: attempt identity mismatch -> WRAPPER_BINDING") + + # --- serializers ---------------------------------------------------------- + ih = {"input_bundle_sha256": "sha256:" + "1" * 64, + "validated_plan_sha256": "sha256:" + "1" * 64, + "candidates_sha256": "sha256:" + "1" * 64, "apply_manifest_sha256": "sha256:" + "1" * 64, + "patch_sha256": "sha256:" + "1" * 64, "pre_sha256": "sha256:" + "1" * 64, + "post_sha256": "sha256:" + "1" * 64} + delta_min = {"reference_closure": _SLOTS} + conv = ft.build_converted_result(ih, b"x\n", delta_min, "WeakEvents.AddPropertyChanged", + _SLOTS, 0, _BINDING, {"probe_deployment_manifest_sha256": "s", + "probe_runner_sha256": "s", + "probe_files": []}, "sha256:" + "0" * 64, + "8.0.100", {"framework_name": "x"}, good, set(ft._CHECK_NAMES)) + check(set(conv["checks"]) == set(ft._CHECK_NAMES) and len(ft._CHECK_NAMES) == 11, + "serializer: eleven checks (converted)") + check(set(conv["checks"].values()) == {"pass"}, "serializer: converted all pass") + check(conv["callsite_binding"]["asserted_wrapper_ordinal"] == 0 + and conv["callsite_binding"]["derived_wrapper_ordinal"] == 0, + "serializer: derived == asserted ordinal recorded") + check(len(conv["attempts"]) == 3 + and conv["probe_protocol"]["allocation_pressure_bytes_per_round"] == 4194304, + "serializer: three attempts + fixed constants") + check(_raises(ft.INFRASTRUCTURE, ft.build_converted_result, ih, b"x\n", delta_min, "T", + _SLOTS, 0, _BINDING, {"probe_deployment_manifest_sha256": "s", + "probe_runner_sha256": "s", "probe_files": []}, + "sha256:0", "8.0.100", {}, good, set(ft._CHECK_NAMES) - {"publication"}), + "serializer: unexecuted check -> INFRASTRUCTURE") + man = ft.build_manual_only_result(ih, b"x\n", delta_min, "T", + {"input_layout", "authority_binding", "delta_binding", + "reference_binding", "publication"}) + check({k for k, v in man["checks"].items() if v == "not_applicable"} == set(ft._MANUAL_ONLY_NA), + "serializer: manual-only six not_applicable") + + # --- _publish_target ------------------------------------------------------ + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "root") + pub = os.path.join(tmp, "pub") + os.makedirs(root) + os.makedirs(pub) + out = os.path.join(pub, "ev") + ft._publish_target(out, [root], b'{"ok":true}\n') + check(os.listdir(out) == ["target-result.json"], "publish: only target-result.json") + check([n for n in os.listdir(pub) if n.startswith(".owen-gate-")] == [], + "publish: no workdir residue") + check(_raises(ft.PUBLICATION, ft._publish_target, os.path.join(root, "x"), [root], + b'{}\n'), "publish: inside protected root -> PUBLICATION") + # cleanup-failure normalization + import shutil as _sh + orig_rename, orig_rmtree = ft.os.rename, ft.shutil.rmtree + + def _boom(*_a, **_k): + raise OSError("boom") + + try: + ft.os.rename = _boom + ft.shutil.rmtree = _boom + check(_raises(ft.PUBLICATION, ft._publish_target, os.path.join(pub, "ev2"), [root], + b'{}\n'), "publish: cleanup failure -> PUBLICATION") + finally: + ft.os.rename, ft.shutil.rmtree = orig_rename, orig_rmtree + _ = _sh + + # --- handler peel --------------------------------------------------------- + check(ft._peel_handler("OnA") == "OnA", "peel: method group") + check(ft._peel_handler("new PropertyChangedEventHandler(OnA)") == "OnA", "peel: new H(M)") + check(ft._peel_handler("new(OnA)") == "OnA", "peel: new(M)") + + _h3_isolation(check) + _h3_revalidation(check) + _h3_cleanup_and_publish(check) + _k1_initial_vs_drift(check) + _h4_validators(check) + _h4_run_child(check) + _h4_bind_delta(check) + _l2_schema(check) + _l3_cleanup(check) + + total = ok + bad + print(f"verify-target (Tier A): {ok}/{total} checks pass") + return 1 if bad else 0 + + +def _reads(p: str) -> bytes: + with open(p, "rb") as fh: + return fh.read() + + +def _h3_isolation(check) -> None: + """G5: EXECUTION_WORK_ROOT is created under a temp parent physically outside every protected + root (TMPDIR inside the source / bundle / output parent is ISOLATION).""" + with tempfile.TemporaryDirectory() as tmp: + for name in ("src", "bundle", "outp"): + prot = os.path.join(tmp, name) + inside = os.path.join(prot, "t") + os.makedirs(inside) + orig = ft.tempfile.gettempdir + try: + ft.tempfile.gettempdir = lambda p=inside: p + check(_raises(ft.ISOLATION, ft._execution_root, [prot]), + f"exec-root: TMPDIR inside {name} -> ISOLATION") + finally: + ft.tempfile.gettempdir = orig + # a temp parent outside every protected root yields a fresh, contained execution root. + safe, prot = os.path.join(tmp, "safe"), os.path.join(tmp, "prot") + os.makedirs(safe) + os.makedirs(prot) + orig = ft.tempfile.gettempdir + try: + ft.tempfile.gettempdir = lambda: safe + wr = ft._execution_root([prot]) + check(os.path.isdir(wr) and ft._same_or_inside(os.path.realpath(safe), wr), + "exec-root: a safe parent yields a fresh contained root") + ft.shutil.rmtree(wr) + finally: + ft.tempfile.gettempdir = orig + + +def _h3_revalidation(check) -> None: + from ownlang.fix_delta import _manifest_sha, _runtime_manifest, _walk_regular_files + + # --- _reval_inputs: any authoritative input changing after binding is a refusal --- + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, delta_bytes = _manual_fixture(tmp) + plan_b, cand_b = _reads(paths["plan.json"]), _reads(paths["candidates.json"]) + parts = _REL.split("/") + man = os.path.join(bundle, "apply-manifest.json") + ih = {"pre_sha256": _sha_bytes(_reads(os.path.join(root, *parts))), + "patch_sha256": _sha_bytes(_reads(os.path.join(bundle, "change.patch"))), + "apply_manifest_sha256": _sha_bytes(_reads(man)), + "post_sha256": _sha_bytes(_reads(os.path.join(bundle, "postimage", *parts)))} + args = (paths["plan.json"], paths["candidates.json"], paths["delta.json"], root, bundle, + _REL, plan_b, cand_b, delta_bytes, ih) + try: + ft._reval_inputs(*args) + check(True, "reval-inputs: no drift passes") + except ft.TargetError: + check(False, "reval-inputs: false drift") + # locked K1 categories: plan/candidates drift -> AUTHORITY_BINDING, delta -> DELTA_BINDING, + # source/patch/manifest/postimage drift AFTER binding -> ISOLATION. + drifts = [(paths["plan.json"], ft.AUTHORITY_BINDING, "plan"), + (paths["candidates.json"], ft.AUTHORITY_BINDING, "candidates"), + (paths["delta.json"], ft.DELTA_BINDING, "delta"), + (os.path.join(root, *parts), ft.ISOLATION, "source"), + (os.path.join(bundle, "change.patch"), ft.ISOLATION, "patch"), + (os.path.join(bundle, "apply-manifest.json"), ft.ISOLATION, "manifest"), + (os.path.join(bundle, "postimage", *parts), ft.ISOLATION, "postimage")] + for path, cat, label in drifts: + original = _reads(path) + with open(path, "ab") as fh: + fh.write(b"// drift\n") + check(_raises(cat, ft._reval_inputs, *args), f"reval-inputs: {label} drift -> {cat}") + with open(path, "wb") as fh: + fh.write(original) + + # --- _reval_toolchain: probe deployment / slot / host / runtime drift --- + with tempfile.TemporaryDirectory() as tmp: + work = os.path.join(tmp, "work") + pdir = os.path.join(work, "probe") + os.makedirs(pdir) + with open(os.path.join(pdir, "probe.dll"), "wb") as fh: + fh.write(b"PROBE") + probe_fp = {"probe_deployment_manifest_sha256": + _manifest_sha(pdir, _walk_regular_files(pdir, ft.TOOLCHAIN_BINDING), + ft.TOOLCHAIN_BINDING)} + slotd = os.path.join(work, "references", "000000") + os.makedirs(slotd) + with open(os.path.join(slotd, "W.dll"), "wb") as fh: + fh.write(b"WDLL") + slot_ev = [{"ordinal": 0, "relative_path": "W.dll", "sha256": _sha_bytes(b"WDLL")}] + rt = os.path.join(tmp, "rt") + os.makedirs(rt) + with open(os.path.join(rt, "System.Private.CoreLib.dll"), "wb") as fh: + fh.write(b"RT") + rid = {"selected_runtime_manifest_sha256": _runtime_manifest(rt)} + host = sys.executable + host_sha = ft._hash_resolved(host, ft.TOOLCHAIN_BINDING, "dotnet host") + base = (work, probe_fp, slot_ev, [slotd], rt, rid, host, host_sha) + try: + ft._reval_toolchain(*base) + check(True, "reval-toolchain: no drift passes") + except ft.TargetError: + check(False, "reval-toolchain: false drift") + with open(os.path.join(pdir, "probe.dll"), "ab") as fh: + fh.write(b"X") + check(_raises(ft.TOOLCHAIN_BINDING, ft._reval_toolchain, *base), + "reval-toolchain: probe deployment drift -> TOOLCHAIN_BINDING") + with open(os.path.join(pdir, "probe.dll"), "wb") as fh: + fh.write(b"PROBE") + with open(os.path.join(slotd, "W.dll"), "ab") as fh: + fh.write(b"X") + check(_raises(ft.REFERENCE_BINDING, ft._reval_toolchain, *base), + "reval-toolchain: slot drift -> REFERENCE_BINDING") + with open(os.path.join(slotd, "W.dll"), "wb") as fh: + fh.write(b"WDLL") + drifted_host = (work, probe_fp, slot_ev, [slotd], rt, rid, host, "sha256:" + "0" * 64) + check(_raises(ft.TOOLCHAIN_BINDING, ft._reval_toolchain, *drifted_host), + "reval-toolchain: dotnet host drift -> TOOLCHAIN_BINDING") + with open(os.path.join(rt, "System.Private.CoreLib.dll"), "ab") as fh: + fh.write(b"X") + check(_raises(ft.TOOLCHAIN_BINDING, ft._reval_toolchain, *base), + "reval-toolchain: selected runtime drift -> TOOLCHAIN_BINDING") + + +def _h3_cleanup_and_publish(check) -> None: + # --- _remove_root_strict: a cleanup failure is PUBLICATION --- + with tempfile.TemporaryDirectory() as tmp: + d = os.path.join(tmp, "work") + os.makedirs(d) + orig = ft.shutil.rmtree + + def _boom(*_a, **_k): + raise OSError("locked") + + try: + ft.shutil.rmtree = _boom + check(_raises(ft.PUBLICATION, ft._remove_root_strict, d), + "remove-root: cleanup failure -> PUBLICATION") + finally: + ft.shutil.rmtree = orig + + # --- no filesystem operation runs after the publication rename; no private residue --- + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, _delta = _manual_fixture(tmp) + outdir = os.path.join(tmp, "pub", "target") + os.makedirs(os.path.join(tmp, "pub")) + state = {"renamed": False, "after": []} + real_rename, real_rmtree = ft.os.rename, ft.shutil.rmtree + real_scandir, real_stat = ft.os.scandir, ft.os.stat + + def rename_spy(a, b): + real_rename(a, b) + state["renamed"] = True + + def guard(name, real): + def _f(*a, **k): + if state["renamed"]: + state["after"].append(name) + return real(*a, **k) + return _f + try: + ft.os.rename = rename_spy + ft.shutil.rmtree = guard("rmtree", real_rmtree) + ft.os.scandir = guard("scandir", real_scandir) + ft.os.stat = guard("stat", real_stat) + before = {n for n in os.listdir(ft.tempfile.gettempdir()) + if n.startswith("owen-target-")} + published = ft.run_verify_target(bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, + outdir, [], None) + after = {n for n in os.listdir(ft.tempfile.gettempdir()) + if n.startswith("owen-target-")} + finally: + ft.os.rename, ft.shutil.rmtree = real_rename, real_rmtree + ft.os.scandir, ft.os.stat = real_scandir, real_stat + check(state["renamed"] and state["after"] == [], + "publish: no filesystem op after the rename") + check(after == before, "publish: no EXECUTION_WORK_ROOT residue after success") + check(os.path.isfile(os.path.join(published, "target-result.json")), + "publish: the artifact exists after a clean run") + + +_RW = {"assembly_simple_name": "WeakEvents", + "module_mvid": "d94f6f4c-0000-4000-8000-00000000abcd", + "metadata_token": "0x06000001", "resolved_signature": "System.Void WeakEvents.M()"} + + +def _binding_result(fids: list[str]) -> dict: + cs = [{"finding_id": f, "preimage_span": [i, 10], "postimage_span": [i * 2, 20], **_RW} + for i, f in enumerate(sorted(fids))] + return {"version": 1, "operation": "weak-target-bind", "converted_callsites": len(fids), + "derived_wrapper_ordinal": 0, "resolved_wrapper": dict(_RW), + "callsite_binding": {"all_callsites_same_symbol": True, + "target_is_source_defined": False}, + "callsites": cs} + + +def _probe_result(**over) -> dict: + r = {"version": 1, "operation": "weak-target-probe", "attempt": 0, + "strong_delivered_once": True, "strong_retained": True, "weak_control_collected": True, + "delivered_count": 1, "threw_on_subscribe": False, "threw_on_first_raise": False, + "subscriber_collected": True, "threw_on_post_collection_raise": False, + "resolved_wrapper": {"ordinal": 0, "slot_sha256": "sha256:" + "a" * 64, + "assembly_simple_name": "WeakEvents", + "module_mvid": "d94f6f4c-0000-4000-8000-00000000abcd", + "metadata_token": "0x06000001", + "resolved_signature": "System.Void WeakEvents.M()"}} + r.update(over) + return r + + +def _k1_initial_vs_drift(check) -> None: + """K1: a startup mismatch against the accepted delta is DELTA_BINDING (bind_bundle); the same + file changing AFTER the initial binding is ISOLATION (_reval_inputs).""" + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, delta_bytes = _manual_fixture(tmp) + with open(paths["candidates.json"], encoding="utf-8") as fh: + cands = json.load(fh) + with open(paths["plan.json"], encoding="utf-8") as fh: + plan = json.load(fh) + auth = validate_gate_authority(plan, cands) + plan_b, cand_b = _reads(paths["plan.json"]), _reads(paths["candidates.json"]) + delta = ft.bind_delta(delta_bytes, auth, plan_b, cand_b) + parts = _REL.split("/") + man = os.path.join(bundle, "apply-manifest.json") + ih = {"pre_sha256": _sha_bytes(_reads(os.path.join(root, *parts))), + "patch_sha256": _sha_bytes(_reads(os.path.join(bundle, "change.patch"))), + "apply_manifest_sha256": _sha_bytes(_reads(man)), + "post_sha256": _sha_bytes(_reads(os.path.join(bundle, "postimage", *parts)))} + args = (paths["plan.json"], paths["candidates.json"], paths["delta.json"], root, bundle, + _REL, plan_b, cand_b, delta_bytes, ih) + for path, label in ((os.path.join(root, *parts), "source"), + (os.path.join(bundle, "change.patch"), "patch"), + (man, "manifest"), + (os.path.join(bundle, "postimage", *parts), "postimage")): + original = _reads(path) + with open(path, "ab") as fh: + fh.write(b"// x\n") + # startup: the accepted bundle no longer binds the delta -> DELTA_BINDING + check(_raises(ft.DELTA_BINDING, ft.bind_bundle, bundle, root, _REL, delta), + f"{label}: initial hash mismatch -> DELTA_BINDING") + # after binding: the same drift during execution -> ISOLATION + check(_raises(ft.ISOLATION, ft._reval_inputs, *args), + f"{label}: post-binding drift -> ISOLATION") + with open(path, "wb") as fh: + fh.write(original) + + +def _h4_validators(check) -> None: + ids = ["OWN001:sha256:" + "1" * 64, "OWN001:sha256:" + "2" * 64] + try: + ft._validate_binding_result(_binding_result(ids), ids) + check(True, "binding-result: a valid bijection passes") + except ft.TargetError: + check(False, "binding-result: false rejection") + # malformed shape/type -> INFRASTRUCTURE + b = _binding_result(ids) + b["derived_wrapper_ordinal"] = True + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: bool ordinal -> INFRASTRUCTURE") + b = _binding_result(ids) + b["callsites"][0]["surprise"] = 1 + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: extra callsite key -> INFRASTRUCTURE") + b = _binding_result(ids) + b["callsites"][0]["postimage_span"] = [1, -1] + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: negative span -> INFRASTRUCTURE") + b = _binding_result(ids) + b["callsites"][0]["module_mvid"] = "different" + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: callsite identity != resolved_wrapper -> INFRASTRUCTURE") + b = _binding_result(ids) + b["version"] = True + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: version=true (bool) -> INFRASTRUCTURE") + # well-formed but non-bijective / duplicate span -> CALLSITE_BINDING + check(_raises(ft.CALLSITE_BINDING, ft._validate_binding_result, _binding_result(ids), ids[:1]), + "binding-result: not a bijection -> CALLSITE_BINDING") + b = _binding_result(ids) + b["callsites"][1]["postimage_span"] = list(b["callsites"][0]["postimage_span"]) + check(_raises(ft.CALLSITE_BINDING, ft._validate_binding_result, b, ids), + "binding-result: duplicate postimage span -> CALLSITE_BINDING") + + # probe-result field types / formats + try: + ft._validate_probe_result(_probe_result(), 0) + check(True, "probe-result: a valid result passes") + except ft.TargetError: + check(False, "probe-result: false rejection") + bad = _probe_result() + bad["resolved_wrapper"]["ordinal"] = True + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), + "probe-result: bool ordinal -> INFRASTRUCTURE") + bad = _probe_result() + bad["resolved_wrapper"]["slot_sha256"] = "sha256:XYZ" + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), + "probe-result: malformed slot sha -> INFRASTRUCTURE") + bad = _probe_result() + bad["resolved_wrapper"]["extra"] = 1 + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), + "probe-result: extra resolved_wrapper key -> INFRASTRUCTURE") + bad = _probe_result() + bad["resolved_wrapper"]["module_mvid"] = "not-a-guid" + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), + "probe-result: malformed mvid -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, _probe_result(version=True), 0), + "probe-result: version=true (bool) -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, _probe_result(attempt=False), 0), + "probe-result: attempt=false for attempt 0 -> INFRASTRUCTURE") + + # runtime-unsupported result schema (gate for accepting child exit 10) + good = {"version": 1, "operation": "weak-target-probe", "attempt": 0, + "runtime_unsupported": True, "reason": "FileNotFoundException"} + try: + ft._validate_unsupported_result(good, 0) + check(True, "unsupported-result: valid schema passes") + except ft.TargetError: + check(False, "unsupported-result: false rejection") + check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, {**good, "reason": 1}, 0), + "unsupported-result: non-string reason -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, + {**good, "runtime_unsupported": False}, 0), + "unsupported-result: flag not true -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, good, 1), + "unsupported-result: wrong attempt -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, + {**good, "attempt": True}, 1), + "unsupported-result: attempt=true for attempt 1 -> INFRASTRUCTURE") + + +def _h4_run_child(check) -> None: + with tempfile.TemporaryDirectory() as tmp: + env = {k: v for k, v in os.environ.items() if k in ("PATH", "SystemRoot", "SYSTEMROOT")} + rc, out, err, reason = ft._run_child( + [sys.executable, "-c", "import time; time.sleep(30)"], tmp, env, 1) + check(reason == "timeout", "run-child: over-time child -> timeout (killed)") + flood_out = "import sys; sys.stdout.buffer.write(b'x'*200000)" + rc, out, err, reason = ft._run_child([sys.executable, "-c", flood_out], tmp, env, 20) + check(reason == "stdout_overflow" and len(out) <= ft._OUT_LIMIT, + "run-child: stdout overflow -> capped + killed") + flood_err = "import sys; sys.stderr.buffer.write(b'y'*200000)" + rc, out, err, reason = ft._run_child([sys.executable, "-c", flood_err], tmp, env, 20) + check(reason == "stderr_overflow" and len(err) <= ft._OUT_LIMIT, + "run-child: stderr overflow -> capped + killed") + rc, out, err, reason = ft._run_child( + [sys.executable, "-c", "print('ok')"], tmp, env, 20) + check(reason is None and rc == 0 and out.strip() == b"ok", + "run-child: a clean child returns bounded output") + + +def _h4_bind_delta(check) -> None: + with tempfile.TemporaryDirectory() as tmp: + _root, _bundle, paths, delta_bytes = _manual_fixture(tmp) + with open(paths["candidates.json"], encoding="utf-8") as fh: + cands = json.load(fh) + with open(paths["plan.json"], encoding="utf-8") as fh: + plan = json.load(fh) + auth = validate_gate_authority(plan, cands) + plan_b, cand_b = _reads(paths["plan.json"]), _reads(paths["candidates.json"]) + try: + ft.bind_delta(delta_bytes, auth, plan_b, cand_b) + check(True, "bind_delta: the frozen 15-key delta binds") + except ft.TargetError: + check(False, "bind_delta: false rejection of a valid delta") + d = json.loads(delta_bytes) + d["surprise"] = 1 + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), + "bind_delta: unknown top-level key -> DELTA_BINDING") + d = json.loads(delta_bytes) + del d["input_hashes"]["pre_sha256"] + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), + "bind_delta: missing consumed field -> DELTA_BINDING") + d = json.loads(delta_bytes) + d["toolchain_fingerprint"]["resolved_runtime_identity"]["tfm"] = 9 + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), + "bind_delta: wrong-typed runtime identity -> DELTA_BINDING") + # exact closed nested key sets: an extra key in a consumed nested object is DELTA_BINDING. + for pointer, label in ( + (("input_hashes",), "input_hashes"), + (("target_api",), "target_api"), + (("toolchain_fingerprint", "resolved_runtime_identity"), "runtime-identity"), + (("analysis_scope",), "analysis_scope"), + (("expected",), "expected"), + ): + d = json.loads(delta_bytes) + node = d + for key in pointer: + node = node[key] + node["surprise"] = 1 + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), + auth, plan_b, cand_b), + f"bind_delta: extra {label} key -> DELTA_BINDING") + # a malformed reference_closure entry (missing keys) is DELTA_BINDING, not a KeyError. + d = json.loads(delta_bytes) + d["reference_closure"] = [{"ordinal": 0, "relative_path": "W.dll"}] + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), + "bind_delta: malformed reference_closure entry -> DELTA_BINDING") + + +def _l2_schema(check) -> None: + """L2: the exact consumed Step 10 schema — bool-vs-int, value types, SHA form, ordinal order.""" + with tempfile.TemporaryDirectory() as tmp: + _root, _bundle, paths, delta_bytes = _manual_fixture(tmp) + with open(paths["candidates.json"], encoding="utf-8") as fh: + cands = json.load(fh) + with open(paths["plan.json"], encoding="utf-8") as fh: + plan = json.load(fh) + auth = validate_gate_authority(plan, cands) + plan_b, cand_b = _reads(paths["plan.json"]), _reads(paths["candidates.json"]) + rc0 = {"ordinal": 0, "source_dir_ordinal": 0, "relative_path": "W.dll", + "sha256": "sha256:" + "a" * 64} + + def run_bad(fn, label: str) -> None: + d = json.loads(delta_bytes) + fn(d) + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), + auth, plan_b, cand_b), label) + + run_bad(lambda d: d.update(schema=True), "L2: schema=true -> DELTA_BINDING") + run_bad(lambda d: d["analysis_scope"].update(reference_dir_count=False), + "L2: reference_dir_count=false -> DELTA_BINDING") + run_bad(lambda d: d["analysis_scope"].update(selected_class=1), + "L2: selected_class non-string -> DELTA_BINDING") + run_bad(lambda d: d["analysis_scope"].update(closure_kind=1), + "L2: closure_kind non-string -> DELTA_BINDING") + run_bad(lambda d: d["expected"].update(manual_review_ids=[1]), + "L2: non-string expected id -> DELTA_BINDING") + run_bad(lambda d: d["input_hashes"].update(pre_sha256="sha256:ZZ"), + "L2: malformed input hash -> DELTA_BINDING") + run_bad(lambda d: d.update(reference_closure=[{**rc0, "ordinal": True}]), + "L2: boolean reference ordinal -> DELTA_BINDING") + run_bad(lambda d: d.update(reference_closure=[{**rc0, "ordinal": -1}]), + "L2: negative reference ordinal -> DELTA_BINDING") + run_bad(lambda d: d.update(reference_closure=[{**rc0, "sha256": "sha256:zz"}]), + "L2: malformed reference sha -> DELTA_BINDING") + run_bad(lambda d: d.update(reference_closure=[dict(rc0), {**rc0, "ordinal": 0}]), + "L2: duplicate / unsorted reference ordinal -> DELTA_BINDING") + # positive control: a valid, ordered two-entry closure binds. + d = json.loads(delta_bytes) + d["reference_closure"] = [rc0, {"ordinal": 1, "source_dir_ordinal": 1, + "relative_path": "B.dll", "sha256": "sha256:" + "b" * 64}] + try: + ft.bind_delta(_canonical_bytes(d), auth, plan_b, cand_b) + check(True, "L2: a valid ordered two-entry closure binds") + except ft.TargetError: + check(False, "L2: false rejection of a valid closure") + + +def _l3_cleanup(check) -> None: + """L3: strict failure-path cleanup — successful cleanup re-raises the original refusal, a + cleanup failure is PUBLICATION, and OUTPUT_DIR is always absent (no ignore_errors).""" + # (a) a controlled failure after the execution root is claimed, cleanup succeeds -> original + # category; (b) same with a cleanup failure -> PUBLICATION. Both leave the out-dir absent. + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, _delta = _manual_fixture(tmp) + refdir = os.path.join(tmp, "refs") # a ref-dir the delta's empty closure will not match + os.makedirs(refdir) + with open(os.path.join(refdir, "X.dll"), "wb") as fh: + fh.write(b"MZ") + out_a = os.path.join(tmp, "out-a") + check(_raises(ft.REFERENCE_BINDING, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, out_a, [refdir], None), + "L3: failure + successful cleanup -> original category") + check(not os.path.exists(out_a), "L3: out-dir absent after the original-category failure") + + out_b = os.path.join(tmp, "out-b") + orig = ft.shutil.rmtree + + def _boom(*_a, **_k): + raise OSError("locked") + + try: + ft.shutil.rmtree = _boom + check(_raises(ft.PUBLICATION, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, out_b, + [refdir], None), + "L3: failure + execution-root cleanup failure -> PUBLICATION") + finally: + ft.shutil.rmtree = orig + check(not os.path.exists(out_b), "L3: out-dir absent after the cleanup-failure PUBLICATION") + + # (c) a protected root resolves inside the execution root: cleanup success -> ISOLATION; + # cleanup failure -> PUBLICATION. + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, "base") + prot = os.path.join(base, "prot") + os.makedirs(prot) + orig_mkdtemp, orig_rmtree = ft.tempfile.mkdtemp, ft.shutil.rmtree + try: + ft.tempfile.mkdtemp = lambda *a, **k: base + check(_raises(ft.ISOLATION, ft._execution_root, [prot]), + "L3: protected-inside-root + cleanup success -> ISOLATION") + os.makedirs(prot, exist_ok=True) + + def _boom(*_a, **_k): + raise OSError("locked") + + ft.shutil.rmtree = _boom + check(_raises(ft.PUBLICATION, ft._execution_root, [prot]), + "L3: protected-inside-root + cleanup failure -> PUBLICATION") + finally: + ft.tempfile.mkdtemp, ft.shutil.rmtree = orig_mkdtemp, orig_rmtree + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/tests/test_verify_target_tierb.py b/tests/test_verify_target_tierb.py new file mode 100644 index 00000000..5b375b5e --- /dev/null +++ b/tests/test_verify_target_tierb.py @@ -0,0 +1,747 @@ +#!/usr/bin/env python3 +"""S2 step 11 — Tier B: the FULL public CLI acceptance of the fake-target gate. + +Every case runs the real pipeline end to end through the PUBLIC command line only — +never a private helper as its proof: + + real extractor -> candidates -> validate-plan -> apply (Owen rewriter) -> gate + -> `own-fix subscriptions verify-delta` (--ref-dir wrapper closure) + -> `own-fix subscriptions verify-target` (fixed Roslyn bind + fixed runtime probe) + -> published target-result.json + +The wrapper under test is a SEPARATELY COMPILED assembly shipped as a reference slot (a +name-only-recognized decoy cannot pass): a genuine weak wrapper is accepted; a strong decoy is +refused TARGET_RETAINS; no-op / twice-delivering / throwing wrappers are refused TARGET_BEHAVIOR; +a wrong-signature wrapper is refused WRAPPER_BINDING; a source-defined target is refused +CALLSITE_BINDING; two converted callsites onto the same wrapper pass; a net9 / missing-dependency +wrapper is refused WRAPPER_RUNTIME_UNSUPPORTED (never TARGET_RETAINS); a manual-only plan passes +with the six probe checks not_applicable; and two independent runs are byte-identical. + +Gating: REQUIRED (a missing dotnet / build / execution failure is a FAILURE, not a skip) exactly +when OWN_TIERB_REQUIRED=1 — which the wpf-extractor CI job sets. In any other context it is the +explicit non-required mode and skips cleanly, so the offline suite stays green. + +Run: OWN_TIERB_REQUIRED=1 python tests/test_verify_target_tierb.py +""" + +from __future__ import annotations + +import glob +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.fix_target import _CHECK_NAMES, _MANUAL_ONLY_NA + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_EXT = os.path.join(_REPO, "frontend", "roslyn", "OwnSharp.Extractor") +_RW = os.path.join(_REPO, "frontend", "roslyn", "Owen.CSharp.Rewriter") +_PROBE = os.path.join(_REPO, "frontend", "roslyn", "OwnSharp.WeakTargetProbe") +_REL = "S.cs" +_FQN = "S" + +# --- the pristine preimage variants ------------------------------------------------ +_PRE = """using System.ComponentModel; +public class S +{ + public S(INotifyPropertyChanged a) { a.PropertyChanged += OnA; } + void OnA(object s, PropertyChangedEventArgs e) { } +} +""" +# two DISTINCT converted callsites (a, b) both onto the same wrapper. +_PRE_TWO = """using System.ComponentModel; +public class S +{ + public S(INotifyPropertyChanged a, INotifyPropertyChanged b) + { a.PropertyChanged += OnA; b.PropertyChanged += OnA; } + void OnA(object s, PropertyChangedEventArgs e) { } +} +""" +# the wrapper is defined IN the source (not a reference slot) -> CALLSITE_BINDING. +_PRE_SRCDEF = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { + var wr = new System.WeakReference(handler.Target); + var mi = handler.Method; + PropertyChangedEventHandler? relay = null; + relay = (s, e) => + { + var t = wr.Target; + if (t == null) source.PropertyChanged -= relay; + else mi.Invoke(t, new object?[]{s, e}); + }; + source.PropertyChanged += relay; + } +} +public class S +{ + public S(INotifyPropertyChanged a) { a.PropertyChanged += OnA; } + void OnA(object s, PropertyChangedEventArgs e) { } +} +""" + +# --- the wrapper variants (all named WeakEvents.AddPropertyChanged) ----------------- +_WEAK = """using System.ComponentModel; +public static class WeakEvents +{ + // A genuine non-retaining subscription: the subscriber is held only weakly. + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { + var wr = new System.WeakReference(handler.Target); + var mi = handler.Method; + PropertyChangedEventHandler? relay = null; + relay = (s, e) => + { + var t = wr.Target; + if (t == null) source.PropertyChanged -= relay; + else mi.Invoke(t, new object?[] { s, e }); + }; + source.PropertyChanged += relay; + } +} +""" +_STRONG = """using System.ComponentModel; +public static class WeakEvents +{ + // A decoy: named like the accepted wrapper but strongly retains the subscriber. + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { source.PropertyChanged += handler; } +} +""" +_NOOP = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) { } +} +""" +_TWICE = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { source.PropertyChanged += handler; source.PropertyChanged += handler; } +} +""" +_THROWING = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { throw new System.InvalidOperationException("no target"); } +} +""" +_WRONGSIG = """using System.ComponentModel; +public static class WeakEvents +{ + // wrong shape: an extra parameter, so the accepted (INPC, PCEH) target does not exist. + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler, int extra) { } +} +""" +# net9 wrapper: correct shape and byte-valid metadata, but cannot execute under the net8 probe. +_INCOMPAT = _STRONG +# missing runtime dependency: the body calls a Helper assembly not shipped in the slot. +_HELPER = "namespace Helper { public static class Aux { public static void Touch() { } } }\n" +_MISSINGDEP = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { Helper.Aux.Touch(); source.PropertyChanged += handler; } +} +""" +# exact-shape target PLUS a same-name overload -> WRAPPER_BINDING (no other overload allowed). +_OVERLOAD = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) { } + public static void AddPropertyChanged(string source, PropertyChangedEventHandler handler) { } +} +""" +# two different assemblies both exporting global WeakEvents -> the source call is ambiguous. +_AMB = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) { } +} +""" +# a wrapper that depends on a Helper assembly (weak, genuinely non-retaining) when Helper resolves. +_DEPWEAK = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { + Helper.Aux.Touch(); + var wr = new System.WeakReference(handler.Target); + var mi = handler.Method; + PropertyChangedEventHandler? relay = null; + relay = (s, e) => + { + var t = wr.Target; + if (t == null) source.PropertyChanged -= relay; + else mi.Invoke(t, new object?[] { s, e }); + }; + source.PropertyChanged += relay; + } +} +""" +_HELPER_OK = "namespace Helper { public static class Aux { public static void Touch() { } } }\n" +_HELPER_THROW = ("namespace Helper { public static class Aux { public static void Touch() " + "{ throw new System.InvalidOperationException(\"bad helper\"); } } }\n") +# a wrapper whose body makes a RUNTIME call into Microsoft.CodeAnalysis — a NON-framework assembly +# already present in the probe's default context — proving the closed load context never satisfies +# it from there. (A constant/enum reference would be folded at compile time and load nothing, so the +# body must force an actual assembly load: a real method call into the Roslyn assembly.) +_DEP_ROSLYN = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { + var e = Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseExpression("a"); + if (e != null) source.PropertyChanged += handler; + } +} +""" + +_CASES = [ + {"name": "weak", "pre": _PRE, "wrapper": _WEAK, "convert": True, "ref": True, + "probe": True, "expect": ("pass", "converted")}, + {"name": "strong", "pre": _PRE, "wrapper": _STRONG, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "TARGET_RETAINS")}, + {"name": "noop", "pre": _PRE, "wrapper": _NOOP, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "TARGET_BEHAVIOR")}, + {"name": "twice", "pre": _PRE, "wrapper": _TWICE, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "TARGET_BEHAVIOR")}, + {"name": "throwing", "pre": _PRE, "wrapper": _THROWING, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "TARGET_BEHAVIOR")}, + {"name": "wrongsig", "pre": _PRE, "wrapper": _WRONGSIG, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "WRAPPER_BINDING")}, + {"name": "overload", "pre": _PRE, "wrapper": _OVERLOAD, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "WRAPPER_BINDING")}, + {"name": "srcdef", "pre": _PRE_SRCDEF, "wrapper": None, "convert": True, "ref": False, + "probe": True, "expect": ("refuse", "CALLSITE_BINDING")}, + {"name": "twoconv", "pre": _PRE_TWO, "wrapper": _WEAK, "convert": True, "ref": True, + "probe": True, "expect": ("pass", "two")}, + {"name": "incompat", "pre": _PRE, "wrapper": _INCOMPAT, "tfm": "net9.0", "convert": True, + "ref": True, "probe": True, "expect": ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED")}, + # a .NET Framework 4.x wrapper references mscorlib -> cross-family incompatibility (L1). + {"name": "netfx", "pre": _PRE, "wrapper": _STRONG, "tfm": "net48", "convert": True, + "ref": True, "probe": True, "expect": ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED")}, + # a compatible netstandard2.0 wrapper is NOT rejected by the runtime-family predicate. + {"name": "netstandard", "pre": _PRE, "wrapper": _WEAK, "tfm": "netstandard2.0", "convert": True, + "ref": True, "probe": True, "expect": ("pass", "converted")}, + {"name": "missingdep", "pre": _PRE, "wrapper": _MISSINGDEP, "helper": _HELPER, + "convert": True, "ref": True, "probe": True, + "expect": ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED")}, + {"name": "manual", "pre": _PRE, "wrapper": None, "convert": False, "ref": False, + "probe": False, "expect": ("pass", "manual")}, +] + + +class Fail(Exception): + pass + + +def _sha(b: bytes) -> str: + return "sha256:" + hashlib.sha256(b).hexdigest() + + +def _run(argv: list[str], cwd: str | None = None, + env: dict | None = None) -> subprocess.CompletedProcess: + # decode as UTF-8 with replacement — a localized MSBuild / NuGet-restore line (e.g. under a + # non-UTF-8 Windows console codepage) must never crash the reader thread. + return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, check=False, env=env, + encoding="utf-8", errors="replace") + + +def _py(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess: + return _run([sys.executable, "-m", "ownlang", *args], cwd=cwd or _REPO) + + +def _find_dotnet() -> str | None: + return shutil.which("dotnet") or (r"C:\Program Files\dotnet\dotnet.exe" + if os.path.isfile(r"C:\Program Files\dotnet\dotnet.exe") + else None) + + +def _dotnet_env(dotnet: str) -> dict: + env = dict(os.environ) + env["PATH"] = os.path.dirname(dotnet) + os.pathsep + env.get("PATH", "") + return env + + +def _build(dotnet: str) -> tuple[str, str]: + for proj in (_EXT, _RW, _PROBE): + p = _run([dotnet, "build", proj, "-c", "Release", "-v", "q", "--nologo"]) + if p.returncode != 0: + raise Fail(f"build {proj}: {p.stdout[-400:]}{p.stderr[-400:]}") + ext = glob.glob(os.path.join(_EXT, "bin", "*", "*", "ownsharp-extract.dll")) + probe = glob.glob(os.path.join(_PROBE, "bin", "*", "net8.0", "OwnSharp.WeakTargetProbe.dll")) + if not ext or not probe: + raise Fail("extractor/probe DLL not found after build") + return ext[0], probe[0] + + +def _csproj(tfm: str, refs: list[tuple[str, str]], asmname: str, + pkgs: list[tuple[str, str]] | None = None) -> str: + pkgs = list(pkgs or []) + if tfm.startswith("net4"): # .NET Framework targets need the cross-platform ref assemblies + pkgs.append(("Microsoft.NETFramework.ReferenceAssemblies", "1.0.3")) + items = "".join(f'{h}' + for n, h in refs) + items += "".join(f'' for n, v in pkgs) + grp = f"{items}" if items else "" + # LangVersion latest: net48 / netstandard2.0 default to C# 7.3, where Nullable=enable and the + # nullable annotations in the wrapper sources are an error. + return (f'' + f'{tfm}enable' + f'latest' + f'{asmname}true' + f'{grp}') + + +def _compile(dotnet: str, work: str, name: str, src: str, tfm: str, asmname: str, + refs: list[tuple[str, str]] | None = None, + pkgs: list[tuple[str, str]] | None = None) -> str: + """Compile .dll and return its output path (not a slot).""" + d = os.path.join(work, f"c-{name}") + os.makedirs(d) + with open(os.path.join(d, "P.csproj"), "w", encoding="utf-8") as fh: + fh.write(_csproj(tfm, refs or [], asmname, pkgs)) + with open(os.path.join(d, "P.cs"), "w", encoding="utf-8") as fh: + fh.write(src) + p = _run([dotnet, "build", os.path.join(d, "P.csproj"), "-c", "Release", "-v", "q", "--nologo"]) + if p.returncode != 0: + raise Fail(f"compile {name}: {p.stdout[-500:]}") + return os.path.join(d, "bin", "Release", tfm, f"{asmname}.dll") + + +def _slot(work: str, name: str, dll: str, as_name: str | None = None) -> str: + """A fresh ref-dir holding exactly one DLL (one ordered slot).""" + refdir = os.path.join(work, f"ref-{name}") + os.makedirs(refdir) + shutil.copy(dll, os.path.join(refdir, as_name or os.path.basename(dll))) + return refdir + + +def _build_wrapper(dotnet: str, work: str, name: str, src: str, tfm: str, + helper: str | None, asmname: str = "WeakEvents") -> str: + """Compile a standalone .dll (optionally referencing a NON-shipped Helper.dll) + and return a fresh ref-dir holding ONLY that DLL (exactly one ordered slot).""" + refs: list[tuple[str, str]] = [] + if helper is not None: + helper_dll = _compile(dotnet, work, f"h-{name}", helper, tfm, "Helper") + refs.append(("Helper", helper_dll)) + dll = _compile(dotnet, work, f"w-{name}", src, tfm, asmname, refs) + return _slot(work, name, dll) + + +def _mkroot(work: str, name: str, cs: str) -> str: + root = os.path.join(work, f"root-{name}") + os.makedirs(os.path.join(root, os.path.dirname(_REL)) if os.path.dirname(_REL) else root, + exist_ok=True) + with open(os.path.join(root, *_REL.split("/")), "wb") as fh: + fh.write(cs.replace("\r\n", "\n").encode("utf-8")) + return root + + +def _candidates(dotnet: str, dll: str, root: str, work: str) -> str: + facts = os.path.join(work, "fc.json") + p = _run([dotnet, "exec", dll, "extract", _REL, "--out", facts, "--fix-candidates", + "--weak-subscribe", "WeakEvents.AddPropertyChanged"], cwd=root) + if p.returncode != 0: + raise Fail(f"extract: {p.stderr[-300:]}") + own = os.path.join(work, "own.toml") + with open(own, "w", encoding="utf-8") as fh: + fh.write('[weak-subscription]\nsubscribe = ["WeakEvents.AddPropertyChanged"]\n') + cands = os.path.join(work, "candidates.json") + p = _py(["own-fix", "subscriptions", "candidates", facts, "--config", own, + "--class", _FQN, "--output", cands, "--root", root]) + if p.returncode != 0: + raise Fail(f"candidates: {p.stderr[-300:]}") + return cands + + +def _plan(cands: str, work: str, convert: bool) -> str: + from ownlang.fix_plan import validate_plan + with open(cands, encoding="utf-8") as fh: + c = json.load(fh) + action = "convert_acquire" if convert else "manual_review" + decisions = [{"finding_id": x["finding_id"], "action": action} for x in c["candidates"]] + plan = os.path.join(work, "plan.json") + with open(plan, "w", encoding="utf-8") as fh: + json.dump(validate_plan(c, {"version": 1, "decisions": decisions}), fh) + return plan + + +def _apply_and_gate(cands: str, plan: str, root: str, work: str) -> tuple[str, str]: + bundle = os.path.join(work, "bundle") + rewriter = f'dotnet run --project "{_RW.replace(os.sep, "/")}" -c Release --no-build --' + p = _py(["own-fix", "subscriptions", "apply", "--plan", plan, "--candidates", cands, + "--root", root, "--out", bundle, "--rewriter", rewriter]) + if p.returncode != 0: + raise Fail(f"apply: {p.stderr[-400:]}") + gate_out = os.path.join(work, "gate") + p = _py(["own-fix", "subscriptions", "gate", "--bundle", bundle, "--plan", plan, + "--candidates", cands, "--root", root, "--out", gate_out]) + if p.returncode != 0: + raise Fail(f"gate: {p.stderr[-400:]}") + return bundle, os.path.join(gate_out, "gate-result.json") + + +def _verify_delta(dotnet: str, dll: str, bundle: str, plan: str, cands: str, root: str, + gate: str, work: str, ref_dirs: list[str]) -> str: + out = os.path.join(work, "delta") + argv = [sys.executable, "-m", "ownlang", "own-fix", "subscriptions", "verify-delta", + "--bundle", bundle, "--plan", plan, "--candidates", cands, "--root", root, + "--gate", gate, "--extractor-dll", dll, "--out", out] + for rd in ref_dirs: + argv += ["--ref-dir", rd] + p = _run(argv, cwd=_REPO, env=_dotnet_env(dotnet)) + if p.returncode != 0: + raise Fail(f"verify-delta rc={p.returncode}: {p.stderr[-500:]}") + return os.path.join(out, "delta-result.json") + + +def _verify_target(dotnet: str, probe: str, bundle: str, plan: str, cands: str, root: str, + delta: str, out: str, ref_dirs: list[str], ordinal: int | None, + use_probe: bool, env: dict | None = None) -> subprocess.CompletedProcess: + argv = [sys.executable, "-m", "ownlang", "own-fix", "subscriptions", "verify-target", + "--bundle", bundle, "--root", root, "--plan", plan, "--candidates", cands, + "--delta", delta, "--out", out] + if use_probe: + argv += ["--probe-dll", probe, "--wrapper-ordinal", str(ordinal)] + for rd in ref_dirs: + argv += ["--ref-dir", rd] + return _run(argv, cwd=_REPO, env=env or _dotnet_env(dotnet)) + + +def _schema_ok(path: str, manual: bool, check, name: str) -> dict: + with open(path, "rb") as fh: + raw = fh.read() + obj = json.loads(raw) + canon = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + check(raw == canon.encode() + b"\n", f"{name}: target-result.json is canonical bytes + LF") + check(obj.get("schema") == 1 and obj.get("operation") == "verify-target-wrapper" + and obj.get("status") == "pass", f"{name}: schema/operation/status") + check(set(obj["checks"]) == set(_CHECK_NAMES) and len(_CHECK_NAMES) == 11, + f"{name}: exactly the eleven checks") + check(obj["delta_binding"]["bound"] is True and obj["delta_binding"]["step10_status"] == "pass", + f"{name}: delta bound") + _no_absolute_paths(raw, check, name) + return obj + + +def _no_absolute_paths(raw: bytes, check, name: str) -> None: + """No slot / deployment / execution absolute path may leak into published evidence (H2).""" + import re + text = raw.decode("utf-8") + leaked = bool(re.search(r"[A-Za-z]:\\", text)) or any( + s in text for s in ("/tmp/", "/home/", "/var/", "/root/", "\\\\")) + check(not leaked, f"{name}: evidence publishes no absolute path") + + +def run() -> int: + required = os.environ.get("OWN_TIERB_REQUIRED") == "1" + if not required: + print("verify-target (Tier B): SKIP (non-required mode; set OWN_TIERB_REQUIRED=1)") + return 0 + dotnet = _find_dotnet() + if dotnet is None: + print("verify-target (Tier B): FAIL — required but no dotnet host") + return 1 + + ok = 0 + fails: list[str] = [] + + def check(cond: bool, label: str) -> None: + nonlocal ok + if cond: + ok += 1 + else: + fails.append(label) + + try: + ext, probe = _build(dotnet) + with tempfile.TemporaryDirectory() as work: + for c in _CASES: + _run_case(dotnet, ext, probe, work, c, check) + _determinism(dotnet, ext, probe, work, check) + _run_ambiguous(dotnet, ext, probe, work, check) + _run_bind_unit_cases(dotnet, ext, probe, work, check) + _run_dep_cases(dotnet, ext, probe, work, check) + _run_two_slot_versions(dotnet, ext, probe, work, check) + except Fail as exc: + fails.append(f"Tier B setup: {exc}") + + for f in fails: + print(f" FAIL: {f}") + total = ok + len(fails) + print(f"verify-target (Tier B, full CLI): {ok}/{total} checks pass") + return 1 if fails else 0 + + +def _build_chain(dotnet: str, ext: str, work: str, name: str, pre: str, ref_dirs: list[str], + convert: bool) -> tuple[str, str, str, str, str, str]: + """extract -> candidates -> plan -> apply -> gate -> verify-delta over EXPLICIT ref-dirs; + returns (bundle, plan, cands, delta, root, workdir).""" + root = _mkroot(work, name, pre) + w = os.path.join(work, f"run-{name}") + os.makedirs(w, exist_ok=True) + cands = _candidates(dotnet, ext, root, w) + plan = _plan(cands, w, convert) + bundle, gate = _apply_and_gate(cands, plan, root, w) + delta = _verify_delta(dotnet, ext, bundle, plan, cands, root, gate, w, ref_dirs) + return bundle, plan, cands, delta, root, w + + +def _chain(dotnet: str, ext: str, work: str, + c: dict) -> tuple[str, str, str, str, list[str], str]: + name = c["name"] + ref_dirs: list[str] = [] + if c["wrapper"] is not None: + ref_dirs = [_build_wrapper(dotnet, work, name, c["wrapper"], c.get("tfm", "net8.0"), + c.get("helper"))] + used = ref_dirs if c["ref"] else [] + bundle, plan, cands, delta, root, _w = _build_chain(dotnet, ext, work, name, c["pre"], + used, c["convert"]) + return bundle, plan, cands, delta, used, root + + +def _run_case(dotnet: str, ext: str, probe: str, work: str, c: dict, check) -> None: + name = c["name"] + try: + bundle, plan, cands, delta, ref_dirs, root = _chain(dotnet, ext, work, c) + except Fail as exc: + check(False, f"{name}: chain setup failed ({exc})") + return + out = os.path.join(work, f"run-{name}", "target") + p = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, out, ref_dirs, + 0 if c["probe"] else None, c["probe"]) + kind, detail = c["expect"] + if kind == "refuse": + check(p.returncode == 2 and detail in p.stderr, + f"{name}: expect refuse {detail} (rc={p.returncode}: {p.stderr.strip()[-160:]})") + return + if p.returncode != 0: + check(False, f"{name}: expect pass ({p.stderr.strip()[-200:]})") + return + obj = _schema_ok(os.path.join(out, "target-result.json"), detail == "manual", check, name) + if detail == "manual": + na = {k for k, v in obj["checks"].items() if v == "not_applicable"} + check(na == set(_MANUAL_ONLY_NA), f"{name}: exactly the six not_applicable") + check(all(k not in obj for k in ("selected_wrapper", "attempts", "callsite_binding")), + f"{name}: manual-only omits the probe fields") + else: + check(set(obj["checks"].values()) == {"pass"}, f"{name}: all eleven checks pass") + check(len(obj["attempts"]) == 3, f"{name}: three probe attempts recorded") + check(obj["selected_wrapper"]["assembly_simple_name"] == "WeakEvents", + f"{name}: selected wrapper is WeakEvents") + want = 2 if detail == "two" else 1 + check(obj["callsite_binding"]["converted_callsites"] == want, + f"{name}: converted_callsites == {want}") + check(obj["callsite_binding"]["derived_wrapper_ordinal"] + == obj["callsite_binding"]["asserted_wrapper_ordinal"] == 0, + f"{name}: derived == asserted ordinal 0") + + +def _determinism(dotnet: str, ext: str, probe: str, work: str, check) -> None: + c = {"name": "det", "pre": _PRE, "wrapper": _WEAK, "convert": True, "ref": True, + "probe": True, "expect": ("pass", "converted")} + try: + bundle, plan, cands, delta, ref_dirs, root = _chain(dotnet, ext, work, c) + except Fail as exc: + check(False, f"determinism: chain setup failed ({exc})") + return + o1 = os.path.join(work, "det-1") + o2 = os.path.join(work, "det-2") + p1 = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, o1, ref_dirs, 0, True) + p2 = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, o2, ref_dirs, 0, True) + if p1.returncode != 0 or p2.returncode != 0: + check(False, f"determinism: a run failed ({p1.returncode}/{p2.returncode})") + return + with open(os.path.join(o1, "target-result.json"), "rb") as fh: + r1 = fh.read() + with open(os.path.join(o2, "target-result.json"), "rb") as fh: + r2 = fh.read() + check(r1 == r2, "determinism: two independent runs are byte-identical") + check(_sha(r1) == _sha(r2), "determinism: identical evidence sha") + + +def _expect_result(p: subprocess.CompletedProcess, out: str, expect: tuple[str, str], + check, name: str) -> dict | None: + kind, detail = expect + if kind == "refuse": + check(p.returncode == 2 and detail in p.stderr, + f"{name}: expect refuse {detail} (rc={p.returncode}: {p.stderr.strip()[-160:]})") + return None + if p.returncode != 0: + check(False, f"{name}: expect pass ({p.stderr.strip()[-200:]})") + return None + return _schema_ok(os.path.join(out, "target-result.json"), detail == "manual", check, name) + + +def _run_ambiguous(dotnet: str, ext: str, probe: str, work: str, check) -> None: + """H1: two DIFFERENT reference assemblies both exporting global WeakEvents make the source + call ambiguous -> CALLSITE_BINDING (a candidate is never promoted to the bound symbol).""" + try: + da = _slot(work, "ambA", _compile(dotnet, work, "ambA", _AMB, "net8.0", "WeakEventsA")) + db = _slot(work, "ambB", _compile(dotnet, work, "ambB", _AMB, "net8.0", "WeakEventsB")) + bundle, plan, cands, delta, root, w = _build_chain(dotnet, ext, work, "amb", _PRE, + [da, db], True) + except Fail as exc: + check(False, f"ambiguous: chain setup failed ({exc})") + return + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, out, [da, db], 0, True) + _expect_result(p, out, ("refuse", "CALLSITE_BINDING"), check, "ambiguous") + + +# K3: an exact-shape net8 wrapper with a callsite that cannot bind for a NON-runtime reason must be +# CALLSITE_BINDING (never WRAPPER_RUNTIME_UNSUPPORTED). These are bind-unit cases: the postimage is +# crafted so Roslyn's SemanticModel returns a null Symbol, exercising the positive-predicate branch. +_PRE_OBJ = ("using System.ComponentModel;\npublic class S { public S(object a) " + "{ a.PropertyChanged += OnA; } void OnA(object s, PropertyChangedEventArgs e){} }\n") +_POST_OBJ = ("using System.ComponentModel;\npublic class S { public S(object a) " + "{ WeakEvents.AddPropertyChanged(a, OnA); } " + "void OnA(object s, PropertyChangedEventArgs e){} }\n") +_PRE_BADH = ("using System.ComponentModel;\npublic class S { public S(INotifyPropertyChanged a) " + "{ a.PropertyChanged += OnA; } void OnA(int x){} void OnA(string y){} }\n") +_POST_BADH = ("using System.ComponentModel;\npublic class S { public S(INotifyPropertyChanged a) " + "{ WeakEvents.AddPropertyChanged(a, OnA); } " + "void OnA(int x){} void OnA(string y){} }\n") + + +def _bind_unit(dotnet: str, probe: str, work: str, name: str, pre: str, post: str, + wrapper: str, expect_cat: str, check) -> None: + from ownlang.fix_delta import _select_runtime + from ownlang.fix_target import TargetError, run_bind + try: + dll = _compile(dotnet, work, f"bu-{name}", wrapper, "net8.0", "WeakEvents") + slot = os.path.join(work, f"buslots-{name}", "000000") + os.makedirs(slot) + shutil.copy(dll, os.path.join(slot, "WeakEvents.dll")) + slots_root = os.path.dirname(slot) + listing = _run([dotnet, "--list-runtimes"]).stdout + selected_ver, _rt = _select_runtime(listing, "Microsoft.NETCore.App", "8.0.0") + start = pre.index("a.PropertyChanged += OnA") + fid = "OWN001:sha256:" + "a" * 64 + bind_params = {"converted": [{"finding_id": fid, "occurrence_ordinal": 0, "file": "S.cs", + "containing_type": "S", "event": "PropertyChanged", + "source": "a", "handler": "OnA", "normalized_handler": "OnA", + "acquire_span": {"start": start, + "length": len("a.PropertyChanged += OnA")}}]} + w = os.path.join(work, f"buw-{name}") + os.makedirs(w) + except Fail as exc: + check(False, f"bind-unit {name}: setup failed ({exc})") + return + try: + run_bind(w, dotnet, probe, selected_ver, "S.cs", pre, post, slots_root, + "WeakEvents.AddPropertyChanged", "S", bind_params, [fid]) + check(False, f"bind-unit {name}: expected {expect_cat} but bind passed") + except TargetError as exc: + check(exc.category == expect_cat, + f"bind-unit {name}: {exc.category} (want {expect_cat})") + + +def _run_bind_unit_cases(dotnet: str, ext: str, probe: str, work: str, check) -> None: + # exact-shape net8 wrapper, receiver cannot convert to INPC -> CALLSITE_BINDING (not runtime) + _bind_unit(dotnet, probe, work, "argconv", _PRE_OBJ, _POST_OBJ, _WEAK, + "CALLSITE_BINDING", check) + # exact-shape net8 wrapper, ambiguous / non-convertible handler method group -> CALLSITE_BINDING + _bind_unit(dotnet, probe, work, "badhandler", _PRE_BADH, _POST_BADH, _WEAK, + "CALLSITE_BINDING", check) + + +def _run_dep_cases(dotnet: str, ext: str, probe: str, work: str, check) -> None: + """H2: the closed load context. A wrapper dependency resolves ONLY from a materialized slot; + it is never satisfied from the probe deployment, the default context, or an arbitrary path.""" + # (a) dependency present in an accepted slot -> pass. + try: + helper = _compile(dotnet, work, "dhOK", _HELPER_OK, "net8.0", "Helper") + weak = _compile(dotnet, work, "dwOK", _DEPWEAK, "net8.0", "WeakEvents", + [("Helper", helper)]) + wd, hd = _slot(work, "dwOK", weak), _slot(work, "dhOK", helper) + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "depin", _PRE, [wd, hd], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, b, pl, ca, rt, dl, out, [wd, hd], 0, True) + _expect_result(p, out, ("pass", "converted"), check, "dep-in-slot") + except Fail as exc: + check(False, f"dep-in-slot: chain setup failed ({exc})") + + # (b) dependency absent from slots but copied NEXT TO the probe deployment -> unsupported. + try: + helper = _compile(dotnet, work, "dhNP", _HELPER_OK, "net8.0", "Helper") + weak = _compile(dotnet, work, "dwNP", _DEPWEAK, "net8.0", "WeakEvents", + [("Helper", helper)]) + wd = _slot(work, "dwNP", weak) + probe_copy_dir = os.path.join(work, "probe-copy") + shutil.copytree(os.path.dirname(probe), probe_copy_dir) + shutil.copy(helper, os.path.join(probe_copy_dir, "Helper.dll")) + probe_copy = os.path.join(probe_copy_dir, os.path.basename(probe)) + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "depnp", _PRE, [wd], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe_copy, b, pl, ca, rt, dl, out, [wd], 0, True) + _expect_result(p, out, ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED"), check, + "dep-next-to-probe") + except Fail as exc: + check(False, f"dep-next-to-probe: chain setup failed ({exc})") + + # (c) dependency absent from slots but ALREADY loaded in the probe's default context + # (Microsoft.CodeAnalysis, a probe deployment assembly) -> unsupported, never satisfied. + try: + weak = _compile(dotnet, work, "dwDC", _DEP_ROSLYN, "net8.0", "WeakEvents", None, + [("Microsoft.CodeAnalysis.CSharp", "4.9.2")]) + wd = _slot(work, "dwDC", weak) + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "depdc", _PRE, [wd], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, b, pl, ca, rt, dl, out, [wd], 0, True) + _expect_result(p, out, ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED"), check, + "dep-default-context") + except Fail as exc: + check(False, f"dep-default-context: chain setup failed ({exc})") + + +def _run_two_slot_versions(dotnet: str, ext: str, probe: str, work: str, check) -> None: + """H2: two slots export the same simple-name dependency; the FROZEN first-winning slot is the + one loaded, proven behaviorally (good Helper -> pass; throwing Helper -> TARGET_BEHAVIOR).""" + try: + hgood = _compile(dotnet, work, "tvHg", _HELPER_OK, "net8.0", "Helper") + hthrow = _compile(dotnet, work, "tvHt", _HELPER_THROW, "net8.0", "Helper") + weak = _compile(dotnet, work, "tvW", _DEPWEAK, "net8.0", "WeakEvents", + [("Helper", hgood)]) + wd = _slot(work, "tvW", weak) + good, throw = _slot(work, "tvHg", hgood), _slot(work, "tvHt", hthrow) + # good Helper is the earlier (first-winning) slot -> pass + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "tvA", _PRE, [wd, good, throw], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, b, pl, ca, rt, dl, out, [wd, good, throw], 0, True) + _expect_result(p, out, ("pass", "converted"), check, "two-slot first-wins (good)") + # throwing Helper is the earlier slot -> the wrapper throws on subscribe -> TARGET_BEHAVIOR + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "tvB", _PRE, [wd, throw, good], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, b, pl, ca, rt, dl, out, [wd, throw, good], 0, True) + _expect_result(p, out, ("refuse", "TARGET_BEHAVIOR"), check, "two-slot first-wins (throw)") + except Fail as exc: + check(False, f"two-slot-versions: chain setup failed ({exc})") + + +if __name__ == "__main__": + raise SystemExit(run())