diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92cc0686..a2857979 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -208,3 +208,40 @@ jobs: unzip -Z1 "${app_bundles[0]}" > "$entries" grep -Eq '(^|/)lib/arm64-v8a/libil2cpp\.so$' "$entries" grep -Eq '(^|/)lib/arm64-v8a/libbacktrace-native\.so$' "$entries" + + dex_entries=() + while IFS= read -r entry; do + if [[ "$entry" =~ ^base/dex/classes([0-9]+)?\.dex$ ]]; then + dex_entries+=("$entry") + fi + done < "$entries" + + if (( ${#dex_entries[@]} == 0 )); then + echo "Error: App Bundle contains no base/dex/classes*.dex entries." >&2 + exit 1 + fi + + dex_dir="$RUNNER_TEMP/backtrace-aab-dex" + mkdir -p "$dex_dir" + + handler_class='backtraceio/library/nativeCalls/BacktraceCrashHandler' + handler_descriptor="L${handler_class};" + handler_found=false + + for dex_index in "${!dex_entries[@]}"; do + dex_entry="${dex_entries[$dex_index]}" + dex_file="$dex_dir/classes-$dex_index.dex" + unzip -p "${app_bundles[0]}" "$dex_entry" > "$dex_file" + + if grep -aFq -- "$handler_descriptor" "$dex_file"; then + handler_found=true + break + fi + done + + if [[ "$handler_found" != true ]]; then + echo "Error: App Bundle DEX files do not contain ${handler_class}." >&2 + printf 'Searched DEX entries:\n' >&2 + printf ' %s\n' "${dex_entries[@]}" >&2 + exit 1 + fi diff --git a/Runtime/BacktraceClient.cs b/Runtime/BacktraceClient.cs index a20dfd21..6df666c6 100644 --- a/Runtime/BacktraceClient.cs +++ b/Runtime/BacktraceClient.cs @@ -194,7 +194,10 @@ public bool SetAttribute(string key, string value) AttributeProvider[key] = value; if (_nativeClient != null) { - _nativeClient.SetAttribute(key, value); + NativeAttributeLifecycle.TrySetAttribute( + () => _nativeClient.SetAttribute(key, value), + NativeAttributeLifecycle.NativeAttributeFailureCode, + warning => Debug.LogWarning(warning)); } return true; } @@ -368,6 +371,10 @@ internal INativeClient NativeClient { return _nativeClient; } + set + { + _nativeClient = value; + } } public bool EnablePerformanceStatistics diff --git a/Runtime/Native/Android/AndroidLoadedLibraryPath.cs b/Runtime/Native/Android/AndroidLoadedLibraryPath.cs index 7abfdc5f..86199954 100644 --- a/Runtime/Native/Android/AndroidLoadedLibraryPath.cs +++ b/Runtime/Native/Android/AndroidLoadedLibraryPath.cs @@ -1,6 +1,8 @@ -#if UNITY_ANDROID +#if UNITY_ANDROID || UNITY_EDITOR using System; +#if UNITY_ANDROID using System.Runtime.InteropServices; +#endif namespace Backtrace.Unity.Runtime.Native.Android { @@ -13,6 +15,7 @@ namespace Backtrace.Unity.Runtime.Native.Android /// internal static class AndroidLoadedLibraryPath { +#if UNITY_ANDROID // RTLD_LAZY has the same value on 32- and 64-bit bionic. // RTLD_NOW (2 on LP64) must NOT be used here: on LP32 bionic the value 2 means RTLD_GLOBAL, // which would irreversibly promote every exported symbol of the already-loaded library into the global group and let later dlopen'ed libraries bind against them. @@ -50,44 +53,51 @@ private struct DlInfo /// internal static string TryGet() { - try + return TryGet(GetLoadedLibraryPath); + } + + private static string GetLoadedLibraryPath() + { + lock (HandleLock) { - lock (HandleLock) + if (_libraryHandle == IntPtr.Zero) { - if (_libraryHandle == IntPtr.Zero) - { - _libraryHandle = DlOpen(NativeLibraryName, RtldLazy); - } - if (_libraryHandle == IntPtr.Zero) - { - return null; - } - - IntPtr anchor = DlSym(_libraryHandle, AnchorSymbol); - if (anchor == IntPtr.Zero) - { - return null; - } + _libraryHandle = DlOpen(NativeLibraryName, RtldLazy); + } + if (_libraryHandle == IntPtr.Zero) + { + return null; + } - DlInfo information; - if (DlAddr(anchor, out information) == 0 || information.FileName == IntPtr.Zero) - { - return null; - } + IntPtr anchor = DlSym(_libraryHandle, AnchorSymbol); + if (anchor == IntPtr.Zero) + { + return null; + } - string path = Marshal.PtrToStringAnsi(information.FileName); - return string.IsNullOrEmpty(path) ? null : path; + DlInfo information; + if (DlAddr(anchor, out information) == 0 || information.FileName == IntPtr.Zero) + { + return null; } + + return Marshal.PtrToStringAnsi(information.FileName); } - catch (DllNotFoundException) - { - return null; - } - catch (EntryPointNotFoundException) + } +#endif + + /// + /// Returns a non-empty path supplied by the provider, or null when the provider cannot produce one. + /// The provider boundary is deliberately broad because linker metadata is optional and fallback resolution must continue after any managed failure. + /// + internal static string TryGet(Func pathProvider) + { + try { - return null; + string path = pathProvider(); + return string.IsNullOrEmpty(path) ? null : path; } - catch (SEHException) + catch (Exception) { return null; } diff --git a/Runtime/Native/Android/AndroidNativeInitialization.cs b/Runtime/Native/Android/AndroidNativeInitialization.cs index 00253415..35d5716e 100644 --- a/Runtime/Native/Android/AndroidNativeInitialization.cs +++ b/Runtime/Native/Android/AndroidNativeInitialization.cs @@ -4,9 +4,9 @@ namespace Backtrace.Unity.Runtime.Native.Android { /// - /// Coordinates native-backend activation and the managed setup that follows it. - /// The activation callback is invoked before JNI cleanup, - /// a cleanup failure after a successful native initialization still rolls the backend back. + /// Coordinates native-bridge completion and the managed setup that follows it. + /// The completion callback is invoked before JNI cleanup so a false result or any later + /// cleanup or setup failure can roll back possible native side effects. /// internal static class AndroidNativeInitialization { @@ -29,48 +29,62 @@ internal static bool Execute( throw new ArgumentNullException("rollback"); } - bool backendActive = false; + bool nativeBridgeCompleted = false; try { - bool initialized = initialize(() => backendActive = true); + bool initialized = initialize(() => nativeBridgeCompleted = true); if (!initialized) { + if (nativeBridgeCompleted) + { + TryRollback(rollback, reportRollbackFailure); + } + return false; } - // Keep the transaction safe even if an initializer forgets to invoke the early activation callback after returning true. - backendActive = true; + // Keep the transaction safe even if an initializer returns true without invoking the native-bridge completion callback. + nativeBridgeCompleted = true; completeSetup(); return true; } catch { - if (backendActive) + if (nativeBridgeCompleted) { - try - { - rollback(); - } - catch (Exception rollbackFailure) - { - // Rollback is best-effort. Preserve the setup exception, which is the actionable failure, while still allowing a contained diagnostic. - if (reportRollbackFailure != null) - { - try - { - reportRollbackFailure(rollbackFailure); - } - catch (Exception) - { - // Diagnostics must never replace the setup failure. - } - } - } + TryRollback(rollback, reportRollbackFailure); } throw; } } + private static void TryRollback( + Action rollback, + Action reportRollbackFailure) + { + try + { + rollback(); + } + catch (Exception rollbackFailure) + { + // Rollback is best-effort. Preserve the initialization outcome while still allowing a contained diagnostic. + if (reportRollbackFailure == null) + { + return; + } + + try + { + reportRollbackFailure(rollbackFailure); + } + catch (Exception) + { + // Diagnostics must never replace or escape the original initialization outcome. + } + } + } + /// /// Deletes every non-zero JNI local reference in the supplied order. /// A failed deletion does not prevent later references from being released; diff --git a/Runtime/Native/Android/NativeClient.cs b/Runtime/Native/Android/NativeClient.cs index 3191342c..0741f721 100644 --- a/Runtime/Native/Android/NativeClient.cs +++ b/Runtime/Native/Android/NativeClient.cs @@ -1,4 +1,4 @@ -#if UNITY_ANDROID +#if UNITY_ANDROID || UNITY_EDITOR using Backtrace.Unity.Common; using Backtrace.Unity.Extensions; using Backtrace.Unity.Model; @@ -22,6 +22,7 @@ namespace Backtrace.Unity.Runtime.Native.Android internal sealed class NativeClient : NativeClientBase, INativeClient { private const string CallbackMethodName = "OnAnrDetected"; + private const string NativeAttributeFailureCode = "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE"; // The P/Invoke declarations live in AndroidNativeInterop, which also compiles in the Editor and the EditMode signature tests can pin the corrected return types. @@ -31,6 +32,8 @@ internal sealed class NativeClient : NativeClientBase, INativeClient /// private readonly Dictionary _attributeMapping = new Dictionary(); + private readonly Action _nativeAttributeWriter; + private void SetDefaultAttributeMaps() { _attributeMapping.Add("FDSize", "descriptor.count"); @@ -112,6 +115,7 @@ private void SetDefaultAttributeMaps() public string GameObjectName { get; internal set; } public NativeClient(BacktraceConfiguration configuration, BacktraceBreadcrumbs breadcrumbs, IDictionary clientAttributes, IEnumerable attachments, string gameObjectName) : base(configuration, breadcrumbs) { + _nativeAttributeWriter = SetNativeAttribute; GameObjectName = gameObjectName; SetDefaultAttributeMaps(); if (!_enabled) @@ -131,6 +135,23 @@ public NativeClient(BacktraceConfiguration configuration, BacktraceBreadcrumbs b } } + /// + /// Creates an enabled client around an instance-scoped attribute writer. + /// This keeps Editor tests on the concrete OOM path without invoking JNI. + /// + internal NativeClient( + BacktraceConfiguration configuration, + Action nativeAttributeWriter) : base(configuration, null) + { + if (nativeAttributeWriter == null) + { + throw new ArgumentNullException("nativeAttributeWriter"); + } + + _nativeAttributeWriter = nativeAttributeWriter; + CaptureNativeCrashes = true; + } + /// /// Setup communication between Unity and Android to receive information about unhandled thread exceptions /// @@ -315,7 +336,7 @@ private static bool InvokeInitialize( string[] attributeValues, string[] attachments, string[] environmentVariables, - Action markNativeBackendActive) + Action markNativeBridgeCallCompleted) { IntPtr urlRef = IntPtr.Zero; IntPtr databaseRef = IntPtr.Zero; @@ -341,12 +362,9 @@ private static bool InvokeInitialize( valuesRef, attachmentsRef, environmentRef); - if (initialized) - { - // Record activation before local-reference cleanup. - // If that cleanup throws, the caller must still disable the already-active backend. - markNativeBackendActive(); - } + // Record native bridge completion before local-reference cleanup. + // Even a false result can leave native state that the transaction must roll back. + markNativeBridgeCallCompleted(); return initialized; } finally @@ -439,7 +457,11 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt // The authoritative linker answer is queried FIRST: // it must remain usable even when process-ABI detection or application metadata is unavailable, // and both of those lookups are fully fail-safe (they return null/empty instead of throwing). +#if UNITY_ANDROID var loadedLibraryPath = AndroidLoadedLibraryPath.TryGet(); +#else + string loadedLibraryPath = null; +#endif // The ABI of THIS PROCESS (not the device-preferred ABI: a 32-bit process on a 64-bit device differs). // It is required only for the x86 policy and the split/base-APK path fallback, @@ -453,7 +475,11 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt return false; } +#if UNITY_ANDROID var applicationInfo = AndroidApplicationInfoSnapshot.Capture(); +#else + var applicationInfo = new AndroidApplicationInfoSnapshot(); +#endif if (string.IsNullOrEmpty(applicationInfo.NativeLibraryDir)) { // Legacy discovery for hosts without a Unity activity. @@ -478,7 +504,7 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt BuildLibrarySearchPaths(applicationInfo.NativeLibraryDir)); var initialized = AndroidNativeInitialization.Execute( - markNativeBackendActive => InvokeInitialize( + markNativeBridgeCallCompleted => InvokeInitialize( minidumpUrl, databasePath, _crashHandlerPath, @@ -486,7 +512,7 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt new string[0], attachments == null ? new string[0] : attachments.ToArray(), environmentVariables, - markNativeBackendActive), + markNativeBridgeCallCompleted), () => { foreach (var attribute in backtraceAttributes) @@ -512,6 +538,7 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt private static string TryGetProcessAbi() { +#if UNITY_ANDROID try { return AndroidProcessAbi.Capture(); @@ -520,6 +547,9 @@ private static string TryGetProcessAbi() { return null; } +#else + return null; +#endif } private List BuildLibrarySearchPaths(string nativeLibraryDir) @@ -688,7 +718,10 @@ public void SetAttribute(string key, string value) return; } // avoid null reference in crashpad source code - SetNativeAttribute(key, value ?? string.Empty); + NativeAttributeLifecycle.TrySetAttribute( + () => _nativeAttributeWriter(key, value ?? string.Empty), + NativeAttributeFailureCode, + warning => Debug.LogWarning(warning)); } /// diff --git a/Runtime/Native/Base/NativeClientBase.cs b/Runtime/Native/Base/NativeClientBase.cs index df707e31..13b894e3 100644 --- a/Runtime/Native/Base/NativeClientBase.cs +++ b/Runtime/Native/Base/NativeClientBase.cs @@ -1,4 +1,4 @@ -#if UNITY_ANDROID || UNITY_IOS || UNITY_STANDALONE_WIN || UNITY_GAMECORE_XBOXSERIES || UNITY_GAMECORE_XBOXONE || UNITY_STANDALONE_OSX +#if UNITY_ANDROID || UNITY_IOS || UNITY_STANDALONE_WIN || UNITY_GAMECORE_XBOXSERIES || UNITY_GAMECORE_XBOXONE || UNITY_STANDALONE_OSX || UNITY_EDITOR using Backtrace.Unity.Model; using Backtrace.Unity.Model.Breadcrumbs; using Backtrace.Unity.Extensions; @@ -130,4 +130,4 @@ private bool ShouldStoreAnrBreadcrumbs() } } } -#endif \ No newline at end of file +#endif diff --git a/Runtime/Native/NativeAttributeLifecycle.cs b/Runtime/Native/NativeAttributeLifecycle.cs new file mode 100644 index 00000000..a53c80a4 --- /dev/null +++ b/Runtime/Native/NativeAttributeLifecycle.cs @@ -0,0 +1,58 @@ +using System; + +namespace Backtrace.Unity.Runtime.Native +{ + /// + /// Contains optional native attribute writes so platform or diagnostic failures cannot escape into the host game. + /// Delegates keep this policy testable in the Editor. + /// + internal static class NativeAttributeLifecycle + { + internal const string NativeAttributeFailureCode = "BT_UNITY_NATIVE_ATTRIBUTE_FAILURE"; + + internal static bool TrySetAttribute( + Action setAttribute, + string failureCode, + Action logWarning) + { + if (setAttribute == null) + { + throw new ArgumentNullException("setAttribute"); + } + if (string.IsNullOrEmpty(failureCode)) + { + throw new ArgumentException("A native attribute failure code is required", "failureCode"); + } + + try + { + setAttribute(); + return true; + } + catch (Exception exception) + { + LogFailure(failureCode, exception, logWarning); + return false; + } + } + + private static void LogFailure( + string failureCode, + Exception exception, + Action logWarning) + { + var failureType = exception == null ? "unknown" : exception.GetType().FullName; + try + { + if (logWarning != null) + { + logWarning(failureCode + ": Failure type: " + failureType); + } + } + catch (Exception) + { + // Diagnostics are optional and must not let a native attribute failure escape. + } + } + } +} diff --git a/Runtime/Native/NativeAttributeLifecycle.cs.meta b/Runtime/Native/NativeAttributeLifecycle.cs.meta new file mode 100644 index 00000000..89ecaffe --- /dev/null +++ b/Runtime/Native/NativeAttributeLifecycle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9252dbbdc2ea4dd6b819069196326f2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/BacktraceAttributeTests.cs b/Tests/Runtime/BacktraceAttributeTests.cs index 35bf1160..6d77cdfa 100644 --- a/Tests/Runtime/BacktraceAttributeTests.cs +++ b/Tests/Runtime/BacktraceAttributeTests.cs @@ -2,6 +2,7 @@ using Backtrace.Unity.Model; using Backtrace.Unity.Model.Attributes; using Backtrace.Unity.Model.JsonData; +using Backtrace.Unity.Runtime.Native; using NUnit.Framework; using System; using System.Collections; @@ -17,6 +18,41 @@ public class BacktraceAttributeTests : BacktraceBaseTest { private const int CLIENT_RATE_LIMIT = 3; + private sealed class FirstAttributeThrowingNativeClient : INativeClient + { + internal int SetAttributeCalls { get; private set; } + + public void Disable() + { } + + public void GetAttributes(IDictionary attributes) + { } + + public void HandleAnr() + { } + + public bool OnOOM() + { + return false; + } + + public void PauseAnrThread(bool state) + { } + + public void SetAttribute(string key, string value) + { + SetAttributeCalls++; + if (SetAttributeCalls == 1) + { + throw new InvalidOperationException( + "native attribute failed for secret-key=secret-value at /private/data/backtrace https://submit.example.test/token"); + } + } + + public void Update(float time) + { } + } + [SetUp] public void Setup() { @@ -129,6 +165,36 @@ public IEnumerator TesClientAttributesMethod_BacktraceDataShouldIncludeClientAtt yield return null; } + [Test] + public void SetAttributes_NativeClientFailure_DoesNotInterruptManagedAttributes() + { + var nativeClient = new FirstAttributeThrowingNativeClient(); + BacktraceClient.NativeClient = nativeClient; + var attributes = new Dictionary + { + { "first-managed-attribute", "first-value" }, + { "second-managed-attribute", "second-value" } + }; + + Debug.unityLogger.logEnabled = true; + try + { + LogAssert.Expect( + LogType.Warning, + "BT_UNITY_NATIVE_ATTRIBUTE_FAILURE: Failure type: System.InvalidOperationException"); + Assert.DoesNotThrow(() => BacktraceClient.SetAttributes(attributes)); + } + finally + { + Debug.unityLogger.logEnabled = false; + } + + Assert.AreEqual(2, nativeClient.SetAttributeCalls); + var managedAttributes = BacktraceClient.AttributeProvider.GenerateAttributes(false); + Assert.AreEqual("first-value", managedAttributes["first-managed-attribute"]); + Assert.AreEqual("second-value", managedAttributes["second-managed-attribute"]); + } + [UnityTest] public IEnumerator TestAttributesGeneration_CreateCorrectAttributes_WithDiffrentReportConfiguration() diff --git a/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs b/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs new file mode 100644 index 00000000..64bff44b --- /dev/null +++ b/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs @@ -0,0 +1,39 @@ +#if UNITY_ANDROID || UNITY_EDITOR +using System; +using Backtrace.Unity.Runtime.Native.Android; +using NUnit.Framework; + +namespace Backtrace.Unity.Tests.Runtime +{ + /// + /// Tests the optional linker-path provider boundary without loading libdl in the editor. + /// + public class AndroidLoadedLibraryPathTests + { + [Test] + public void ProviderPathIsPreserved() + { + const string expected = "/data/app/example/lib/arm64/libbacktrace-native.so"; + + string result = AndroidLoadedLibraryPath.TryGet(() => expected); + + Assert.AreEqual(expected, result); + } + + [Test] + public void EmptyProviderPathReturnsNull() + { + Assert.IsNull(AndroidLoadedLibraryPath.TryGet(() => string.Empty)); + } + + [Test] + public void UnexpectedProviderExceptionReturnsNull() + { + string result = AndroidLoadedLibraryPath.TryGet( + () => { throw new InvalidOperationException("provider failed"); }); + + Assert.IsNull(result); + } + } +} +#endif diff --git a/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs.meta b/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs.meta new file mode 100644 index 00000000..cf01f4d0 --- /dev/null +++ b/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d9e30f855f9b4e1da0c6146bf6b8a071 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs b/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs new file mode 100644 index 00000000..5adcb530 --- /dev/null +++ b/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs @@ -0,0 +1,63 @@ +#if UNITY_ANDROID || UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Globalization; +using Backtrace.Unity.Model; +using Backtrace.Unity.Runtime.Native.Android; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Backtrace.Unity.Tests.Runtime +{ + public class AndroidNativeClientOomTests + { + [Test] + public void OnOomAttemptsTimestampAfterFirstAttributeWriteFails() + { + var attempts = new List>(); + var configuration = ScriptableObject.CreateInstance(); + try + { + var client = new NativeClient( + configuration, + (key, value) => + { + attempts.Add(new KeyValuePair(key, value)); + if (attempts.Count == 1) + { + throw new InvalidOperationException("first native attribute failed"); + } + }); + + LogAssert.Expect( + LogType.Warning, + "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE: Failure type: System.InvalidOperationException"); + + bool result = false; + Assert.DoesNotThrow(() => { result = client.OnOOM(); }); + + Assert.IsTrue(result); + Assert.AreEqual(2, attempts.Count); + Assert.AreEqual("memory.warning", attempts[0].Key); + Assert.AreEqual("true", attempts[0].Value); + Assert.AreEqual("memory.warning.date", attempts[1].Key); + + int timestamp; + Assert.IsTrue( + int.TryParse( + attempts[1].Value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out timestamp), + "The second attribute must contain an invariant Unix timestamp."); + Assert.Greater(timestamp, 0); + } + finally + { + UnityEngine.Object.DestroyImmediate(configuration); + } + } + } +} +#endif diff --git a/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs.meta b/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs.meta new file mode 100644 index 00000000..a7a14f86 --- /dev/null +++ b/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 093f8f7bbc1c42c9b93611ac4ec1851c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Native/Android/AndroidNativeInitializationTests.cs b/Tests/Runtime/Native/Android/AndroidNativeInitializationTests.cs index 3972a372..a0eec492 100644 --- a/Tests/Runtime/Native/Android/AndroidNativeInitializationTests.cs +++ b/Tests/Runtime/Native/Android/AndroidNativeInitializationTests.cs @@ -9,20 +9,63 @@ namespace Backtrace.Unity.Tests.Runtime public class AndroidNativeInitializationTests { [Test] - public void RejectedInitializationDoesNotCompleteOrRollback() + public void RejectedBeforeNativeBridgeDoesNotRollback() + { + int rollbackCount = 0; + + bool initialized = AndroidNativeInitialization.Execute( + markCompleted => false, + () => Assert.Fail("Completion must not run"), + () => rollbackCount++, + null); + + Assert.IsFalse(initialized); + Assert.AreEqual(0, rollbackCount); + } + + [Test] + public void NativeBridgeFalseResultRollsBackPartialState() { int completionCount = 0; int rollbackCount = 0; bool initialized = AndroidNativeInitialization.Execute( - markActive => false, + markCompleted => + { + markCompleted(); + return false; + }, () => completionCount++, () => rollbackCount++, null); Assert.IsFalse(initialized); Assert.AreEqual(0, completionCount); - Assert.AreEqual(0, rollbackCount); + Assert.AreEqual(1, rollbackCount); + } + + [Test] + public void NativeBridgeFalseResultRollbackFailureIsContained() + { + var rollbackFailure = new InvalidOperationException("sensitive rollback detail"); + Exception reported = null; + + Assert.DoesNotThrow(() => + { + bool initialized = AndroidNativeInitialization.Execute( + markCompleted => + { + markCompleted(); + return false; + }, + () => Assert.Fail("Completion must not run"), + () => { throw rollbackFailure; }, + failure => reported = failure); + + Assert.IsFalse(initialized); + }); + + Assert.AreSame(rollbackFailure, reported); } [Test] diff --git a/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs b/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs new file mode 100644 index 00000000..8247605c --- /dev/null +++ b/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using Backtrace.Unity.Runtime.Native; +using NUnit.Framework; + +namespace Backtrace.Unity.Tests.Runtime +{ + public class NativeAttributeLifecycleTests + { + private const string AndroidFailureCode = "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE"; + + [Test] + public void NativeAttributeFailureIsContainedAndLogsOnlyFailureType() + { + const string sensitiveKey = "secret-key"; + const string sensitiveValue = "secret-value"; + const string sensitivePath = "/private/data/backtrace"; + const string sensitiveUrl = "https://submit.example.test/token"; + var warnings = new List(); + + bool result = true; + Assert.DoesNotThrow(() => result = NativeAttributeLifecycle.TrySetAttribute( + () => { throw new InvalidOperationException( + sensitiveKey + " " + sensitiveValue + " " + sensitivePath + " " + sensitiveUrl); }, + AndroidFailureCode, + warnings.Add)); + + Assert.IsFalse(result); + CollectionAssert.AreEqual( + new[] { "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE: Failure type: System.InvalidOperationException" }, + warnings); + StringAssert.DoesNotContain(sensitiveKey, warnings[0]); + StringAssert.DoesNotContain(sensitiveValue, warnings[0]); + StringAssert.DoesNotContain(sensitivePath, warnings[0]); + StringAssert.DoesNotContain(sensitiveUrl, warnings[0]); + } + + [Test] + public void FailedAttributeDoesNotPreventLaterAttributeOperations() + { + int attempts = 0; + var warnings = new List(); + + bool firstResult = NativeAttributeLifecycle.TrySetAttribute( + () => + { + attempts++; + throw new InvalidOperationException("first attribute failed"); + }, + AndroidFailureCode, + warnings.Add); + + Assert.IsFalse(firstResult); + Assert.AreEqual(1, warnings.Count); + warnings.Clear(); + + bool secondResult = NativeAttributeLifecycle.TrySetAttribute( + () => attempts++, + AndroidFailureCode, + warnings.Add); + + Assert.IsTrue(secondResult); + Assert.AreEqual(2, attempts); + Assert.IsEmpty(warnings, "A successful native attribute write must not log a warning."); + } + + [Test] + public void LoggingFailureDoesNotEscape() + { + Assert.DoesNotThrow(() => NativeAttributeLifecycle.TrySetAttribute( + () => { throw new InvalidOperationException("native attribute failed"); }, + AndroidFailureCode, + warning => { throw new ApplicationException("logger failed"); })); + } + + [Test] + public void ContainedAndroidFailureDoesNotProduceDuplicateGenericWarning() + { + var warnings = new List(); + + bool result = NativeAttributeLifecycle.TrySetAttribute( + () => NativeAttributeLifecycle.TrySetAttribute( + () => { throw new InvalidOperationException("native attribute failed"); }, + AndroidFailureCode, + warnings.Add), + NativeAttributeLifecycle.NativeAttributeFailureCode, + warnings.Add); + + Assert.IsTrue(result); + CollectionAssert.AreEqual( + new[] { "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE: Failure type: System.InvalidOperationException" }, + warnings); + } + } +} diff --git a/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs.meta b/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs.meta new file mode 100644 index 00000000..9bfd1cad --- /dev/null +++ b/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51d47a47036c4df4a934a5da833ee2b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: