From 35a03e3cbd05acd77a68f02989dde6dc69489f72 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 17 Oct 2026 17:29:36 +0000 Subject: [PATCH 1/3] Verify relational bindings across query shapes Relational binding needs an independent contract across hierarchy and cache states. Compare 144 fields with direct Unity queries across three relations, all four shapes, sealed/base/interface matching, state and filters, and depth/count limits. Exercise 72 combinations and eight cache/hierarchy checkpoints. Unity 6000.4.6f1 PlayMode passes 226 cases with zero failures or skips. Removing breadth-first ordering causes 48 failures. Removing the parent depth limits causes 72 failures. Restored Runtime sources match the base revision. The first ordering mutation exposed missing fast-path coverage; sealed query fields close that gap. Default, legacy, Odin and SINGLE_THREADED test-source checks and assembly-reference probes pass. Broader lifecycle, backend and paired-performance acceptance remains open in #644. Refs #644 --- .../RelationalBindingMatrixTarget.cs | 685 ++++++++++++++++ .../RelationalBindingMatrixTarget.cs.meta | 11 + .../RelationalBindingMatrixTests.cs | 740 ++++++++++++++++++ .../RelationalBindingMatrixTests.cs.meta | 11 + 4 files changed, 1447 insertions(+) create mode 100644 Tests/Runtime/Attributes/Components/RelationalBindingMatrixTarget.cs create mode 100644 Tests/Runtime/Attributes/Components/RelationalBindingMatrixTarget.cs.meta create mode 100644 Tests/Runtime/Attributes/RelationalBindingMatrixTests.cs create mode 100644 Tests/Runtime/Attributes/RelationalBindingMatrixTests.cs.meta diff --git a/Tests/Runtime/Attributes/Components/RelationalBindingMatrixTarget.cs b/Tests/Runtime/Attributes/Components/RelationalBindingMatrixTarget.cs new file mode 100644 index 000000000..79404f5ca --- /dev/null +++ b/Tests/Runtime/Attributes/Components/RelationalBindingMatrixTarget.cs @@ -0,0 +1,685 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Attributes +{ + using System.Collections.Generic; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Tests.Core.TestTypes; + + public sealed class RelationalBindingMatrixTarget : MonoBehaviour + { + [SiblingComponent(Optional = true)] + public RelationalExactComponent allSiblingConcreteSingle; + + [SiblingComponent(Optional = true)] + public RelationalExactComponent[] allSiblingConcreteArray; + + [SiblingComponent(Optional = true)] + public List allSiblingConcreteList; + + [SiblingComponent(Optional = true)] + public HashSet allSiblingConcreteSet; + + [SiblingComponent(Optional = true)] + public TestInterfaceComponent allSiblingSealedSingle; + + [SiblingComponent(Optional = true)] + public TestInterfaceComponent[] allSiblingSealedArray; + + [SiblingComponent(Optional = true)] + public List allSiblingSealedList; + + [SiblingComponent(Optional = true)] + public HashSet allSiblingSealedSet; + + [SiblingComponent(Optional = true)] + public ITestInterface allSiblingInterfaceSingle; + + [SiblingComponent(Optional = true)] + public ITestInterface[] allSiblingInterfaceArray; + + [SiblingComponent(Optional = true)] + public List allSiblingInterfaceList; + + [SiblingComponent(Optional = true)] + public HashSet allSiblingInterfaceSet; + + [ParentComponent(Optional = true)] + public RelationalExactComponent allParentConcreteSingle; + + [ParentComponent(Optional = true)] + public RelationalExactComponent[] allParentConcreteArray; + + [ParentComponent(Optional = true)] + public List allParentConcreteList; + + [ParentComponent(Optional = true)] + public HashSet allParentConcreteSet; + + [ParentComponent(Optional = true)] + public TestInterfaceComponent allParentSealedSingle; + + [ParentComponent(Optional = true)] + public TestInterfaceComponent[] allParentSealedArray; + + [ParentComponent(Optional = true)] + public List allParentSealedList; + + [ParentComponent(Optional = true)] + public HashSet allParentSealedSet; + + [ParentComponent(Optional = true)] + public ITestInterface allParentInterfaceSingle; + + [ParentComponent(Optional = true)] + public ITestInterface[] allParentInterfaceArray; + + [ParentComponent(Optional = true)] + public List allParentInterfaceList; + + [ParentComponent(Optional = true)] + public HashSet allParentInterfaceSet; + + [ChildComponent(Optional = true)] + public RelationalExactComponent allChildConcreteSingle; + + [ChildComponent(Optional = true)] + public RelationalExactComponent[] allChildConcreteArray; + + [ChildComponent(Optional = true)] + public List allChildConcreteList; + + [ChildComponent(Optional = true)] + public HashSet allChildConcreteSet; + + [ChildComponent(Optional = true)] + public TestInterfaceComponent allChildSealedSingle; + + [ChildComponent(Optional = true)] + public TestInterfaceComponent[] allChildSealedArray; + + [ChildComponent(Optional = true)] + public List allChildSealedList; + + [ChildComponent(Optional = true)] + public HashSet allChildSealedSet; + + [ChildComponent(Optional = true)] + public ITestInterface allChildInterfaceSingle; + + [ChildComponent(Optional = true)] + public ITestInterface[] allChildInterfaceArray; + + [ChildComponent(Optional = true)] + public List allChildInterfaceList; + + [ChildComponent(Optional = true)] + public HashSet allChildInterfaceSet; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public RelationalExactComponent activeSiblingConcreteSingle; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public RelationalExactComponent[] activeSiblingConcreteArray; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public List activeSiblingConcreteList; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public HashSet activeSiblingConcreteSet; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public TestInterfaceComponent activeSiblingSealedSingle; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public TestInterfaceComponent[] activeSiblingSealedArray; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public List activeSiblingSealedList; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public HashSet activeSiblingSealedSet; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public ITestInterface activeSiblingInterfaceSingle; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public ITestInterface[] activeSiblingInterfaceArray; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public List activeSiblingInterfaceList; + + [SiblingComponent(Optional = true, IncludeInactive = false)] + public HashSet activeSiblingInterfaceSet; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public RelationalExactComponent activeParentConcreteSingle; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public RelationalExactComponent[] activeParentConcreteArray; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public List activeParentConcreteList; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public HashSet activeParentConcreteSet; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public TestInterfaceComponent activeParentSealedSingle; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public TestInterfaceComponent[] activeParentSealedArray; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public List activeParentSealedList; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public HashSet activeParentSealedSet; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public ITestInterface activeParentInterfaceSingle; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public ITestInterface[] activeParentInterfaceArray; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public List activeParentInterfaceList; + + [ParentComponent(OnlyAncestors = true, Optional = true, IncludeInactive = false)] + public HashSet activeParentInterfaceSet; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public RelationalExactComponent activeChildConcreteSingle; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public RelationalExactComponent[] activeChildConcreteArray; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public List activeChildConcreteList; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public HashSet activeChildConcreteSet; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public TestInterfaceComponent activeChildSealedSingle; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public TestInterfaceComponent[] activeChildSealedArray; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public List activeChildSealedList; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public HashSet activeChildSealedSet; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public ITestInterface activeChildInterfaceSingle; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public ITestInterface[] activeChildInterfaceArray; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public List activeChildInterfaceList; + + [ChildComponent(OnlyDescendants = true, Optional = true, IncludeInactive = false)] + public HashSet activeChildInterfaceSet; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public RelationalExactComponent filteredSiblingConcreteSingle; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public RelationalExactComponent[] filteredSiblingConcreteArray; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredSiblingConcreteList; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredSiblingConcreteSet; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public TestInterfaceComponent filteredSiblingSealedSingle; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public TestInterfaceComponent[] filteredSiblingSealedArray; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredSiblingSealedList; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredSiblingSealedSet; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public ITestInterface filteredSiblingInterfaceSingle; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public ITestInterface[] filteredSiblingInterfaceArray; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredSiblingInterfaceList; + + [SiblingComponent( + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredSiblingInterfaceSet; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public RelationalExactComponent filteredParentConcreteSingle; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public RelationalExactComponent[] filteredParentConcreteArray; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredParentConcreteList; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredParentConcreteSet; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public TestInterfaceComponent filteredParentSealedSingle; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public TestInterfaceComponent[] filteredParentSealedArray; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredParentSealedList; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredParentSealedSet; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public ITestInterface filteredParentInterfaceSingle; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public ITestInterface[] filteredParentInterfaceArray; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredParentInterfaceList; + + [ParentComponent( + OnlyAncestors = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredParentInterfaceSet; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public RelationalExactComponent filteredChildConcreteSingle; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public RelationalExactComponent[] filteredChildConcreteArray; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredChildConcreteList; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredChildConcreteSet; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public TestInterfaceComponent filteredChildSealedSingle; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public TestInterfaceComponent[] filteredChildSealedArray; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredChildSealedList; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredChildSealedSet; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public ITestInterface filteredChildInterfaceSingle; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public ITestInterface[] filteredChildInterfaceArray; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public List filteredChildInterfaceList; + + [ChildComponent( + OnlyDescendants = true, + Optional = true, + IncludeInactive = false, + TagFilter = "Player", + NameFilter = "Match", + MaxCount = 2 + )] + public HashSet filteredChildInterfaceSet; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public RelationalExactComponent boundedSiblingConcreteSingle; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public RelationalExactComponent[] boundedSiblingConcreteArray; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public List boundedSiblingConcreteList; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public HashSet boundedSiblingConcreteSet; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public TestInterfaceComponent boundedSiblingSealedSingle; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public TestInterfaceComponent[] boundedSiblingSealedArray; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public List boundedSiblingSealedList; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public HashSet boundedSiblingSealedSet; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public ITestInterface boundedSiblingInterfaceSingle; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public ITestInterface[] boundedSiblingInterfaceArray; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public List boundedSiblingInterfaceList; + + [SiblingComponent(Optional = true, MaxCount = 3)] + public HashSet boundedSiblingInterfaceSet; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public RelationalExactComponent boundedParentConcreteSingle; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public RelationalExactComponent[] boundedParentConcreteArray; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public List boundedParentConcreteList; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public HashSet boundedParentConcreteSet; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public TestInterfaceComponent boundedParentSealedSingle; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public TestInterfaceComponent[] boundedParentSealedArray; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public List boundedParentSealedList; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public HashSet boundedParentSealedSet; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public ITestInterface boundedParentInterfaceSingle; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public ITestInterface[] boundedParentInterfaceArray; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public List boundedParentInterfaceList; + + [ParentComponent(OnlyAncestors = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public HashSet boundedParentInterfaceSet; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public RelationalExactComponent boundedChildConcreteSingle; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public RelationalExactComponent[] boundedChildConcreteArray; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public List boundedChildConcreteList; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public HashSet boundedChildConcreteSet; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public TestInterfaceComponent boundedChildSealedSingle; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public TestInterfaceComponent[] boundedChildSealedArray; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public List boundedChildSealedList; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public HashSet boundedChildSealedSet; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public ITestInterface boundedChildInterfaceSingle; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public ITestInterface[] boundedChildInterfaceArray; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public List boundedChildInterfaceList; + + [ChildComponent(OnlyDescendants = true, Optional = true, MaxCount = 3, MaxDepth = 2)] + public HashSet boundedChildInterfaceSet; + } +} diff --git a/Tests/Runtime/Attributes/Components/RelationalBindingMatrixTarget.cs.meta b/Tests/Runtime/Attributes/Components/RelationalBindingMatrixTarget.cs.meta new file mode 100644 index 000000000..314833d18 --- /dev/null +++ b/Tests/Runtime/Attributes/Components/RelationalBindingMatrixTarget.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 85bc903bc6793a76aa75cd4224aa4fad +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Attributes/RelationalBindingMatrixTests.cs b/Tests/Runtime/Attributes/RelationalBindingMatrixTests.cs new file mode 100644 index 000000000..3e5c8432c --- /dev/null +++ b/Tests/Runtime/Attributes/RelationalBindingMatrixTests.cs @@ -0,0 +1,740 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Attributes +{ + using System; + using System.Collections.Generic; + using NUnit.Framework; + using UnityEngine; + using WallstopStudios.UnityHelpers.Core.Attributes; + using WallstopStudios.UnityHelpers.Core.Helper; + using WallstopStudios.UnityHelpers.Tests.Core; + using WallstopStudios.UnityHelpers.Tests.Core.TestTypes; + + [TestFixture] + [Category("Fast")] + public sealed class RelationalBindingMatrixTests : CommonTestBase + { + private const int SiblingDirection = 0; + private const int ParentDirection = 1; + private const int ChildDirection = 2; + + private static void ClearBindingCaches() + { + SiblingComponentExtensions.ClearCachedFieldMetadata(); + ParentComponentExtensions.ClearCachedFieldMetadata(); + ChildComponentExtensions.ClearCachedFieldMetadata(); + ReflectionHelpers.ClearFieldGetterCache(); + ReflectionHelpers.ClearFieldSetterCache(); + } + + private static List Query( + Transform owner, + int direction, + bool excludeSelf, + bool activeOnly, + bool filtered, + int maxDepth, + int maxCount + ) + where T : class + { + List result = new(); + Queue<(Transform transform, int depth)> pending = new(); + pending.Enqueue((owner, 0)); + while (pending.TryDequeue(out (Transform transform, int depth) item)) + { + if (!excludeSelf || item.depth != 0 || direction == SiblingDirection) + { + foreach (Component candidate in item.transform.GetComponents()) + { + if (candidate == null || !(candidate is T value)) + { + continue; + } + if ( + activeOnly + && ( + !candidate.gameObject.activeInHierarchy + || ( + direction != ParentDirection + && candidate is Behaviour behaviour + && !behaviour.enabled + ) + ) + ) + { + continue; + } + if ( + filtered + && ( + !candidate.gameObject.CompareTag("Player") + || !candidate.gameObject.name.Contains("Match") + ) + ) + { + continue; + } + result.Add(value); + if (0 < maxCount && maxCount <= result.Count) + { + return result; + } + } + } + if (0 < maxDepth && maxDepth <= item.depth) + { + continue; + } + if (direction == ParentDirection && item.transform.parent != null) + { + pending.Enqueue((item.transform.parent, item.depth + 1)); + } + else if (direction == ChildDirection) + { + foreach (Transform child in item.transform) + { + pending.Enqueue((child, item.depth + 1)); + } + } + } + return result; + } + + private static void AssertShapes( + List expected, + T single, + T[] array, + List list, + HashSet set, + string label + ) + where T : class + { + Assert.AreSame(expected.Count == 0 ? null : expected[0], single, label + ".Single"); + Assert.IsTrue(array != null, label + ".Array must be assigned even when empty."); + Assert.IsTrue(list != null, label + ".List must be assigned even when empty."); + Assert.IsTrue(set != null, label + ".Set must be assigned even when empty."); + CollectionAssert.AreEqual(expected, array, label + ".Array"); + CollectionAssert.AreEqual(expected, list, label + ".List"); + CollectionAssert.AreEquivalent(expected, set, label + ".Set"); + } + + private static void AssertBindings(RelationalBindingMatrixTarget target) + { + target.AssignRelationalComponents(); + AssertShapes( + Query( + target.transform, + SiblingDirection, + false, + false, + false, + 0, + 0 + ), + target.allSiblingConcreteSingle, + target.allSiblingConcreteArray, + target.allSiblingConcreteList, + target.allSiblingConcreteSet, + "allSiblingConcrete" + ); + AssertShapes( + Query( + target.transform, + SiblingDirection, + false, + false, + false, + 0, + 0 + ), + target.allSiblingSealedSingle, + target.allSiblingSealedArray, + target.allSiblingSealedList, + target.allSiblingSealedSet, + "allSiblingSealed" + ); + AssertShapes( + Query( + target.transform, + SiblingDirection, + false, + false, + false, + 0, + 0 + ), + target.allSiblingInterfaceSingle, + target.allSiblingInterfaceArray, + target.allSiblingInterfaceList, + target.allSiblingInterfaceSet, + "allSiblingInterface" + ); + AssertShapes( + Query( + target.transform, + ParentDirection, + false, + false, + false, + 0, + 0 + ), + target.allParentConcreteSingle, + target.allParentConcreteArray, + target.allParentConcreteList, + target.allParentConcreteSet, + "allParentConcrete" + ); + AssertShapes( + Query( + target.transform, + ParentDirection, + false, + false, + false, + 0, + 0 + ), + target.allParentSealedSingle, + target.allParentSealedArray, + target.allParentSealedList, + target.allParentSealedSet, + "allParentSealed" + ); + AssertShapes( + Query(target.transform, ParentDirection, false, false, false, 0, 0), + target.allParentInterfaceSingle, + target.allParentInterfaceArray, + target.allParentInterfaceList, + target.allParentInterfaceSet, + "allParentInterface" + ); + AssertShapes( + Query( + target.transform, + ChildDirection, + false, + false, + false, + 0, + 0 + ), + target.allChildConcreteSingle, + target.allChildConcreteArray, + target.allChildConcreteList, + target.allChildConcreteSet, + "allChildConcrete" + ); + AssertShapes( + Query( + target.transform, + ChildDirection, + false, + false, + false, + 0, + 0 + ), + target.allChildSealedSingle, + target.allChildSealedArray, + target.allChildSealedList, + target.allChildSealedSet, + "allChildSealed" + ); + AssertShapes( + Query(target.transform, ChildDirection, false, false, false, 0, 0), + target.allChildInterfaceSingle, + target.allChildInterfaceArray, + target.allChildInterfaceList, + target.allChildInterfaceSet, + "allChildInterface" + ); + AssertShapes( + Query( + target.transform, + SiblingDirection, + true, + true, + false, + 0, + 0 + ), + target.activeSiblingConcreteSingle, + target.activeSiblingConcreteArray, + target.activeSiblingConcreteList, + target.activeSiblingConcreteSet, + "activeSiblingConcrete" + ); + AssertShapes( + Query( + target.transform, + SiblingDirection, + true, + true, + false, + 0, + 0 + ), + target.activeSiblingSealedSingle, + target.activeSiblingSealedArray, + target.activeSiblingSealedList, + target.activeSiblingSealedSet, + "activeSiblingSealed" + ); + AssertShapes( + Query(target.transform, SiblingDirection, true, true, false, 0, 0), + target.activeSiblingInterfaceSingle, + target.activeSiblingInterfaceArray, + target.activeSiblingInterfaceList, + target.activeSiblingInterfaceSet, + "activeSiblingInterface" + ); + AssertShapes( + Query( + target.transform, + ParentDirection, + true, + true, + false, + 0, + 0 + ), + target.activeParentConcreteSingle, + target.activeParentConcreteArray, + target.activeParentConcreteList, + target.activeParentConcreteSet, + "activeParentConcrete" + ); + AssertShapes( + Query( + target.transform, + ParentDirection, + true, + true, + false, + 0, + 0 + ), + target.activeParentSealedSingle, + target.activeParentSealedArray, + target.activeParentSealedList, + target.activeParentSealedSet, + "activeParentSealed" + ); + AssertShapes( + Query(target.transform, ParentDirection, true, true, false, 0, 0), + target.activeParentInterfaceSingle, + target.activeParentInterfaceArray, + target.activeParentInterfaceList, + target.activeParentInterfaceSet, + "activeParentInterface" + ); + AssertShapes( + Query( + target.transform, + ChildDirection, + true, + true, + false, + 0, + 0 + ), + target.activeChildConcreteSingle, + target.activeChildConcreteArray, + target.activeChildConcreteList, + target.activeChildConcreteSet, + "activeChildConcrete" + ); + AssertShapes( + Query( + target.transform, + ChildDirection, + true, + true, + false, + 0, + 0 + ), + target.activeChildSealedSingle, + target.activeChildSealedArray, + target.activeChildSealedList, + target.activeChildSealedSet, + "activeChildSealed" + ); + AssertShapes( + Query(target.transform, ChildDirection, true, true, false, 0, 0), + target.activeChildInterfaceSingle, + target.activeChildInterfaceArray, + target.activeChildInterfaceList, + target.activeChildInterfaceSet, + "activeChildInterface" + ); + AssertShapes( + Query( + target.transform, + SiblingDirection, + true, + true, + true, + 0, + 2 + ), + target.filteredSiblingConcreteSingle, + target.filteredSiblingConcreteArray, + target.filteredSiblingConcreteList, + target.filteredSiblingConcreteSet, + "filteredSiblingConcrete" + ); + AssertShapes( + Query( + target.transform, + SiblingDirection, + true, + true, + true, + 0, + 2 + ), + target.filteredSiblingSealedSingle, + target.filteredSiblingSealedArray, + target.filteredSiblingSealedList, + target.filteredSiblingSealedSet, + "filteredSiblingSealed" + ); + AssertShapes( + Query(target.transform, SiblingDirection, true, true, true, 0, 2), + target.filteredSiblingInterfaceSingle, + target.filteredSiblingInterfaceArray, + target.filteredSiblingInterfaceList, + target.filteredSiblingInterfaceSet, + "filteredSiblingInterface" + ); + AssertShapes( + Query( + target.transform, + ParentDirection, + true, + true, + true, + 0, + 2 + ), + target.filteredParentConcreteSingle, + target.filteredParentConcreteArray, + target.filteredParentConcreteList, + target.filteredParentConcreteSet, + "filteredParentConcrete" + ); + AssertShapes( + Query( + target.transform, + ParentDirection, + true, + true, + true, + 0, + 2 + ), + target.filteredParentSealedSingle, + target.filteredParentSealedArray, + target.filteredParentSealedList, + target.filteredParentSealedSet, + "filteredParentSealed" + ); + AssertShapes( + Query(target.transform, ParentDirection, true, true, true, 0, 2), + target.filteredParentInterfaceSingle, + target.filteredParentInterfaceArray, + target.filteredParentInterfaceList, + target.filteredParentInterfaceSet, + "filteredParentInterface" + ); + AssertShapes( + Query( + target.transform, + ChildDirection, + true, + true, + true, + 0, + 2 + ), + target.filteredChildConcreteSingle, + target.filteredChildConcreteArray, + target.filteredChildConcreteList, + target.filteredChildConcreteSet, + "filteredChildConcrete" + ); + AssertShapes( + Query( + target.transform, + ChildDirection, + true, + true, + true, + 0, + 2 + ), + target.filteredChildSealedSingle, + target.filteredChildSealedArray, + target.filteredChildSealedList, + target.filteredChildSealedSet, + "filteredChildSealed" + ); + AssertShapes( + Query(target.transform, ChildDirection, true, true, true, 0, 2), + target.filteredChildInterfaceSingle, + target.filteredChildInterfaceArray, + target.filteredChildInterfaceList, + target.filteredChildInterfaceSet, + "filteredChildInterface" + ); + AssertShapes( + Query( + target.transform, + SiblingDirection, + true, + false, + false, + 0, + 3 + ), + target.boundedSiblingConcreteSingle, + target.boundedSiblingConcreteArray, + target.boundedSiblingConcreteList, + target.boundedSiblingConcreteSet, + "boundedSiblingConcrete" + ); + AssertShapes( + Query( + target.transform, + SiblingDirection, + true, + false, + false, + 0, + 3 + ), + target.boundedSiblingSealedSingle, + target.boundedSiblingSealedArray, + target.boundedSiblingSealedList, + target.boundedSiblingSealedSet, + "boundedSiblingSealed" + ); + AssertShapes( + Query(target.transform, SiblingDirection, true, false, false, 0, 3), + target.boundedSiblingInterfaceSingle, + target.boundedSiblingInterfaceArray, + target.boundedSiblingInterfaceList, + target.boundedSiblingInterfaceSet, + "boundedSiblingInterface" + ); + AssertShapes( + Query( + target.transform, + ParentDirection, + true, + false, + false, + 2, + 3 + ), + target.boundedParentConcreteSingle, + target.boundedParentConcreteArray, + target.boundedParentConcreteList, + target.boundedParentConcreteSet, + "boundedParentConcrete" + ); + AssertShapes( + Query( + target.transform, + ParentDirection, + true, + false, + false, + 2, + 3 + ), + target.boundedParentSealedSingle, + target.boundedParentSealedArray, + target.boundedParentSealedList, + target.boundedParentSealedSet, + "boundedParentSealed" + ); + AssertShapes( + Query(target.transform, ParentDirection, true, false, false, 2, 3), + target.boundedParentInterfaceSingle, + target.boundedParentInterfaceArray, + target.boundedParentInterfaceList, + target.boundedParentInterfaceSet, + "boundedParentInterface" + ); + AssertShapes( + Query( + target.transform, + ChildDirection, + true, + false, + false, + 2, + 3 + ), + target.boundedChildConcreteSingle, + target.boundedChildConcreteArray, + target.boundedChildConcreteList, + target.boundedChildConcreteSet, + "boundedChildConcrete" + ); + AssertShapes( + Query( + target.transform, + ChildDirection, + true, + false, + false, + 2, + 3 + ), + target.boundedChildSealedSingle, + target.boundedChildSealedArray, + target.boundedChildSealedList, + target.boundedChildSealedSet, + "boundedChildSealed" + ); + AssertShapes( + Query(target.transform, ChildDirection, true, false, false, 2, 3), + target.boundedChildInterfaceSingle, + target.boundedChildInterfaceArray, + target.boundedChildInterfaceList, + target.boundedChildInterfaceSet, + "boundedChildInterface" + ); + } + + [TearDown] + public void ClearMetadataAfterCapabilityScope() + { + ClearBindingCaches(); + } + + [Test] + public void BindingMatchesIndependentQueriesAcrossStatesAndMutations( + [Values(0, 1, 3)] int matchesPerNode, + [Values(1, 3)] int depth, + [Values(false, true)] bool inactive, + [Values(false, true)] bool disabled, + [Values(0, 1, 2)] int capability + ) + { + using IDisposable scope = new ReflectionCapabilityScope( + capability == 0, + capability == 1 + ); + ClearBindingCaches(); + GameObject root = CreateCandidates("MatchRoot", matchesPerNode, disabled); + GameObject ancestor = root; + for (int level = 1; level < depth; ++level) + { + GameObject next = CreateCandidates("MatchAncestor", matchesPerNode, disabled); + next.transform.SetParent(ancestor.transform); + ancestor = next; + } + GameObject owner = CreateCandidates("MatchOwner", matchesPerNode, disabled); + owner.transform.SetParent(ancestor.transform); + RelationalBindingMatrixTarget target = + owner.AddComponent(); + GameObject first = CreateCandidates("MatchFirst", matchesPerNode, disabled); + first.transform.SetParent(owner.transform); + GameObject deep = first; + for (int level = 0; level < depth; ++level) + { + GameObject next = CreateCandidates("MatchDescendant", matchesPerNode, disabled); + next.transform.SetParent(deep.transform); + deep = next; + } + GameObject wrongName = CreateCandidates("Decoy", matchesPerNode, false); + wrongName.transform.SetParent(owner.transform); + GameObject wrongTag = CreateCandidates("MatchWrongTag", matchesPerNode, false); + wrongTag.tag = "Untagged"; + wrongTag.transform.SetParent(owner.transform); + GameObject second = CreateCandidates("MatchSecond", matchesPerNode, false); + second.transform.SetParent(owner.transform); + first.SetActive(!inactive); + root.SetActive(!inactive); + + AssertBindings(target); + AssertBindings(target); + CreateCandidates("MatchUnrelated", matchesPerNode, false); + AssertBindings(target); + second.transform.SetAsFirstSibling(); + AssertBindings(target); + root.SetActive(true); + first.SetActive(true); + foreach ( + RelationalExactComponent candidate in root.GetComponentsInChildren( + true + ) + ) + { + candidate.enabled = !candidate.enabled; + } + AssertBindings(target); + first.transform.SetParent(null); + AssertBindings(target); + foreach ( + Component candidate in owner.GetComponentsInParent(true) + ) + { + UnityEngine.Object.DestroyImmediate(candidate); // UNH-SUPPRESS: Destroy candidates to verify stale bindings are replaced. + } + foreach ( + RelationalExactComponent candidate in owner.GetComponentsInChildren( + true + ) + ) + { + UnityEngine.Object.DestroyImmediate(candidate); // UNH-SUPPRESS: Destroy candidates to verify stale bindings are replaced. + } + foreach ( + TestInterfaceComponent candidate in root.GetComponentsInChildren( + true + ) + ) + { + UnityEngine.Object.DestroyImmediate(candidate); // UNH-SUPPRESS: Remove the final interface matches to verify empty assignments. + } + AssertBindings(target); + + GameObject farParent = CreateCandidates("MatchFarParent", 1, false); + GameObject middleParent = Track(new GameObject("EmptyMiddleParent")); + middleParent.transform.SetParent(farParent.transform); + GameObject nearParent = Track(new GameObject("EmptyNearParent")); + nearParent.transform.SetParent(middleParent.transform); + owner.transform.SetParent(nearParent.transform); + GameObject nearChild = Track(new GameObject("EmptyNearChild")); + nearChild.transform.SetParent(owner.transform); + GameObject middleChild = Track(new GameObject("EmptyMiddleChild")); + middleChild.transform.SetParent(nearChild.transform); + GameObject farChild = CreateCandidates("MatchFarChild", 1, false); + farChild.transform.SetParent(middleChild.transform); + AssertBindings(target); + } + + private GameObject CreateCandidates(string name, int count, bool disabled) + { + GameObject result = Track(new GameObject(name)); + result.tag = "Player"; + for (int index = 0; index < count; ++index) + { + result.AddComponent().enabled = !disabled; + result.AddComponent().enabled = !disabled; + result.AddComponent().enabled = !disabled; + } + return result; + } + } +} diff --git a/Tests/Runtime/Attributes/RelationalBindingMatrixTests.cs.meta b/Tests/Runtime/Attributes/RelationalBindingMatrixTests.cs.meta new file mode 100644 index 000000000..a0d1a82fc --- /dev/null +++ b/Tests/Runtime/Attributes/RelationalBindingMatrixTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 97c940606440b5bbdaa7bfae48ef8ee5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 0e70d2a1d18199e023f97fb9c0fa20afcd0eef1c Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 17 Oct 2026 17:30:45 +0000 Subject: [PATCH 2/3] Reject hidden unsafe and IL2CPP option calls Text scans missed valid escaped identifiers and calls inside interpolation. Use Roslyn syntax across conditional branches. Resolve attribute aliases from syntax, preserve source positions, and require literal boolean true for values. Keep literal text separate from interpolation code. Compile a control with unsafe compilation disabled; never execute its memory-access calls. All 267 safety controls pass. The normalized SARIF report has zero findings across 2,467 sources, 45 assembly definitions and 18 check projects. It records 64 bounded stack allocations and passing memory-access, stack-bound and IL2CPP controls. The report validates against the official OASIS SARIF 2.1.0 schema. No Runtime source or shipped analyzer DLL changes. Broader backend conformance, native fault causation, build-policy coverage and paired-performance acceptance remain open in #637. Refs #637 --- .../Il2CppCodePolicy.cs | 167 ++++++++++++++ .../Il2CppCodePolicy.cs.meta | 11 + .../Program.cs | 16 +- .../SafetySyntaxTrees.cs | 60 +++++ .../SafetySyntaxTrees.cs.meta | 11 + .../UnsafeAccessPolicy.cs | 126 +++++++++++ .../UnsafeAccessPolicy.cs.meta | 11 + docs/project/contributing.md | 8 +- scripts/lint-unsafe-code.js | 153 +++++++------ scripts/tests/test-lint-unsafe-code.js | 207 ++++++++++++++++++ 10 files changed, 695 insertions(+), 75 deletions(-) create mode 100644 Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Il2CppCodePolicy.cs create mode 100644 Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Il2CppCodePolicy.cs.meta create mode 100644 Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/SafetySyntaxTrees.cs create mode 100644 Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/SafetySyntaxTrees.cs.meta create mode 100644 Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/UnsafeAccessPolicy.cs create mode 100644 Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/UnsafeAccessPolicy.cs.meta diff --git a/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Il2CppCodePolicy.cs b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Il2CppCodePolicy.cs new file mode 100644 index 000000000..286cfcea6 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Il2CppCodePolicy.cs @@ -0,0 +1,167 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.SyntaxPolicy +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp; + using Microsoft.CodeAnalysis.CSharp.Syntax; + + internal static class Il2CppCodePolicy + { + internal static int Run() + { + using JsonDocument document = JsonDocument.Parse(Console.In.ReadToEnd()); + using Utf8JsonWriter writer = new(Console.OpenStandardOutput()); + writer.WriteStartObject(); + writer.WriteStartArray("Variants"); + foreach (JsonElement source in document.RootElement.EnumerateArray()) + { + string path = source.GetProperty(nameof(StackAllocationSource.Path)).GetString(); + string text = source.GetProperty(nameof(StackAllocationSource.Text)).GetString(); + foreach (SyntaxTree tree in SafetySyntaxTrees.Parse(path, text)) + { + writer.WriteStartObject(); + writer.WriteString(nameof(StackAllocationSource.Path), path); + writer.WriteString(nameof(StackAllocationSource.Text), Normalize(tree, text)); + writer.WriteStartArray("SetOptionStarts"); + foreach (int start in FindSetOptionStarts(tree)) + { + writer.WriteNumberValue(start); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + } + writer.WriteEndArray(); + writer.WriteEndObject(); + return 0; + } + + private static SortedSet FindSetOptionStarts(SyntaxTree tree) + { + HashSet names = new(StringComparer.Ordinal) + { + "Il2CppSetOption", + "Il2CppSetOptionAttribute", + }; + SyntaxNode root = tree.GetRoot(); + foreach (SyntaxNode node in root.DescendantNodes()) + { + if ( + node is UsingDirectiveSyntax directive + && directive.Alias != null + && names.Contains(SafetySyntaxTrees.LastName(directive.Name)) + ) + { + string alias = directive.Alias.Name.Identifier.ValueText; + names.Add(alias); + if ( + alias.EndsWith("Attribute", StringComparison.Ordinal) + && "Attribute".Length < alias.Length + ) + { + names.Add(alias.Substring(0, alias.Length - "Attribute".Length)); + } + } + } + SortedSet starts = new(); + foreach (SyntaxNode node in root.DescendantNodes()) + { + if ( + node is AttributeSyntax attribute + && attribute.ArgumentList != null + && names.Contains(SafetySyntaxTrees.LastName(attribute.Name)) + ) + { + starts.Add(attribute.ArgumentList.OpenParenToken.SpanStart); + } + else if ( + node is ObjectCreationExpressionSyntax creation + && creation.ArgumentList != null + && names.Contains(SafetySyntaxTrees.LastName(creation.Type)) + ) + { + starts.Add(creation.ArgumentList.OpenParenToken.SpanStart); + } + } + return starts; + } + + private static string Normalize(SyntaxTree tree, string text) + { + char[] code = new char[text.Length]; + for (int index = 0; index < text.Length; ++index) + { + char character = text[index]; + code[index] = character == '\n' || character == '\r' ? character : ' '; + } + foreach (SyntaxToken token in tree.GetRoot().DescendantTokens()) + { + if ( + token.Parent is ConstructorDeclarationSyntax constructor + && token == constructor.Identifier + || token.Parent is MethodDeclarationSyntax method && token == method.Identifier + ) + { + continue; + } + if ( + token.Parent is LiteralExpressionSyntax literal + && ( + literal.IsKind(SyntaxKind.StringLiteralExpression) + || literal.IsKind(SyntaxKind.Utf8StringLiteralExpression) + || literal.IsKind(SyntaxKind.CharacterLiteralExpression) + ) + ) + { + code[token.SpanStart] = '"'; + code[token.SpanStart + 1] = '"'; + continue; + } + if (token.Parent is InterpolatedStringExpressionSyntax interpolation) + { + if (token == interpolation.StringStartToken) + { + code[token.SpanStart] = '"'; + code[token.SpanStart + 1] = '"'; + } + continue; + } + if ( + token.Parent is InterpolatedStringTextSyntax + || token.Parent is InterpolationFormatClauseSyntax + ) + { + continue; + } + string value = token.Text; + if (token.IsKind(SyntaxKind.IdentifierToken)) + { + value = token.ValueText; + if ( + token.Text.StartsWith("@", StringComparison.Ordinal) + || SyntaxFacts.GetKeywordKind(value) != SyntaxKind.None + ) + { + value = "@" + value; + } + } + if (token.Span.Length < value.Length) + { + throw new InvalidOperationException( + "Normalized token exceeds its source span." + ); + } + for (int index = 0; index < value.Length; ++index) + { + code[token.SpanStart + index] = value[index]; + } + } + return new string(code); + } + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Il2CppCodePolicy.cs.meta b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Il2CppCodePolicy.cs.meta new file mode 100644 index 000000000..308a0af41 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Il2CppCodePolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4dd807ea03d3644fd4e5c807eb5f07b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Program.cs b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Program.cs index d703b0c63..572458568 100644 --- a/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Program.cs +++ b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/Program.cs @@ -69,6 +69,20 @@ private static int Main(string[] arguments) { return StackAllocationPolicy.Run(); } + if ( + arguments.Length == 1 + && string.Equals(arguments[0], "--unsafe-access-json", StringComparison.Ordinal) + ) + { + return UnsafeAccessPolicy.Run(); + } + if ( + arguments.Length == 1 + && string.Equals(arguments[0], "--il2cpp-code-json", StringComparison.Ordinal) + ) + { + return Il2CppCodePolicy.Run(); + } if ( 1 < arguments.Length || ( @@ -78,7 +92,7 @@ private static int Main(string[] arguments) ) { Console.Error.WriteLine( - "Usage: SyntaxPolicy [--fix | --stackalloc-json], from the repository root." + "Usage: SyntaxPolicy [--fix | --stackalloc-json | --unsafe-access-json | --il2cpp-code-json], from the repository root." ); return 2; } diff --git a/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/SafetySyntaxTrees.cs b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/SafetySyntaxTrees.cs new file mode 100644 index 000000000..f727a5655 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/SafetySyntaxTrees.cs @@ -0,0 +1,60 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.SyntaxPolicy +{ + using System; + using System.Collections.Generic; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp; + using Microsoft.CodeAnalysis.CSharp.Syntax; + + internal static class SafetySyntaxTrees + { + private const int MaximumConditionalSymbols = 12; + + internal static IEnumerable Parse(string path, string text) + { + List symbols = Program.FindSymbols(text); + if (MaximumConditionalSymbols < symbols.Count) + { + throw new InvalidOperationException( + "Safety source has more than twelve conditional symbols: " + path + ); + } + for (int combination = 0; combination < (1 << symbols.Count); ++combination) + { + List enabled = new(); + for (int index = 0; index < symbols.Count; ++index) + { + if ((combination & (1 << index)) != 0) + { + enabled.Add(symbols[index]); + } + } + yield return CSharpSyntaxTree.ParseText( + text, + new CSharpParseOptions(LanguageVersion.Latest, preprocessorSymbols: enabled), + path + ); + } + } + + internal static string LastName(SyntaxNode node) + { + switch (node) + { + case SimpleNameSyntax simple: + return simple.Identifier.ValueText; + case QualifiedNameSyntax qualified: + return qualified.Right.Identifier.ValueText; + case AliasQualifiedNameSyntax alias: + return alias.Name.Identifier.ValueText; + case MemberAccessExpressionSyntax member: + return member.Name.Identifier.ValueText; + default: + return null; + } + } + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/SafetySyntaxTrees.cs.meta b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/SafetySyntaxTrees.cs.meta new file mode 100644 index 000000000..aff015144 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/SafetySyntaxTrees.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 259227e8d1e31da92ef0f05e28c7acab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/UnsafeAccessPolicy.cs b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/UnsafeAccessPolicy.cs new file mode 100644 index 000000000..41e84a586 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/UnsafeAccessPolicy.cs @@ -0,0 +1,126 @@ +// MIT License - Copyright (c) 2026 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.SyntaxPolicy +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text.Json; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp; + using Microsoft.CodeAnalysis.CSharp.Syntax; + + internal static class UnsafeAccessPolicy + { + private static readonly HashSet AccessMembers = new(StringComparer.Ordinal) + { + "As", + "AsRef", + "AsPointer", + "Unbox", + "NullRef", + "SkipInit", + "Add", + "Subtract", + "AddByteOffset", + "SubtractByteOffset", + "Read", + "ReadUnaligned", + "Write", + "WriteUnaligned", + "Copy", + "CopyBlock", + "CopyBlockUnaligned", + "InitBlock", + "InitBlockUnaligned", + }; + + internal static int Run() + { + using JsonDocument document = JsonDocument.Parse(Console.In.ReadToEnd()); + List failures = new(); + int inspected = 0; + foreach (JsonElement source in document.RootElement.EnumerateArray()) + { + string path = source.GetProperty(nameof(StackAllocationSource.Path)).GetString(); + string text = source.GetProperty(nameof(StackAllocationSource.Text)).GetString(); + Dictionary accesses = new(); + Dictionary imports = new(); + foreach (SyntaxTree tree in SafetySyntaxTrees.Parse(path, text)) + { + foreach (SyntaxNode node in tree.GetRoot().DescendantNodes()) + { + if ( + node is UsingDirectiveSyntax directive + && ( + directive.Alias != null + || directive.StaticKeyword.IsKind(SyntaxKind.StaticKeyword) + ) + && string.Equals( + SafetySyntaxTrees.LastName(directive.Name), + "Unsafe", + StringComparison.Ordinal + ) + ) + { + imports[directive.SpanStart] = + tree.GetLineSpan(directive.Span).StartLinePosition.Line + 1; + } + if ( + node is MemberAccessExpressionSyntax member + && string.Equals( + SafetySyntaxTrees.LastName(member.Expression), + "Unsafe", + StringComparison.Ordinal + ) + && AccessMembers.Contains(member.Name.Identifier.ValueText) + ) + { + accesses[member.SpanStart] = + tree.GetLineSpan(member.Span).StartLinePosition.Line + 1; + } + } + } + foreach (int line in imports.Values) + { + failures.Add( + path + + ":" + + line + + ": aliases and static imports hide Unsafe member names. Use explicit member names so the memory-access rule can inspect them, see issue #637." + ); + } + inspected += accesses.Count; + if (accesses.Count != 0) + { + int firstLine = int.MaxValue; + foreach (int line in accesses.Values) + { + firstLine = Math.Min(firstLine, line); + } + failures.Add( + path + + ":" + + firstLine + + ": " + + accesses.Count + + " memory-access Unsafe member(s). Reinterpreting memory is undefined behaviour on IL2CPP if the type, layout or index assumption is wrong; use a checked cast, see issue #637." + ); + } + } + failures.Sort(StringComparer.Ordinal); + using Utf8JsonWriter writer = new(Console.OpenStandardOutput()); + writer.WriteStartObject(); + writer.WriteStartArray("Failures"); + foreach (string failure in failures) + { + writer.WriteStringValue(failure); + } + writer.WriteEndArray(); + writer.WriteNumber("Inspected", inspected); + writer.WriteEndObject(); + return 0; + } + } +} diff --git a/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/UnsafeAccessPolicy.cs.meta b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/UnsafeAccessPolicy.cs.meta new file mode 100644 index 000000000..7ca538dd5 --- /dev/null +++ b/Generator~/WallstopStudios.UnityHelpers.SyntaxPolicy/UnsafeAccessPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2314a87c6819680c3821f4d35bc6c151 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/project/contributing.md b/docs/project/contributing.md index 8f4ccc7a6..dd905e58e 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -91,7 +91,13 @@ such as `!true` disables checks even though it contains that word. Named constan expressions, attribute aliases and numeric option casts follow the same rule. Each scan must report both a literal-false control and a negated-true control before it can report a clean package. -Stack-allocation checks use the existing Roslyn policy tool and require .NET SDK 9.0.306. +Memory-access and IL2CPP option checks parse C# syntax, including Unicode-escaped identifiers and interpolation +expressions. Comments, literal text and format strings remain excluded. Every conditional branch +is checked, and each source site is counted once. Aliases and static imports of `Unsafe` are +refused because they hide member names. Size and reference-identity queries remain permitted. +The positive controls require plain, escaped and interpolated calls to be found. + +Stack-allocation and memory-access checks use the existing Roslyn policy tool and require .NET SDK 9.0.306. Repo Lint installs that SDK before running the checks. The compiler resolves constants and the actual length symbol; parameters, locals, readonly fields and properties cannot borrow an unrelated constant's name. Constant expressions and shared constants remain supported. The scan covers conditional branches diff --git a/scripts/lint-unsafe-code.js b/scripts/lint-unsafe-code.js index 2e3e67c07..f58f3fd4f 100644 --- a/scripts/lint-unsafe-code.js +++ b/scripts/lint-unsafe-code.js @@ -24,6 +24,8 @@ undefined behaviour with neither the keyword nor the asmdef flag. See findIl2CppSafetyDisables. */ +// cspell:ignore nsafe + "use strict"; const { @@ -61,12 +63,6 @@ let semanticPolicyFailure = null; WProtoReader uses AreSame to ask whether two spans start at the same element, which is a question about identity, not an access. */ -const UNSAFE_ACCESS = - /\bUnsafe\s*\.\s*(?:As|AsRef|AsPointer|Unbox|NullRef|SkipInit|Add|Subtract|AddByteOffset|SubtractByteOffset|Read|ReadUnaligned|Write|WriteUnaligned|Copy|CopyBlock|CopyBlockUnaligned|InitBlock|InitBlockUnaligned)\b/g; - -const UNSAFE_IMPORT = - /\busing\s+(?:static\s+|@?[A-Za-z_]\w*\s*=\s*)(?:@?[A-Za-z_]\w*\s*::\s*)?(?:@?[A-Za-z_]\w*\s*\.\s*)*@?Unsafe\s*;/g; - /* IL2CPP emits a null check on every reference dereference, a bounds check on every array index and a divide-by-zero check on every integer division, which is what makes a wrong assumption in @@ -83,8 +79,6 @@ const UNSAFE_IMPORT = nothing to baseline, so a single site is the first one. */ const IL2CPP_SET_OPTION = /\bIl2CppSetOption(?:Attribute)?\s*\(/g; -const IL2CPP_SET_OPTION_IMPORT = - /\busing\s+@?([A-Za-z_]\w*)\s*=\s*(?:@?[A-Za-z_]\w*\s*(?:::|\.)\s*)*@?Il2CppSetOption(?:Attribute)?\s*;/g; /* The three checks Unity's Option enum can switch off. Matched case-insensitively and with the @@ -198,7 +192,7 @@ function getSemanticPolicyDll() { const dll = path.join(artifacts, "bin", POLICY_NAME, "release", POLICY_NAME + ".dll"); if (build.status !== 0 || !existsSync(dll)) { semanticPolicyFailure = new Error( - "Could not build the semantic stack-allocation gate: " + + "Could not build the compiler syntax safety gate: " + (build.error?.message ?? build.stderr ?? "") + "\n" + build.stdout @@ -214,21 +208,27 @@ function findStackAllocations(sources) { if (!owned.some((source) => source.text.includes("stackalloc"))) { return { failures: [], inspected: 0 }; } - const result = spawnSync("dotnet", [getSemanticPolicyDll(), "--stackalloc-json"], { - input: JSON.stringify(owned.map((source) => ({ Path: source.path, Text: source.text }))), + return runCompilerPolicy(owned, "--stackalloc-json", "stack-allocation"); +} + +function runCompilerPolicy(sources, mode, label) { + const scan = runCompilerCommand(sources, mode, label); + if (!Array.isArray(scan.Failures) || !Number.isInteger(scan.Inspected) || scan.Inspected < 0) { + throw new Error(`Compiler ${label} scan returned an invalid result`); + } + return { failures: scan.Failures, inspected: scan.Inspected }; +} + +function runCompilerCommand(sources, mode, label) { + const result = spawnSync("dotnet", [getSemanticPolicyDll(), mode], { + input: JSON.stringify(sources.map((source) => ({ Path: source.path, Text: source.text }))), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (result.status !== 0) { - throw new Error( - "Semantic stack-allocation scan failed: " + (result.error?.message ?? result.stderr) - ); - } - const scan = JSON.parse(result.stdout); - if (!Array.isArray(scan.Failures) || !Number.isInteger(scan.Inspected) || scan.Inspected < 0) { - throw new Error("Semantic stack-allocation scan returned an invalid result"); + throw new Error(`Compiler ${label} scan failed: ` + (result.error?.message ?? result.stderr)); } - return { failures: scan.Failures, inspected: scan.Inspected }; + return JSON.parse(result.stdout); } /** @@ -238,38 +238,15 @@ function findStackAllocations(sources) { * @returns {{failures: string[], inspected: number}} violations and how many sites were judged */ function findUnsafeAccess(sources) { - const failures = []; - let inspected = 0; - - for (const source of sources) { - if (isVendored(source.path)) { - continue; - } - - const code = codeOnly(source.text); - for (const importSite of code.matchAll(UNSAFE_IMPORT)) { - const line = code.slice(0, importSite.index).split("\n").length; - failures.push( - `${source.path}:${line}: aliases and static imports hide Unsafe member names. ` + - `Use explicit member names so the memory-access rule can inspect them, ${ISSUE}.` - ); - } - const sites = [...code.matchAll(UNSAFE_ACCESS)]; - if (sites.length === 0) { - continue; - } - - inspected += sites.length; - - const line = code.slice(0, sites[0].index).split("\n").length; - failures.push( - `${source.path}:${line}: ${sites.length} memory-access Unsafe member(s). ` + - `Reinterpreting memory is undefined behaviour on IL2CPP if the ` + - `type, layout or index assumption is wrong; use a checked cast, ${ISSUE}.` - ); + const candidates = sources.filter( + (source) => + !isVendored(source.path) && + (source.text.includes("Unsafe") || /\\[uU][0-9a-fA-F]/.test(source.text)) + ); + if (candidates.length === 0) { + return { failures: [], inspected: 0 }; } - - return { failures, inspected }; + return runCompilerPolicy(candidates, "--unsafe-access-json", "Unsafe memory-access"); } /** Every balanced `(...)` in `code`, each as its open index, close index and inner text. */ @@ -387,15 +364,32 @@ function judgeIl2CppArguments(argumentText, isSetOption) { * @returns {{failures: string[], inspected: number}} violations, and how many lists were judged */ function findIl2CppSafetyDisables(sources) { - const failures = []; - let inspected = 0; - - for (const source of sources) { - if (isVendored(source.path)) { - continue; - } - - const code = codeOnly(source.text); + const failuresBySite = new Map(); + const inspectedSites = new Set(); + const candidates = sources.filter( + (source) => + !isVendored(source.path) && + (IL2CPP_MENTION.test(source.text) || /\\[uU][0-9a-fA-F]/.test(source.text)) + ); + if (candidates.length === 0) { + return { failures: [], inspected: 0 }; + } + const scan = runCompilerCommand(candidates, "--il2cpp-code-json", "IL2CPP code"); + if ( + !Array.isArray(scan.Variants) || + !scan.Variants.every( + (variant) => + typeof variant.Path === "string" && + typeof variant.Text === "string" && + Array.isArray(variant.SetOptionStarts) && + variant.SetOptionStarts.every((start) => Number.isInteger(start) && 0 <= start) + ) + ) { + throw new Error("Compiler IL2CPP code scan returned an invalid result"); + } + for (const variant of scan.Variants) { + const source = { path: variant.Path, text: variant.Text }; + const code = source.text; if (!IL2CPP_MENTION.test(code)) { continue; } @@ -403,12 +397,12 @@ function findIl2CppSafetyDisables(sources) { const regions = parenthesizedRegions(code); const judged = new Map(); const setOptionPatterns = [IL2CPP_SET_OPTION]; - for (const alias of code.matchAll(IL2CPP_SET_OPTION_IMPORT)) { - const name = alias[1]; - setOptionPatterns.push(new RegExp(`\\b${name}\\s*\\(`, "g")); - if (name.endsWith("Attribute") && "Attribute".length < name.length) { - setOptionPatterns.push(new RegExp(`\\b${name.slice(0, -"Attribute".length)}\\s*\\(`, "g")); + for (const start of variant.SetOptionStarts) { + const region = regions.find((candidate) => candidate.start === start); + if (region === undefined) { + throw new Error("Compiler IL2CPP argument list was not present in normalized source"); } + judged.set(start, { region, isSetOption: true }); } for (const pattern of setOptionPatterns) { for (const match of code.matchAll(pattern)) { @@ -431,7 +425,8 @@ function findIl2CppSafetyDisables(sources) { for (const { region, isSetOption } of [...judged.values()].sort( (left, right) => left.region.start - right.region.start )) { - inspected++; + const site = `${source.path}:${region.start}`; + inspectedSites.add(site); const finding = judgeIl2CppArguments(region.text, isSetOption); if (finding === null) { continue; @@ -442,7 +437,8 @@ function findIl2CppSafetyDisables(sources) { finding.options.length === 0 ? "an IL2CPP safety check" : `IL2CPP ${[...new Set(finding.options)].join(", ")}`; - failures.push( + failuresBySite.set( + site, `${source.path}:${line}: '${finding.value}' switches off ${named}. Without those ` + `checks a null dereference, an out-of-range index and an integer divide by zero ` + `are undefined behaviour rather than exceptions; write the literal true or drop ` + @@ -451,7 +447,7 @@ function findIl2CppSafetyDisables(sources) { } } - return { failures, inspected }; + return { failures: [...failuresBySite.values()].sort(), inspected: inspectedSites.size }; } /** @@ -527,7 +523,10 @@ const IL2CPP_CONTROL = " [Il2CppSetOption(Option.NullChecks, false)]\n" + " internal sealed class Subject { }\n" + " [Il2CppSetOption(Option.ArrayBoundsChecks, !true)]\n" + - " internal sealed class ExpressionSubject { }\n}\n"; + " internal sealed class ExpressionSubject { }\n" + + String.raw` [\u0049l2CppSetOption((Option)0, false)]` + + "\n internal sealed class EscapedSubject { }\n" + + ' class InterpolationSubject { string M() { return $"{Configure(Option.NullChecks, false)}"; } }\n}\n'; /* Listed by directory and filtered here, never by a recursive-glob pathspec: git's double-star @@ -601,9 +600,17 @@ function scanRepository(repoRoot) { const unsafeAccess = findUnsafeAccess(sources); failures.push(...unsafeAccess.failures); const unsafeControl = findUnsafeAccess([ - { path: "control/UnsafeAccess.cs", text: "Unsafe.As(ref value);" } + { + path: "control/UnsafeAccess.cs", + text: + "class Control { void M(ref int value) { Unsafe.As(ref value); " + + String.raw`\u0055nsafe.\u0041s(ref value);` + + ' string text = $"{Unsafe.As(ref value)}"; } }' + } ]); - if (unsafeControl.failures.length !== 1) { + const unsafeControlReported = + unsafeControl.failures.length === 1 && unsafeControl.inspected === 3; + if (!unsafeControlReported) { failures.push(`the memory-access Unsafe control was not reported; ${ISSUE}.`); } @@ -611,10 +618,10 @@ function scanRepository(repoRoot) { { path: "control/Il2CppSetOption.cs", text: IL2CPP_CONTROL } ]); const il2cppControlReported = - il2cppControl.inspected === 2 && il2cppControl.failures.length === 2; + il2cppControl.inspected === 4 && il2cppControl.failures.length === 4; if (!il2cppControlReported) { failures.push( - `the literal and expression IL2CPP controls were not both reported, so a clean scan ` + + `the literal, expression, escaped and interpolated IL2CPP controls were not all reported, so a clean scan ` + `cannot prove that disabled runtime checks would be seen; ${ISSUE}.` ); } @@ -641,7 +648,7 @@ function scanRepository(repoRoot) { stackAllocations: inspected, unsafeAccessSites: unsafeAccess.inspected, il2cppOptionLists: findIl2CppSafetyDisables(sources).inspected, - unsafeControlReported: unsafeControl.failures.length === 1, + unsafeControlReported, stackControlReported, il2cppControlReported } diff --git a/scripts/tests/test-lint-unsafe-code.js b/scripts/tests/test-lint-unsafe-code.js index 6002251fd..ffe97d12c 100644 --- a/scripts/tests/test-lint-unsafe-code.js +++ b/scripts/tests/test-lint-unsafe-code.js @@ -13,6 +13,8 @@ // StackOverflowException that no catch intercepts, so a length must be a constant or guarded against one in // the same statement. Both shipped offenders are reproduced verbatim as red cases. +// cspell:ignore nsafe ize ull alue + "use strict"; const assert = require("assert"); @@ -778,6 +780,90 @@ for (const imported of [ }); } +for (const [label, text] of [ + [ + "escaped receiver", + String.raw`class Sample { int Read(ref byte b) { return \u0055nsafe.As(ref b); } }` + ], + [ + "escaped member", + String.raw`class Sample { int Read(ref byte b) { return Unsafe.\u0041s(ref b); } }` + ], + [ + "long Unicode escape", + String.raw`class Sample { int Read(ref byte b) { return \U00000055nsafe.As(ref b); } }` + ], + [ + "interpolated expression", + 'class Sample { string Read(ref byte b) { return $"{Unsafe.As(ref b)}"; } }' + ], + [ + "verbatim interpolated expression", + 'class Sample { string Read(ref byte b) { return $@"{Unsafe.As(ref b)}"; } }' + ], + [ + "escaped alias", + String.raw`using Raw = System.Runtime.CompilerServices.\u0055nsafe; class Sample {}` + ], + [ + "conditional branch", + '#if FEATURE\nclass Sample { string Read(ref byte b) { return $"{Unsafe.As(ref b)}"; } }\n#else\nclass Sample {}\n#endif' + ], + ["method group", String.raw`class Sample { object M() { return Unsafe.\u0041s; } }`] +]) { + runTest(`parsed Unsafe access cannot hide in ${label}`, () => { + const found = unsafeAccess(text); + assert.strictEqual(found.length, 1, JSON.stringify(found)); + assert.match(found[0], /Runtime\/Sample.cs:\d+:/); + }); +} + +for (const text of [ + String.raw`class Sample { string Text = "\u0055nsafe.As(ref value)"; }`, + 'class Sample { string Text = $"Unsafe.As(ref value) {1}"; }', + 'class Sample { string Text = $"{{Unsafe.As(ref value)}}"; }', + 'class Sample { string Text = $"{1:Unsafe.As}"; }', + 'class Sample { string Text = """Unsafe.As(ref value)"""; }', + String.raw`class Sample { int Size() { return \u0055nsafe.\u0053izeOf(); } }` +]) { + runTest(`parsed Unsafe policy leaves literal text and size queries alone: ${text}`, () => { + assert.deepStrictEqual(unsafeAccess(text), []); + }); +} + +runTest("parsed Unsafe access counts each source site once across conditional variants", () => { + const scan = findUnsafeAccess([ + { + path: "Runtime/Sample.cs", + text: '#if FIRST\nclass First {}\n#endif\n#if SECOND\nclass Second {}\n#endif\nclass Sample { string Read(ref byte b) { return $"{Unsafe.As(ref b)} {Unsafe.As(ref b)}"; } }' + } + ]); + assert.strictEqual(scan.inspected, 2); + assert.strictEqual(scan.failures.length, 1); + assert.match(scan.failures[0], /Runtime\/Sample.cs:7:/); +}); + +runTest("parsed Unsafe access fails closed when conditional expansion exceeds its bound", () => { + const directives = Array.from({ length: 13 }, (_, index) => `#if FEATURE${index}\n#endif`).join( + "\n" + ); + assert.throws( + () => unsafeAccess(directives + "\nUnsafe.As(ref value);"), + /more than twelve conditional symbols/ + ); +}); + +runTest( + "parsed Unsafe access preserves nested interpolation expressions and skips format text", + () => { + const text = + 'class Sample { string Read(ref byte b) { return $$"""literal Unsafe.As {{ $"{Unsafe.As(ref b):Unsafe.As}" }}"""; } }'; + const scan = findUnsafeAccess([{ path: "Runtime/Sample.cs", text }]); + assert.strictEqual(scan.inspected, 1); + assert.strictEqual(scan.failures.length, 1); + } +); + runTest("Unsafe.SizeOf is a size query, not memory access", () => { assert.deepStrictEqual( unsafeAccess("class Sample { static int Size() => Unsafe.SizeOf(); }\n"), @@ -851,8 +937,129 @@ function il2cpp(text) { return findIl2CppSafetyDisables([{ path: "Runtime/Sample.cs", text }]).failures; } +for (const [label, text] of [ + [ + "Unicode alias", + String.raw`using \u0394 = Unity.IL2CPP.CompilerServices.Il2CppSetOptionAttribute; [\u0394((Option)0, false)] class Sample {}` + ], + [ + "Unicode suffix alias", + String.raw`using \u0394Attribute = Unity.IL2CPP.CompilerServices.Il2CppSetOptionAttribute; [\u0394((Option)0, false)] class Sample {}` + ], + [ + "Unicode constructor alias", + String.raw`using \u0394 = Unity.IL2CPP.CompilerServices.Il2CppSetOptionAttribute; class Sample { object M() { return new \u0394((Option)0, false); } }` + ], + [ + "escaped attribute and option", + String.raw`[\u0049l2CppSetOption(Option.\u004eullChecks, false)] class Sample {}` + ], + [ + "escaped attribute with numeric option", + String.raw`[\u0049l2CppSetOption((Option)0, false)] class Sample {}` + ], + [ + "escaped attribute alias", + String.raw`using Check = Unity.IL2CPP.CompilerServices.\u0049l2CppSetOptionAttribute; [Check((Option)0, false)] class Sample {}` + ], + [ + "interpolated helper", + 'class Sample { string M() { return $"{Configure(Option.NullChecks, false)}"; } }' + ], + [ + "raw interpolated helper", + 'class Sample { string M() { return $$"""{{Configure(Option.NullChecks, false)}}"""; } }' + ], + [ + "disabled conditional branch", + "#if FEATURE\nclass Enabled {}\n#else\n[Il2CppSetOption(Option.NullChecks, false)] class Sample {}\n#endif" + ], + [ + "escaped value label", + String.raw`[Il2CppSetOption(Option.NullChecks, \u0076alue: false)] class Sample {}` + ] +]) { + runTest(`parsed IL2CPP options cannot hide in ${label}`, () => { + const found = il2cpp(text); + assert.strictEqual(found.length, 1, JSON.stringify(found)); + assert.match(found[0], /Runtime\/Sample.cs:\d+:/); + }); +} + +for (const text of [ + 'class Sample { string Text = $$"""literal Il2CppSetOption(Option.NullChecks, false) {{1}}"""; }', + 'class Sample { string Text = $"{1:Il2CppSetOption(Option.NullChecks, false)}"; }', + String.raw`[\u0049l2CppSetOption(Option.\u004eullChecks, true)] class Sample {}`, + String.raw`class Sample { const bool @true = false; [Il2CppSetOption(Option.NullChecks, true)] void M() {} }` +]) { + runTest(`parsed IL2CPP options preserve enabled checks and literal text: ${text}`, () => { + assert.deepStrictEqual(il2cpp(text), []); + }); +} + +runTest("parsed IL2CPP options count a source site once across conditional variants", () => { + const scan = findIl2CppSafetyDisables([ + { + path: "Runtime/Sample.cs", + text: "#if FIRST\nclass First {}\n#endif\n#if SECOND\nclass Second {}\n#endif\n[Il2CppSetOption(Option.NullChecks, false)] class Sample {}" + } + ]); + assert.strictEqual(scan.inspected, 1); + assert.strictEqual(scan.failures.length, 1); + assert.match(scan.failures[0], /Runtime\/Sample.cs:7:/); +}); + +runTest("the parsed policy controls compile without unsafe compilation or execution", () => { + const temporary = mkdtempSync(path.join(tmpdir(), "unity-helpers-parsed-safety-")); + const text = String.raw` +using System; +using System.Runtime.CompilerServices; +using Unity.IL2CPP.CompilerServices; +using \u0394 = Unity.IL2CPP.CompilerServices.Il2CppSetOptionAttribute; +[\u0394(Option.\u004eullChecks, false)] +internal class Sample +{ + static int Read(ref byte value) { return \u0055nsafe.\u0041s(ref value); } + static string Format(ref byte value) { return $"{Unsafe.As(ref value)} {Configure(Option.NullChecks, false)}"; } + static bool Configure(Option option, bool value) { return value; } +} +namespace Unity.IL2CPP.CompilerServices +{ + public enum Option { NullChecks = 0 } + public sealed class Il2CppSetOptionAttribute : Attribute + { + public Il2CppSetOptionAttribute(Option option, bool value) { } + } +}`; + try { + writeFileSync(path.join(temporary, "Control.cs"), text); + writeFileSync( + path.join(temporary, "Control.csproj"), + 'net9.0false' + ); + const build = spawnSync( + "dotnet", + ["build", path.join(temporary, "Control.csproj"), "--nologo", "--disable-build-servers"], + { cwd: temporary, encoding: "utf8" } + ); + assert.strictEqual(build.status, 0, build.stdout + build.stderr); + const memory = findUnsafeAccess([{ path: "Runtime/Control.cs", text }]); + const options = findIl2CppSafetyDisables([{ path: "Runtime/Control.cs", text }]); + assert.strictEqual(memory.inspected, 2); + assert.strictEqual(memory.failures.length, 1); + assert.strictEqual(options.inspected, 2); + assert.strictEqual(options.failures.length, 2); + } finally { + rmSync(temporary, { recursive: true, force: true, maxRetries: 3 }); + } +}); + /** Every spelling that leaves a check switched off. Each must be reported exactly once. */ const IL2CPP_DISABLED = [ + ...['"true"', '"false"', '""', "'t'"].map((value) => [ + `a literal value that is not boolean true: ${value}`, + `[Il2CppSetOption(Option.NullChecks, ${value})] class Sample {}` + ]), ...["false", "!true"].flatMap((value) => [ `@value: ${value}, option: Option.NullChecks`, From d30382f2507d4f3944af5c0719c4b32a7a4fa76e Mon Sep 17 00:00:00 2001 From: wallstop Date: Sat, 17 Oct 2026 18:06:54 +0000 Subject: [PATCH 3/3] Reject invisible identifier safety bypasses C# ignores format characters inside identifiers. The source prefilters missed those spellings even though Roslyn correctly normalized them. Send raw format characters through both safety policies and require independent positive controls that ordinary spellings cannot mask. Verify three characters, literal exclusions and a compile-only C# proof. All 274 safety controls and the package scan pass. Refs #637. --- docs/project/contributing.md | 7 ++-- scripts/lint-unsafe-code.js | 23 +++++++++---- scripts/tests/test-lint-unsafe-code.js | 47 +++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/docs/project/contributing.md b/docs/project/contributing.md index dd905e58e..fb5302965 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -91,11 +91,12 @@ such as `!true` disables checks even though it contains that word. Named constan expressions, attribute aliases and numeric option casts follow the same rule. Each scan must report both a literal-false control and a negated-true control before it can report a clean package. -Memory-access and IL2CPP option checks parse C# syntax, including Unicode-escaped identifiers and interpolation -expressions. Comments, literal text and format strings remain excluded. Every conditional branch +Memory-access and IL2CPP option checks parse C# syntax, including Unicode escapes, invisible identifier format characters and +interpolation expressions. Comments, literal text and format strings remain excluded. Every conditional branch is checked, and each source site is counted once. Aliases and static imports of `Unsafe` are refused because they hide member names. Size and reference-identity queries remain permitted. -The positive controls require plain, escaped and interpolated calls to be found. +The positive controls require plain, escaped, formatted and interpolated calls to be found. +Separate sources exercise format characters without an ordinary spelling that could mask a prefilter regression. Stack-allocation and memory-access checks use the existing Roslyn policy tool and require .NET SDK 9.0.306. Repo Lint installs that SDK before running the checks. The compiler resolves constants and the actual length symbol; diff --git a/scripts/lint-unsafe-code.js b/scripts/lint-unsafe-code.js index f58f3fd4f..9d0edeecb 100644 --- a/scripts/lint-unsafe-code.js +++ b/scripts/lint-unsafe-code.js @@ -24,7 +24,7 @@ undefined behaviour with neither the keyword nor the asmdef flag. See findIl2CppSafetyDisables. */ -// cspell:ignore nsafe +// cspell:ignore nsafe Unsa "use strict"; @@ -44,6 +44,7 @@ const { spawnSync } = require("node:child_process"); const SOURCE_ROOTS = ["Runtime", "Editor", "Tests"]; const VENDORED_PREFIXES = ["Runtime/Utils/SevenZip/"]; const ISSUE = "see issue #637"; +const IDENTIFIER_ESCAPES_OR_FORMATTING = /\\[uU][0-9a-fA-F]|\p{Cf}/u; const POLICY_NAME = "WallstopStudios.UnityHelpers.SyntaxPolicy"; let semanticPolicyDll = null; let semanticPolicyFailure = null; @@ -241,7 +242,7 @@ function findUnsafeAccess(sources) { const candidates = sources.filter( (source) => !isVendored(source.path) && - (source.text.includes("Unsafe") || /\\[uU][0-9a-fA-F]/.test(source.text)) + (source.text.includes("Unsafe") || IDENTIFIER_ESCAPES_OR_FORMATTING.test(source.text)) ); if (candidates.length === 0) { return { failures: [], inspected: 0 }; @@ -369,7 +370,7 @@ function findIl2CppSafetyDisables(sources) { const candidates = sources.filter( (source) => !isVendored(source.path) && - (IL2CPP_MENTION.test(source.text) || /\\[uU][0-9a-fA-F]/.test(source.text)) + (IL2CPP_MENTION.test(source.text) || IDENTIFIER_ESCAPES_OR_FORMATTING.test(source.text)) ); if (candidates.length === 0) { return { failures: [], inspected: 0 }; @@ -606,22 +607,30 @@ function scanRepository(repoRoot) { "class Control { void M(ref int value) { Unsafe.As(ref value); " + String.raw`\u0055nsafe.\u0041s(ref value);` + ' string text = $"{Unsafe.As(ref value)}"; } }' + }, + { + path: "control/FormattedMemory.cs", + text: `class Control { int M(ref byte value) { return Unsa${String.fromCodePoint(0x200c)}fe.As(ref value); } }` } ]); const unsafeControlReported = - unsafeControl.failures.length === 1 && unsafeControl.inspected === 3; + unsafeControl.failures.length === 2 && unsafeControl.inspected === 4; if (!unsafeControlReported) { failures.push(`the memory-access Unsafe control was not reported; ${ISSUE}.`); } const il2cppControl = findIl2CppSafetyDisables([ - { path: "control/Il2CppSetOption.cs", text: IL2CPP_CONTROL } + { path: "control/Il2CppSetOption.cs", text: IL2CPP_CONTROL }, + { + path: "control/FormattedOption.cs", + text: `[Il2CppSet${String.fromCodePoint(0x200c)}Option((Option)0, false)] class Control {}` + } ]); const il2cppControlReported = - il2cppControl.inspected === 4 && il2cppControl.failures.length === 4; + il2cppControl.inspected === 5 && il2cppControl.failures.length === 5; if (!il2cppControlReported) { failures.push( - `the literal, expression, escaped and interpolated IL2CPP controls were not all reported, so a clean scan ` + + `the literal, expression, escaped, formatted and interpolated IL2CPP controls were not all reported, so a clean scan ` + `cannot prove that disabled runtime checks would be seen; ${ISSUE}.` ); } diff --git a/scripts/tests/test-lint-unsafe-code.js b/scripts/tests/test-lint-unsafe-code.js index ffe97d12c..ba84d79fe 100644 --- a/scripts/tests/test-lint-unsafe-code.js +++ b/scripts/tests/test-lint-unsafe-code.js @@ -13,7 +13,7 @@ // StackOverflowException that no catch intercepts, so a length must be a constant or guarded against one in // the same statement. Both shipped offenders are reproduced verbatim as red cases. -// cspell:ignore nsafe ize ull alue +// cspell:ignore nsafe ize ull alue Unsa "use strict"; @@ -765,6 +765,16 @@ runTest("a memory-access Unsafe member in a file with no baseline entry is a vio assert.ok(found[0].includes("Runtime/Sample.cs:1"), found[0]); }); +for (const point of [0x200b, 0x200c, 0x2060]) { + const format = String.fromCodePoint(point); + runTest(`raw format characters cannot hide Unsafe access: ${point}`, () => { + const text = `using System.Runtime.CompilerServices; class Sample { int Read(ref byte value) { return Unsa${format}fe.As(ref value); } }`; + const scan = findUnsafeAccess([{ path: "Runtime/Formatted.cs", text }]); + assert.strictEqual(scan.inspected, 1); + assert.strictEqual(scan.failures.length, 1); + }); +} + for (const imported of [ "using Raw = System.Runtime.CompilerServices.Unsafe;", "using Raw = global::System.Runtime.CompilerServices.Unsafe;", @@ -1033,6 +1043,16 @@ namespace Unity.IL2CPP.CompilerServices }`; try { writeFileSync(path.join(temporary, "Control.cs"), text); + const format = String.fromCodePoint(0x200c); + const raw = `using System.Runtime.CompilerServices; +using Unity.IL2CPP.CompilerServices; +[Il2CppSet${format}Option((Option)0, false)] +internal class RawControl +{ + static int Read(ref byte value) { return Unsa${format}fe.As(ref value); } +}`; + writeFileSync(path.join(temporary, "RawControl.cs"), raw); + writeFileSync( path.join(temporary, "Control.csproj"), 'net9.0false' @@ -1049,11 +1069,36 @@ namespace Unity.IL2CPP.CompilerServices assert.strictEqual(memory.failures.length, 1); assert.strictEqual(options.inspected, 2); assert.strictEqual(options.failures.length, 2); + assert.strictEqual( + findUnsafeAccess([{ path: "Runtime/RawControl.cs", text: raw }]).inspected, + 1 + ); + assert.strictEqual( + findIl2CppSafetyDisables([{ path: "Runtime/RawControl.cs", text: raw }]).inspected, + 1 + ); } finally { rmSync(temporary, { recursive: true, force: true, maxRetries: 3 }); } }); +for (const point of [0x200b, 0x200c, 0x2060]) { + const format = String.fromCodePoint(point); + runTest(`raw format characters cannot hide IL2CPP options: ${point}`, () => { + const text = `[Il2CppSet${format}Option((Option)0, false)] class Sample {}`; + const scan = findIl2CppSafetyDisables([{ path: "Runtime/Formatted.cs", text }]); + assert.strictEqual(scan.inspected, 1); + assert.strictEqual(scan.failures.length, 1); + }); +} + +runTest("raw format characters in literal text remain excluded", () => { + const format = String.fromCodePoint(0x200c); + const text = `class Sample { string text = "Unsa${format}fe.As(ref value); Il2CppSet${format}Option((Option)0, false)"; }`; + assert.deepStrictEqual(unsafeAccess(text), []); + assert.deepStrictEqual(il2cpp(text), []); +}); + /** Every spelling that leaves a check switched off. Each must be reported exactly once. */ const IL2CPP_DISABLED = [ ...['"true"', '"false"', '""', "'t'"].map((value) => [