diff --git a/build.sh b/build.sh index 5fd89109d..4c675586a 100755 --- a/build.sh +++ b/build.sh @@ -196,9 +196,9 @@ PackageMono() echo "Adding CurlSharp.dll.config (for dllmap)" cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $outputFolderLinux - echo "Adding unix System.Runtime.InteropServices.RuntimeInformation.dll (for SharpRaven)" - cp $sourceFolder/packages/System.Runtime.InteropServices.RuntimeInformation.4.3.0/runtimes/unix/lib/netstandard1.1/System.Runtime.InteropServices.RuntimeInformation.dll $outputFolderLinux - cp $sourceFolder/packages/System.Runtime.InteropServices.RuntimeInformation.4.3.0/runtimes/unix/lib/netstandard1.1/System.Runtime.InteropServices.RuntimeInformation.dll $outputFolderLinux/Sonarr.Update + # Is blacklisted by mono from loading from appdir, instead loading from mono GAC. + echo "Remove System.Runtime.InteropServices.RuntimeInformation.dll (uses win32 interop)" + rm $outputFolderLinux/System.Runtime.InteropServices.RuntimeInformation.dll echo "Renaming Sonarr.Console.exe to Sonarr.exe" rm $outputFolderLinux/Sonarr.exe* diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs new file mode 100644 index 000000000..70c701b74 --- /dev/null +++ b/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using FluentAssertions; +using NzbDrone.Common.Instrumentation.Sentry; +using System; +using NLog; +using NzbDrone.Test.Common; +using System.Globalization; +using System.Linq; + +namespace NzbDrone.Common.Test.InstrumentationTests +{ + [TestFixture] + public class SentryTargetFixture : TestBase + { + private SentryTarget Subject; + + private static LogLevel[] AllLevels = LogLevel.AllLevels.ToArray(); + private static LogLevel[] SentryLevels = LogLevel.AllLevels.Where(x => x >= LogLevel.Error).ToArray(); + private static LogLevel[] OtherLevels = AllLevels.Except(SentryLevels).ToArray(); + + private static Exception[] FilteredExceptions = new Exception[] { + new UnauthorizedAccessException(), + new TinyIoC.TinyIoCResolutionException(typeof(string)) + }; + + [SetUp] + public void Setup() + { + Subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111"); + } + + private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message) + { + return LogEventInfo.Create(level, "SentryTest", ex, CultureInfo.InvariantCulture, message); + } + + [Test, TestCaseSource("AllLevels")] + public void log_without_error_is_not_sentry_event(LogLevel level) + { + Subject.IsSentryMessage(GivenLogEvent(level, null, "test")).Should().BeFalse(); + } + + [Test, TestCaseSource("SentryLevels")] + public void error_or_worse_with_exception_is_sentry_event(LogLevel level) + { + Subject.IsSentryMessage(GivenLogEvent(level, new Exception(), "test")).Should().BeTrue(); + } + + [Test, TestCaseSource("OtherLevels")] + public void less_than_error_with_exception_is_not_sentry_event(LogLevel level) + { + Subject.IsSentryMessage(GivenLogEvent(level, new Exception(), "test")).Should().BeFalse(); + } + + [Test, TestCaseSource("FilteredExceptions")] + public void should_filter_event_for_filtered_exception_types(Exception ex) + { + var log = GivenLogEvent(LogLevel.Error, ex, "test"); + Subject.IsSentryMessage(log).Should().BeFalse(); + } + + [Test, TestCaseSource("FilteredExceptions")] + public void should_not_filter_event_for_filtered_exception_types_if_filtering_disabled(Exception ex) + { + Subject.FilterEvents = false; + var log = GivenLogEvent(LogLevel.Error, ex, "test"); + Subject.IsSentryMessage(log).Should().BeTrue(); + } + + [Test, TestCaseSource(typeof(SentryTarget), "FilteredExceptionMessages")] + public void should_filter_event_for_filtered_exception_messages(string message) + { + var log = GivenLogEvent(LogLevel.Error, new Exception("aaaaaaa" + message + "bbbbbbb"), "test"); + Subject.IsSentryMessage(log).Should().BeFalse(); + } + + [TestCase("A message that isn't filtered")] + [TestCase("Error")] + public void should_not_filter_event_for_exception_messages_that_are_not_filtered(string message) + { + var log = GivenLogEvent(LogLevel.Error, new Exception(message), "test"); + Subject.IsSentryMessage(log).Should().BeTrue(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/MachineNameUserFactory.cs b/src/NzbDrone.Common/Instrumentation/Sentry/MachineNameUserFactory.cs deleted file mode 100644 index 59e892542..000000000 --- a/src/NzbDrone.Common/Instrumentation/Sentry/MachineNameUserFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SharpRaven.Data; - -namespace NzbDrone.Common.Instrumentation.Sentry -{ - public class MachineNameUserFactory : ISentryUserFactory - { - public SentryUser Create() - { - return new SentryUser(HashUtil.AnonymousToken()); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs new file mode 100644 index 000000000..7d351a55a --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using Sentry; +using Sentry.Protocol; + +namespace NzbDrone.Common.Instrumentation.Sentry +{ + public static class SentryCleanser + { + public static SentryEvent CleanseEvent(SentryEvent sentryEvent) + { + try + { + sentryEvent.Message = CleanseLogMessage.Cleanse(sentryEvent.Message); + + if (sentryEvent.Fingerprint != null) + { + var fingerprint = sentryEvent.Fingerprint.Select(x => CleanseLogMessage.Cleanse(x)).ToList(); + sentryEvent.SetFingerprint(fingerprint); + } + + if (sentryEvent.Extra != null) + { + var extras = sentryEvent.Extra.ToDictionary(x => x.Key, y => (object)CleanseLogMessage.Cleanse((string)y.Value)); + sentryEvent.SetExtras(extras); + } + + foreach (var exception in sentryEvent.SentryExceptions) + { + exception.Value = CleanseLogMessage.Cleanse(exception.Value); + foreach (var frame in exception.Stacktrace.Frames) + { + frame.FileName = ShortenPath(frame.FileName); + } + } + } + catch (Exception) + { + + } + + return sentryEvent; + } + + public static Breadcrumb CleanseBreadcrumb(Breadcrumb b) + { + try + { + var message = CleanseLogMessage.Cleanse(b.Message); + var data = b.Data?.ToDictionary(x => x.Key, y => CleanseLogMessage.Cleanse(y.Value)); + return new Breadcrumb(message, b.Type, data, b.Category, b.Level); + } + catch(Exception) + { + + } + + return b; + } + + private static string ShortenPath(string path) + { + + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + // the paths in the stacktrace depend on where it was compiled, + // not the current OS + var rootDirs = new [] { "\\src\\", "/src/" }; + foreach (var rootDir in rootDirs) + { + var index = path.IndexOf(rootDir, StringComparison.Ordinal); + + if (index > 0) + { + return path.Substring(index + rootDir.Length - 1); + } + } + + return path; + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryPacketCleanser.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryPacketCleanser.cs deleted file mode 100644 index 0815c0d48..000000000 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryPacketCleanser.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Newtonsoft.Json.Linq; - -namespace NzbDrone.Common.Instrumentation.Sentry -{ - public class SentryPacketCleanser - { - public void CleansePacket(SonarrSentryPacket packet) - { - packet.Message = CleanseLogMessage.Cleanse(packet.Message); - - if (packet.Fingerprint != null) - { - for (var i = 0; i < packet.Fingerprint.Length; i++) - { - packet.Fingerprint[i] = CleanseLogMessage.Cleanse(packet.Fingerprint[i]); - } - } - - if (packet.Extra != null) - { - var target = JObject.FromObject(packet.Extra); - new CleansingJsonVisitor().Visit(target); - packet.Extra = target; - } - } - } -} diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs index af629df16..5866ad8f3 100644 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs @@ -4,52 +4,123 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading; +using System.Data.SQLite; using NLog; using NLog.Common; using NLog.Targets; using NzbDrone.Common.EnvironmentInfo; -using SharpRaven; -using SharpRaven.Data; +using Sentry; +using Sentry.Protocol; namespace NzbDrone.Common.Instrumentation.Sentry { [Target("Sentry")] public class SentryTarget : TargetWithLayout { - private readonly RavenClient _client; - - private static readonly IDictionary LoggingLevelMap = new Dictionary - { - {LogLevel.Debug, ErrorLevel.Debug}, - {LogLevel.Error, ErrorLevel.Error}, - {LogLevel.Fatal, ErrorLevel.Fatal}, - {LogLevel.Info, ErrorLevel.Info}, - {LogLevel.Trace, ErrorLevel.Debug}, - {LogLevel.Warn, ErrorLevel.Warning}, + // don't report uninformative SQLite exceptions + // busy/locked are benign https://forums.sonarr.tv/t/owin-sqlite-error-5-database-is-locked/5423/11 + // The others will be user configuration problems and silt up Sentry + private static readonly HashSet FilteredSQLiteErrors = new HashSet { + SQLiteErrorCode.Busy, + SQLiteErrorCode.Locked, + SQLiteErrorCode.Perm, + SQLiteErrorCode.ReadOnly, + SQLiteErrorCode.IoErr, + SQLiteErrorCode.Corrupt, + SQLiteErrorCode.Full, + SQLiteErrorCode.CantOpen, + SQLiteErrorCode.Auth }; + // use string and not Type so we don't need a reference to the project + // where these are defined + private static readonly HashSet FilteredExceptionTypeNames = new HashSet { + // UnauthorizedAccessExceptions will just be user configuration issues + "UnauthorizedAccessException", + // Filter out people stuck in boot loops + "CorruptDatabaseException", + // This also filters some people in boot loops + "TinyIoCResolutionException" + }; + + public static readonly List FilteredExceptionMessages = new List { + // Swallow the many, many exceptions flowing through from Jackett + "Jackett.Common.IndexerException" + }; + + // exception types in this list will additionally have the exception message added to the + // sentry fingerprint. Make sure that this message doesn't vary by exception + // (e.g. containing a path or a url) so that the sentry grouping is sensible + private static readonly HashSet IncludeExceptionMessageTypes = new HashSet { + "SQLiteException" + }; + + private static readonly IDictionary LoggingLevelMap = new Dictionary + { + {LogLevel.Debug, SentryLevel.Debug}, + {LogLevel.Error, SentryLevel.Error}, + {LogLevel.Fatal, SentryLevel.Fatal}, + {LogLevel.Info, SentryLevel.Info}, + {LogLevel.Trace, SentryLevel.Debug}, + {LogLevel.Warn, SentryLevel.Warning}, + }; + + private static readonly IDictionary BreadcrumbLevelMap = new Dictionary + { + {LogLevel.Debug, BreadcrumbLevel.Debug}, + {LogLevel.Error, BreadcrumbLevel.Error}, + {LogLevel.Fatal, BreadcrumbLevel.Critical}, + {LogLevel.Info, BreadcrumbLevel.Info}, + {LogLevel.Trace, BreadcrumbLevel.Debug}, + {LogLevel.Warn, BreadcrumbLevel.Warning}, + }; + + private readonly IDisposable _sdk; + private bool _disposed; + private readonly SentryDebounce _debounce; private bool _unauthorized; - + public bool FilterEvents { get; set; } + public string UpdateBranch { get; set; } + public Version DatabaseVersion { get; set; } + public int DatabaseMigration { get; set; } + public SentryTarget(string dsn) { - _client = new RavenClient(new Dsn(dsn), new SonarrJsonPacketFactory(), new SentryRequestFactory(), new MachineNameUserFactory()) - { - Compression = true, - Environment = RuntimeInfo.IsProduction ? "production" : "development", - Release = BuildInfo.Release, - ErrorOnCapture = OnError - }; - - - _client.Tags.Add("osfamily", OsInfo.Os.ToString()); - _client.Tags.Add("runtime", PlatformInfo.PlatformName); - _client.Tags.Add("culture", Thread.CurrentThread.CurrentCulture.Name); - _client.Tags.Add("branch", BuildInfo.Branch); - _client.Tags.Add("version", BuildInfo.Version.ToString()); + _sdk = SentrySdk.Init(o => + { + o.Dsn = new Dsn(dsn); + o.AttachStacktrace = true; + o.MaxBreadcrumbs = 200; + o.SendDefaultPii = true; + o.Debug = false; + o.DiagnosticsLevel = SentryLevel.Debug; + o.Release = BuildInfo.Release; + o.BeforeSend = x => SentryCleanser.CleanseEvent(x); + o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x); + }); + SentrySdk.ConfigureScope(scope => + { + scope.User = new User { + Username = HashUtil.AnonymousToken() + }; + + scope.SetTag("osfamily", OsInfo.Os.ToString()); + scope.SetTag("runtime", PlatformInfo.PlatformName); + scope.SetTag("culture", Thread.CurrentThread.CurrentCulture.Name); + scope.SetTag("branch", BuildInfo.Branch); + scope.SetTag("version", BuildInfo.Version.ToString()); + scope.SetTag("production", RuntimeInfo.IsProduction.ToString()); + }); + _debounce = new SentryDebounce(); + + // initialize to true and reconfigure later + // Otherwise it will default to false and any errors occuring + // before config file gets read will not be filtered + FilterEvents = true; } private void OnError(Exception ex) @@ -79,28 +150,31 @@ namespace NzbDrone.Common.Instrumentation.Sentry var fingerPrint = new List { - logEvent.Level.Ordinal.ToString(), - logEvent.LoggerName + logEvent.Level.ToString(), + logEvent.LoggerName, + logEvent.Message }; var ex = logEvent.Exception; if (ex != null) { - var exception = ex.GetType().Name; - + fingerPrint.Add(ex.GetType().FullName); + fingerPrint.Add(ex.TargetSite.ToString()); if (ex.InnerException != null) { - exception += ex.InnerException.GetType().Name; + fingerPrint.Add(ex.InnerException.GetType().FullName); + } + else if (IncludeExceptionMessageTypes.Contains(ex.GetType().Name)) + { + fingerPrint.Add(ex?.Message); } - - fingerPrint.Add(exception); } return fingerPrint; } - private bool IsSentryMessage(LogEventInfo logEvent) + public bool IsSentryMessage(LogEventInfo logEvent) { if (logEvent.Properties.ContainsKey("Sentry")) { @@ -109,6 +183,24 @@ namespace NzbDrone.Common.Instrumentation.Sentry if (logEvent.Level >= LogLevel.Error && logEvent.Exception != null) { + if (FilterEvents) + { + if (logEvent.Exception is SQLiteException sqliteException && FilteredSQLiteErrors.Contains(sqliteException.ResultCode)) + { + return false; + } + + if (FilteredExceptionTypeNames.Contains(logEvent.Exception.GetType().Name)) + { + return false; + } + + if (FilteredExceptionMessages.Any(x => logEvent.Exception.Message.Contains(x))) + { + return false; + } + } + return true; } @@ -125,6 +217,8 @@ namespace NzbDrone.Common.Instrumentation.Sentry try { + SentrySdk.AddBreadcrumb(logEvent.FormattedMessage, logEvent.LoggerName, level: BreadcrumbLevelMap[logEvent.Level]); + // don't report non-critical events without exceptions if (!IsSentryMessage(logEvent)) { @@ -137,9 +231,8 @@ namespace NzbDrone.Common.Instrumentation.Sentry return; } - var extras = logEvent.Properties.ToDictionary(x => x.Key.ToString(), x => x.Value.ToString()); + var extras = logEvent.Properties.ToDictionary(x => x.Key.ToString(), x => (object)x.Value.ToString()); extras.Remove("Sentry"); - _client.Logger = logEvent.LoggerName; if (logEvent.Exception != null) { @@ -149,46 +242,58 @@ namespace NzbDrone.Common.Instrumentation.Sentry } } - var sentryMessage = new SentryMessage(logEvent.Message, logEvent.Parameters); - var sentryEvent = new SentryEvent(logEvent.Exception) { Level = LoggingLevelMap[logEvent.Level], - Message = sentryMessage, - Extra = extras, - Fingerprint = - { - logEvent.Level.ToString(), - logEvent.LoggerName, - logEvent.Message - } + Logger = logEvent.LoggerName, + Message = logEvent.FormattedMessage, + Environment = UpdateBranch }; - if (logEvent.Exception != null) - { - sentryEvent.Fingerprint.Add(logEvent.Exception.GetType().FullName); - } - - if (logEvent.Properties.ContainsKey("Sentry")) - { - sentryEvent.Fingerprint.Clear(); - Array.ForEach((string[])logEvent.Properties["Sentry"], sentryEvent.Fingerprint.Add); - } + sentryEvent.SetExtras(extras); + sentryEvent.SetFingerprint(fingerPrint); + // this can't be in the constructor as at that point OsInfo won't have + // populated these values yet var osName = Environment.GetEnvironmentVariable("OS_NAME"); var osVersion = Environment.GetEnvironmentVariable("OS_VERSION"); var runTimeVersion = Environment.GetEnvironmentVariable("RUNTIME_VERSION"); - sentryEvent.Tags.Add("os_name", osName); - sentryEvent.Tags.Add("os_version", $"{osName} {osVersion}"); - sentryEvent.Tags.Add("runtime_version", $"{PlatformInfo.PlatformName} {runTimeVersion}"); + sentryEvent.SetTag("os_name", osName); + sentryEvent.SetTag("os_version", $"{osName} {osVersion}"); + sentryEvent.SetTag("runtime_version", $"{PlatformInfo.PlatformName} {runTimeVersion}"); + if (DatabaseVersion != default(Version)) + { + sentryEvent.SetTag("sqlite_version", $"{DatabaseVersion}"); + } - _client.Capture(sentryEvent); + SentrySdk.CaptureEvent(sentryEvent); } catch (Exception e) { OnError(e); } } + + // https://stackoverflow.com/questions/2496311/implementing-idisposable-on-a-subclass-when-the-parent-also-implements-idisposab + protected override void Dispose(bool disposing) + { + // Only do something if we're not already disposed + if (_disposed) + { + // If disposing == true, we're being called from a call to base.Dispose(). In this case, we Dispose() our logger + // If we're being called from a finalizer, our logger will get finalized as well, so no need to do anything. + if (disposing) + { + _sdk?.Dispose(); + } + // Flag us as disposed. This allows us to handle multiple calls to Dispose() as well as ObjectDisposedException + _disposed = true; + } + + // This should always be safe to call multiple times! + // We could include it in the check for disposed above, but I left it out to demonstrate that it's safe + base.Dispose(disposing); + } } } diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs deleted file mode 100644 index 3ba6b499c..000000000 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Collections.Generic; -using SharpRaven.Data; - -namespace NzbDrone.Common.Instrumentation.Sentry -{ - public class SonarrJsonPacketFactory : IJsonPacketFactory - { - private readonly SentryPacketCleanser _cleanser; - - public SonarrJsonPacketFactory() - { - _cleanser = new SentryPacketCleanser(); - } - - private static string ShortenPath(string path) - { - - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - var index = path.IndexOf("\\src\\", StringComparison.Ordinal); - - if (index <= 0) - { - return path; - } - - return path.Substring(index + "\\src".Length); - } - - public JsonPacket Create(string project, SentryEvent @event) - { - var packet = new SonarrSentryPacket(project, @event); - - try - { - foreach (var exception in packet.Exceptions) - { - foreach (var frame in exception.Stacktrace.Frames) - { - frame.Filename = ShortenPath(frame.Filename); - } - } - - _cleanser.CleansePacket(packet); - } - catch (Exception) - { - - } - - return packet; - } - - [Obsolete] - public JsonPacket Create(string project, SentryMessage message, ErrorLevel level = ErrorLevel.Info, IDictionary tags = null, - string[] fingerprint = null, object extra = null) - { - throw new NotImplementedException(); - } - - [Obsolete] - public JsonPacket Create(string project, Exception exception, SentryMessage message = null, ErrorLevel level = ErrorLevel.Error, - IDictionary tags = null, string[] fingerprint = null, object extra = null) - { - throw new NotImplementedException(); - } - } -} diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrSentryPacket.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SonarrSentryPacket.cs deleted file mode 100644 index 8dbfba818..000000000 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrSentryPacket.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Newtonsoft.Json; -using SharpRaven.Data; - -namespace NzbDrone.Common.Instrumentation.Sentry -{ - public class SonarrSentryPacket : JsonPacket - { - private readonly JsonSerializerSettings _setting; - - public SonarrSentryPacket(string project, SentryEvent @event) : - base(project, @event) - { - _setting = new JsonSerializerSettings - { - DefaultValueHandling = DefaultValueHandling.Ignore - }; - } - - public override string ToString(Formatting formatting) - { - return JsonConvert.SerializeObject(this, formatting, _setting); - } - - public override string ToString() - { - return ToString(Formatting.None); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Common/Sonarr.Common.csproj b/src/NzbDrone.Common/Sonarr.Common.csproj index 9af4058c1..4c9e3c62c 100644 --- a/src/NzbDrone.Common/Sonarr.Common.csproj +++ b/src/NzbDrone.Common/Sonarr.Common.csproj @@ -7,7 +7,7 @@ - + @@ -17,6 +17,9 @@ + + ..\Libraries\Sqlite\System.Data.SQLite.dll + diff --git a/src/NzbDrone.Libraries.Test/RuntimeInformationFixture.cs b/src/NzbDrone.Libraries.Test/RuntimeInformationFixture.cs new file mode 100644 index 000000000..50f1c6548 --- /dev/null +++ b/src/NzbDrone.Libraries.Test/RuntimeInformationFixture.cs @@ -0,0 +1,24 @@ +using System.Runtime.InteropServices; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Test.Common; + +namespace NzbDrone.Libraries.Test +{ + [TestFixture] + public class RuntimeInformationFixture : TestBase + { + [Test] + public void should_report_correct_osplatform() + { + var isWindows = OsInfo.IsWindows; + var isLinux = OsInfo.IsLinux; + var isOsx = OsInfo.IsOsx; + + RuntimeInformation.IsOSPlatform(OSPlatform.Windows).Should().Be(isWindows); + RuntimeInformation.IsOSPlatform(OSPlatform.Linux).Should().Be(isLinux && !isOsx); + RuntimeInformation.IsOSPlatform(OSPlatform.OSX).Should().Be(isOsx); + } + } +}