diff --git a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs new file mode 100644 index 0000000000..b3863bcf44 --- /dev/null +++ b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using NUnit.Framework; +using osu.Game.Beatmaps.Formats; + +namespace osu.Game.Tests.Beatmaps.Formats +{ + [TestFixture] + public class ParsingTest + { + [Test] + public void TestNaNHandling() => allThrow("NaN"); + + [Test] + public void TestBadStringHandling() => allThrow("Random string 123"); + + [TestCase(Parsing.MAX_PARSE_VALUE)] + [TestCase(-1)] + [TestCase(0)] + [TestCase(1)] + [TestCase(-Parsing.MAX_PARSE_VALUE)] + [TestCase(10, 10)] + [TestCase(-10, 10)] + public void TestValidRanges(double input, double limit = Parsing.MAX_PARSE_VALUE) + { + Assert.AreEqual(Parsing.ParseInt((input).ToString(CultureInfo.InvariantCulture), (int)limit), (int)input); + Assert.AreEqual(Parsing.ParseFloat((input).ToString(CultureInfo.InvariantCulture), (float)limit), (float)input); + Assert.AreEqual(Parsing.ParseDouble((input).ToString(CultureInfo.InvariantCulture), limit), input); + } + + [TestCase(double.PositiveInfinity)] + [TestCase(double.NegativeInfinity)] + [TestCase(999999999999)] + [TestCase(Parsing.MAX_PARSE_VALUE * 1.1)] + [TestCase(-Parsing.MAX_PARSE_VALUE * 1.1)] + [TestCase(11, 10)] + [TestCase(-11, 10)] + public void TestOutOfRangeHandling(double input, double limit = Parsing.MAX_PARSE_VALUE) + => allThrow(input.ToString(CultureInfo.InvariantCulture), limit); + + private void allThrow(string input, double limit = Parsing.MAX_PARSE_VALUE) + where T : Exception + { + Assert.Throws(getIntParseException(input) ?? typeof(T), () => Parsing.ParseInt(input, (int)limit)); + Assert.Throws(() => Parsing.ParseFloat(input, (float)limit)); + Assert.Throws(() => Parsing.ParseDouble(input, limit)); + } + + /// + /// may not be able to parse some inputs. + /// In this case we expect to receive the raw parsing exception. + /// + /// The input attempting to be parsed. + /// The type of exception thrown by . Null if no exception is thrown. + private Type getIntParseException(string input) + { + try + { + var _ = int.Parse(input); + } + catch (Exception e) + { + return e.GetType(); + } + + return null; + } + } +} diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 4f19fbd323..a27126ad9c 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -2,10 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Globalization; using System.IO; using System.Linq; using osu.Framework.IO.File; +using osu.Framework.Logging; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Beatmaps.ControlPoints; @@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps.Formats public static void Register() { - AddDecoder(@"osu file format v", m => new LegacyBeatmapDecoder(int.Parse(m.Split('v').Last()))); + AddDecoder(@"osu file format v", m => new LegacyBeatmapDecoder(Parsing.ParseInt(m.Split('v').Last()))); } /// @@ -104,25 +104,25 @@ namespace osu.Game.Beatmaps.Formats metadata.AudioFile = FileSafety.PathStandardise(pair.Value); break; case @"AudioLeadIn": - beatmap.BeatmapInfo.AudioLeadIn = int.Parse(pair.Value); + beatmap.BeatmapInfo.AudioLeadIn = Parsing.ParseInt(pair.Value); break; case @"PreviewTime": - metadata.PreviewTime = getOffsetTime(int.Parse(pair.Value)); + metadata.PreviewTime = getOffsetTime(Parsing.ParseInt(pair.Value)); break; case @"Countdown": - beatmap.BeatmapInfo.Countdown = int.Parse(pair.Value) == 1; + beatmap.BeatmapInfo.Countdown = Parsing.ParseInt(pair.Value) == 1; break; case @"SampleSet": defaultSampleBank = (LegacySampleBank)Enum.Parse(typeof(LegacySampleBank), pair.Value); break; case @"SampleVolume": - defaultSampleVolume = int.Parse(pair.Value); + defaultSampleVolume = Parsing.ParseInt(pair.Value); break; case @"StackLeniency": - beatmap.BeatmapInfo.StackLeniency = float.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + beatmap.BeatmapInfo.StackLeniency = Parsing.ParseFloat(pair.Value); break; case @"Mode": - beatmap.BeatmapInfo.RulesetID = int.Parse(pair.Value); + beatmap.BeatmapInfo.RulesetID = Parsing.ParseInt(pair.Value); switch (beatmap.BeatmapInfo.RulesetID) { @@ -142,13 +142,13 @@ namespace osu.Game.Beatmaps.Formats break; case @"LetterboxInBreaks": - beatmap.BeatmapInfo.LetterboxInBreaks = int.Parse(pair.Value) == 1; + beatmap.BeatmapInfo.LetterboxInBreaks = Parsing.ParseInt(pair.Value) == 1; break; case @"SpecialStyle": - beatmap.BeatmapInfo.SpecialStyle = int.Parse(pair.Value) == 1; + beatmap.BeatmapInfo.SpecialStyle = Parsing.ParseInt(pair.Value) == 1; break; case @"WidescreenStoryboard": - beatmap.BeatmapInfo.WidescreenStoryboard = int.Parse(pair.Value) == 1; + beatmap.BeatmapInfo.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; break; } } @@ -163,16 +163,16 @@ namespace osu.Game.Beatmaps.Formats beatmap.BeatmapInfo.StoredBookmarks = pair.Value; break; case @"DistanceSpacing": - beatmap.BeatmapInfo.DistanceSpacing = double.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + beatmap.BeatmapInfo.DistanceSpacing = Math.Max(0, Parsing.ParseDouble(pair.Value)); break; case @"BeatDivisor": - beatmap.BeatmapInfo.BeatDivisor = int.Parse(pair.Value); + beatmap.BeatmapInfo.BeatDivisor = Parsing.ParseInt(pair.Value); break; case @"GridSize": - beatmap.BeatmapInfo.GridSize = int.Parse(pair.Value); + beatmap.BeatmapInfo.GridSize = Parsing.ParseInt(pair.Value); break; case @"TimelineZoom": - beatmap.BeatmapInfo.TimelineZoom = double.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + beatmap.BeatmapInfo.TimelineZoom = Math.Max(0, Parsing.ParseDouble(pair.Value)); break; } } @@ -209,10 +209,10 @@ namespace osu.Game.Beatmaps.Formats beatmap.BeatmapInfo.Metadata.Tags = pair.Value; break; case @"BeatmapID": - beatmap.BeatmapInfo.OnlineBeatmapID = int.Parse(pair.Value); + beatmap.BeatmapInfo.OnlineBeatmapID = Parsing.ParseInt(pair.Value); break; case @"BeatmapSetID": - beatmap.BeatmapInfo.BeatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = int.Parse(pair.Value) }; + beatmap.BeatmapInfo.BeatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = Parsing.ParseInt(pair.Value) }; break; } } @@ -225,22 +225,22 @@ namespace osu.Game.Beatmaps.Formats switch (pair.Key) { case @"HPDrainRate": - difficulty.DrainRate = float.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + difficulty.DrainRate = Parsing.ParseFloat(pair.Value); break; case @"CircleSize": - difficulty.CircleSize = float.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + difficulty.CircleSize = Parsing.ParseFloat(pair.Value); break; case @"OverallDifficulty": - difficulty.OverallDifficulty = float.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + difficulty.OverallDifficulty = Parsing.ParseFloat(pair.Value); break; case @"ApproachRate": - difficulty.ApproachRate = float.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + difficulty.ApproachRate = Parsing.ParseFloat(pair.Value); break; case @"SliderMultiplier": - difficulty.SliderMultiplier = double.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + difficulty.SliderMultiplier = Parsing.ParseDouble(pair.Value); break; case @"SliderTickRate": - difficulty.SliderTickRate = double.Parse(pair.Value, NumberFormatInfo.InvariantInfo); + difficulty.SliderTickRate = Parsing.ParseDouble(pair.Value); break; } } @@ -260,10 +260,12 @@ namespace osu.Game.Beatmaps.Formats beatmap.BeatmapInfo.Metadata.BackgroundFile = FileSafety.PathStandardise(filename); break; case EventType.Break: + double start = getOffsetTime(Parsing.ParseDouble(split[1])); + var breakEvent = new BreakPeriod { - StartTime = getOffsetTime(double.Parse(split[1], NumberFormatInfo.InvariantInfo)), - EndTime = getOffsetTime(double.Parse(split[2], NumberFormatInfo.InvariantInfo)) + StartTime = start, + EndTime = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))) }; if (!breakEvent.HasEffect) @@ -280,25 +282,25 @@ namespace osu.Game.Beatmaps.Formats { string[] split = line.Split(','); - double time = getOffsetTime(double.Parse(split[0].Trim(), NumberFormatInfo.InvariantInfo)); - double beatLength = double.Parse(split[1].Trim(), NumberFormatInfo.InvariantInfo); + double time = getOffsetTime(Parsing.ParseDouble(split[0].Trim())); + double beatLength = Parsing.ParseDouble(split[1].Trim()); double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1; TimeSignatures timeSignature = TimeSignatures.SimpleQuadruple; if (split.Length >= 3) - timeSignature = split[2][0] == '0' ? TimeSignatures.SimpleQuadruple : (TimeSignatures)int.Parse(split[2]); + timeSignature = split[2][0] == '0' ? TimeSignatures.SimpleQuadruple : (TimeSignatures)Parsing.ParseInt(split[2]); LegacySampleBank sampleSet = defaultSampleBank; if (split.Length >= 4) - sampleSet = (LegacySampleBank)int.Parse(split[3]); + sampleSet = (LegacySampleBank)Parsing.ParseInt(split[3]); int customSampleBank = 0; if (split.Length >= 5) - customSampleBank = int.Parse(split[4]); + customSampleBank = Parsing.ParseInt(split[4]); int sampleVolume = defaultSampleVolume; if (split.Length >= 6) - sampleVolume = int.Parse(split[5]); + sampleVolume = Parsing.ParseInt(split[5]); bool timingChange = true; if (split.Length >= 7) @@ -308,7 +310,7 @@ namespace osu.Game.Beatmaps.Formats bool omitFirstBarSignature = false; if (split.Length >= 8) { - EffectFlags effectFlags = (EffectFlags)int.Parse(split[7]); + EffectFlags effectFlags = (EffectFlags)Parsing.ParseInt(split[7]); kiaiMode = effectFlags.HasFlag(EffectFlags.Kiai); omitFirstBarSignature = effectFlags.HasFlag(EffectFlags.OmitFirstBarLine); } @@ -348,8 +350,13 @@ namespace osu.Game.Beatmaps.Formats CustomSampleBank = customSampleBank }); } - catch (FormatException e) + catch (FormatException) { + Logger.Log("A timing point could not be parsed correctly and will be ignored", LoggingTarget.Runtime, LogLevel.Important); + } + catch (OverflowException) + { + Logger.Log("A timing point could not be parsed correctly and will be ignored", LoggingTarget.Runtime, LogLevel.Important); } } diff --git a/osu.Game/Beatmaps/Formats/Parsing.cs b/osu.Game/Beatmaps/Formats/Parsing.cs new file mode 100644 index 0000000000..c3efb8c760 --- /dev/null +++ b/osu.Game/Beatmaps/Formats/Parsing.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; + +namespace osu.Game.Beatmaps.Formats +{ + /// + /// Helper methods to parse from string to number and perform very basic validation. + /// + public static class Parsing + { + public const int MAX_COORDINATE_VALUE = 65536; + + public const double MAX_PARSE_VALUE = int.MaxValue; + + public static float ParseFloat(string input, float parseLimit = (float)MAX_PARSE_VALUE) + { + var output = float.Parse(input, CultureInfo.InvariantCulture); + + if (output < -parseLimit) throw new OverflowException("Value is too low"); + if (output > parseLimit) throw new OverflowException("Value is too high"); + + if (float.IsNaN(output)) throw new FormatException("Not a number"); + + return output; + } + + public static double ParseDouble(string input, double parseLimit = MAX_PARSE_VALUE) + { + var output = double.Parse(input, CultureInfo.InvariantCulture); + + if (output < -parseLimit) throw new OverflowException("Value is too low"); + if (output > parseLimit) throw new OverflowException("Value is too high"); + + if (double.IsNaN(output)) throw new FormatException("Not a number"); + + return output; + } + + public static int ParseInt(string input, int parseLimit = (int)MAX_PARSE_VALUE) + { + var output = int.Parse(input, CultureInfo.InvariantCulture); + + if (output < -parseLimit) throw new OverflowException("Value is too low"); + if (output > parseLimit) throw new OverflowException("Value is too high"); + + return output; + } + } +} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index e397139843..8d6bb8bd3f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -5,7 +5,6 @@ using osuTK; using osu.Game.Rulesets.Objects.Types; using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using osu.Game.Beatmaps.Formats; using osu.Game.Audio; @@ -46,9 +45,11 @@ namespace osu.Game.Rulesets.Objects.Legacy { string[] split = text.Split(','); - Vector2 pos = new Vector2((int)Convert.ToSingle(split[0], CultureInfo.InvariantCulture), (int)Convert.ToSingle(split[1], CultureInfo.InvariantCulture)); + Vector2 pos = new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)); - ConvertHitObjectType type = (ConvertHitObjectType)int.Parse(split[3]); + double startTime = Parsing.ParseDouble(split[2]) + Offset; + + ConvertHitObjectType type = (ConvertHitObjectType)Parsing.ParseInt(split[3]); int comboOffset = (int)(type & ConvertHitObjectType.ComboOffset) >> 4; type &= ~ConvertHitObjectType.ComboOffset; @@ -56,7 +57,7 @@ namespace osu.Game.Rulesets.Objects.Legacy bool combo = type.HasFlag(ConvertHitObjectType.NewCombo); type &= ~ConvertHitObjectType.NewCombo; - var soundType = (LegacySoundType)int.Parse(split[4]); + var soundType = (LegacySoundType)Parsing.ParseInt(split[4]); var bankInfo = new SampleBankInfo(); HitObject result = null; @@ -107,7 +108,7 @@ namespace osu.Game.Rulesets.Objects.Legacy } string[] temp = t.Split(':'); - points[pointIndex++] = new Vector2((int)Convert.ToDouble(temp[0], CultureInfo.InvariantCulture), (int)Convert.ToDouble(temp[1], CultureInfo.InvariantCulture)) - pos; + points[pointIndex++] = new Vector2((int)Parsing.ParseDouble(temp[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseDouble(temp[1], Parsing.MAX_COORDINATE_VALUE)) - pos; } // osu-stable special-cased colinear perfect curves to a CurveType.Linear @@ -116,7 +117,7 @@ namespace osu.Game.Rulesets.Objects.Legacy if (points.Length == 3 && pathType == PathType.PerfectCurve && isLinear(points)) pathType = PathType.Linear; - int repeatCount = Convert.ToInt32(split[6], CultureInfo.InvariantCulture); + int repeatCount = Parsing.ParseInt(split[6]); if (repeatCount > 9000) throw new ArgumentOutOfRangeException(nameof(repeatCount), @"Repeat count is way too high"); @@ -125,7 +126,7 @@ namespace osu.Game.Rulesets.Objects.Legacy repeatCount = Math.Max(0, repeatCount - 1); if (split.Length > 7) - length = Convert.ToDouble(split[7], CultureInfo.InvariantCulture); + length = Math.Max(0, Parsing.ParseDouble(split[7])); if (split.Length > 10) readCustomSampleBanks(split[10], bankInfo); @@ -184,7 +185,9 @@ namespace osu.Game.Rulesets.Objects.Legacy } else if (type.HasFlag(ConvertHitObjectType.Spinner)) { - result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, Convert.ToDouble(split[5], CultureInfo.InvariantCulture) + Offset); + double endTime = Math.Max(startTime, Parsing.ParseDouble(split[5]) + Offset); + + result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, endTime); if (split.Length > 6) readCustomSampleBanks(split[6], bankInfo); @@ -193,12 +196,12 @@ namespace osu.Game.Rulesets.Objects.Legacy { // Note: Hold is generated by BMS converts - double endTime = Convert.ToDouble(split[2], CultureInfo.InvariantCulture); + double endTime = Math.Max(startTime, Parsing.ParseDouble(split[2])); if (split.Length > 5 && !string.IsNullOrEmpty(split[5])) { string[] ss = split[5].Split(':'); - endTime = Convert.ToDouble(ss[0], CultureInfo.InvariantCulture); + endTime = Math.Max(startTime, Parsing.ParseDouble(ss[0])); readCustomSampleBanks(string.Join(":", ss.Skip(1)), bankInfo); } @@ -211,7 +214,7 @@ namespace osu.Game.Rulesets.Objects.Legacy return null; } - result.StartTime = Convert.ToDouble(split[2], CultureInfo.InvariantCulture) + Offset; + result.StartTime = startTime; if (result.Samples.Count == 0) result.Samples = convertSoundType(soundType, bankInfo); @@ -222,8 +225,14 @@ namespace osu.Game.Rulesets.Objects.Legacy } catch (FormatException) { - throw new FormatException("One or more hit objects were malformed."); + Logger.Log("A hitobject could not be parsed correctly and will be ignored", LoggingTarget.Runtime, LogLevel.Important); } + catch (OverflowException) + { + Logger.Log("A hitobject could not be parsed correctly and will be ignored", LoggingTarget.Runtime, LogLevel.Important); + } + + return null; } private void readCustomSampleBanks(string str, SampleBankInfo bankInfo) @@ -233,8 +242,8 @@ namespace osu.Game.Rulesets.Objects.Legacy string[] split = str.Split(':'); - var bank = (LegacyBeatmapDecoder.LegacySampleBank)int.Parse(split[0]); - var addbank = (LegacyBeatmapDecoder.LegacySampleBank)int.Parse(split[1]); + var bank = (LegacyBeatmapDecoder.LegacySampleBank)Parsing.ParseInt(split[0]); + var addbank = (LegacyBeatmapDecoder.LegacySampleBank)Parsing.ParseInt(split[1]); string stringBank = bank.ToString().ToLowerInvariant(); if (stringBank == @"none") @@ -247,10 +256,10 @@ namespace osu.Game.Rulesets.Objects.Legacy bankInfo.Add = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; if (split.Length > 2) - bankInfo.CustomSampleBank = int.Parse(split[2]); + bankInfo.CustomSampleBank = Parsing.ParseInt(split[2]); if (split.Length > 3) - bankInfo.Volume = int.Parse(split[3]); + bankInfo.Volume = Math.Max(0, Parsing.ParseInt(split[3])); bankInfo.Filename = split.Length > 4 ? split[4] : null; }