diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 000000000..be251508e --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "sharpfuzz.commandline": { + "version": "2.3.0", + "commands": [ + "sharpfuzz" + ] + } + } +} diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 000000000..87227ae6d --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,98 @@ +name: fuzz + +# Coverage-guided fuzzing of the parsing surface. Not a pull request check: a useful run takes +# minutes, and the corpus it builds up is what makes later runs find anything. See +# csharp/PhoneNumbers.Fuzz/README.md for running the same thing locally. + +on: + schedule: + # Sundays, offset from the other scheduled jobs in this repo. + - cron: '15 4 * * 0' + workflow_dispatch: + inputs: + duration: + description: 'Seconds to fuzz for' + required: false + default: '900' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + fuzz: + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + env: + DURATION: ${{ github.event.inputs.duration || '900' }} + PUBLISH_DIR: fuzz-out + CORPUS_DIR: csharp/PhoneNumbers.Fuzz/corpus + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.x + + # The bridge that lets libFuzzer drive a managed target. Built from source rather than taken + # from the release page: the releases are x64 and these runners are arm64. + - name: Build libfuzzer-dotnet + run: | + if ! command -v clang > /dev/null + then + sudo apt-get update + sudo apt-get install -y clang + fi + git clone --no-checkout https://github.com/Metalnem/libfuzzer-dotnet libfuzzer-dotnet-src + git -C libfuzzer-dotnet-src checkout bd39d4e88d715ab460a929943645be2a186cde52 + clang -fsanitize=fuzzer libfuzzer-dotnet-src/libfuzzer-dotnet.cc -o libfuzzer-dotnet + + # Findings come from the corpus growing across runs, so carry it forward. The key is unique + # per run because a cache entry cannot be overwritten; restore-keys picks the newest prefix + # match on the way in. + - name: Restore corpus + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: corpus-cache + key: fuzz-corpus-${{ github.run_id }} + restore-keys: | + fuzz-corpus- + + - name: Publish the fuzz target + run: dotnet publish csharp/PhoneNumbers.Fuzz -c Release -o "${PUBLISH_DIR}" + + # Only the library under test is instrumented: coverage of the harness, SharpFuzz or dnlib + # would just be noise steering the fuzzer away from PhoneNumbers. + - name: Instrument PhoneNumbers.dll + run: | + dotnet tool restore + dotnet sharpfuzz "${PUBLISH_DIR}/PhoneNumbers.dll" + + - name: Fuzz + run: | + mkdir -p corpus-cache artifacts + cp -n "${CORPUS_DIR}"/* corpus-cache/ || true + ./libfuzzer-dotnet \ + -timeout=10 \ + -max_total_time="${DURATION}" \ + -print_final_stats=1 \ + -artifact_prefix=artifacts/ \ + --target_path=dotnet \ + --target_arg="${PUBLISH_DIR}/PhoneNumbers.Fuzz.dll" \ + corpus-cache + + # A crash fails the step above, so this runs on failure to carry the input out. Reproduce it + # with: dotnet fuzz-out/PhoneNumbers.Fuzz.dll + - name: Upload crashes + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fuzz-crashes + path: artifacts/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 98782a0d0..39978b0de 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,11 @@ csharp/PhoneNumbers.PerformanceTest/BenchmarkDotNet.Artifacts/ # Coverage reports coverage/ + +# Fuzzing scratch: the bridge, the published target, the working corpus and any crashes. +# See csharp/PhoneNumbers.Fuzz/README.md. +/libfuzzer-dotnet +/libfuzzer-dotnet-src/ +/fuzz-out/ +/corpus-cache/ +/artifacts/ diff --git a/CLAUDE.md b/CLAUDE.md index 84c344460..430dc96d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ The library tracks upstream metadata releases (~every two weeks) via the `create - `csharp/coverlet.runsettings` — keeps the generated data tables out of coverage instrumentation; passed by the coverage workflow. - `resources/` — XML metadata (`PhoneNumberMetadata.xml`, `ShortNumberMetadata.xml`, `PhoneNumberAlternateFormats.xml`, `PhoneNumberMetadataForTesting.xml`), plus `geocoding/`, `carrier/`, `timezones/`. **These are copied verbatim from upstream** (`locale/` is the exception: it is generated from the local jdk by `DumpLocale.java`) — do not hand-edit. The library no longer reads them at runtime: the build pipeline emits binary equivalents under `obj/metadata/`, `obj/geocoding/`, `obj/timezones/` which are embedded into the published assembly. - `lib/github-actions-metadata-update.sh` + `lib/DumpLocale.java` — automation that pulls upstream resources and regenerates `resources/locale/country_names.txt`. +- `csharp/PhoneNumbers.Fuzz/` — SharpFuzz/libFuzzer target for the parsing surface, run weekly by `fuzz.yml`. Not in the solution; see its README and the note in its csproj. ## Common commands diff --git a/csharp/Directory.Packages.props b/csharp/Directory.Packages.props index 77331d4e7..120c0b084 100644 --- a/csharp/Directory.Packages.props +++ b/csharp/Directory.Packages.props @@ -11,9 +11,12 @@ + + + diff --git a/csharp/PhoneNumbers.Fuzz/PhoneNumbers.Fuzz.csproj b/csharp/PhoneNumbers.Fuzz/PhoneNumbers.Fuzz.csproj new file mode 100644 index 000000000..02e0c8217 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/PhoneNumbers.Fuzz.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + PhoneNumbers.Fuzz + PhoneNumbers.Fuzz + enable + false + + + + + + + + + + + + diff --git a/csharp/PhoneNumbers.Fuzz/Program.cs b/csharp/PhoneNumbers.Fuzz/Program.cs new file mode 100644 index 000000000..83b8578d3 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/Program.cs @@ -0,0 +1,102 @@ +using System; +using System.Linq; +using System.Text; +using SharpFuzz; + +namespace PhoneNumbers.Fuzz +{ + /// + /// Coverage-guided fuzz target for the untrusted-input surface named in SECURITY.md: the strings + /// callers pass to the public API. libFuzzer drives this through the libfuzzer-dotnet bridge - + /// see README.md in this directory for how to run it. + /// + /// Anything thrown out of is reported as a crash, so the expected failure + /// (NumberParseException for input that is not a phone number) is caught here and everything + /// else is left to escape. + /// + internal static class Program + { + private static readonly PhoneNumberUtil PhoneUtil = PhoneNumberUtil.GetInstance(); + + /// Ordered so a given input byte always selects the same region. + private static readonly string[] Regions = + PhoneUtil.GetSupportedRegions().OrderBy(r => r, StringComparer.Ordinal).ToArray(); + + private static readonly PhoneNumberOfflineGeocoder Geocoder = PhoneNumberOfflineGeocoder.GetInstance(); + private static readonly PhoneNumberToCarrierMapper CarrierMapper = PhoneNumberToCarrierMapper.GetInstance(); + private static readonly PhoneNumberToTimeZonesMapper TimeZonesMapper = PhoneNumberToTimeZonesMapper.GetInstance(); + private static readonly ShortNumberInfo ShortInfo = ShortNumberInfo.GetInstance(); + + public static void Main() => Fuzzer.LibFuzzer.Run(Fuzz); + + private static void Fuzz(ReadOnlySpan span) + { + if (span.IsEmpty) + return; + + // First byte picks the default region, the rest is the number. Giving the fuzzer a byte + // to steer with is what lets it reach region-specific parsing and formatting branches. + var region = Regions[span[0] % Regions.Length]; + var input = Encoding.UTF8.GetString(span.Slice(1)); + + PhoneNumberUtil.Normalize(input); + PhoneNumberUtil.NormalizeDigitsOnly(input); + PhoneNumberUtil.NormalizeDiallableCharsOnly(input); + PhoneNumberUtil.ConvertAlphaCharactersInNumber(input); + PhoneNumberUtil.IsViablePhoneNumber(input); + PhoneNumberUtil.ExtractPossibleNumber(input); + + FindNumbers(input, region); + FormatAsYouType(input, region); + + PhoneNumber number; + try + { + number = PhoneUtil.Parse(input, region); + } + catch (NumberParseException) + { + return; + } + + ExerciseReadOnlySurface(number); + } + + private static void FindNumbers(string input, string region) + { + foreach (var _ in PhoneUtil.FindNumbers(input, region)) + { + // Enumerating is the point: the matcher does its work lazily. + } + } + + private static void FormatAsYouType(string input, string region) + { + var formatter = PhoneUtil.GetAsYouTypeFormatter(region); + + // Bounded because the formatter is per-keystroke: without a cap a long input turns a + // single fuzz iteration into thousands of calls and starves the rest of the target. + for (var i = 0; i < input.Length && i < 200; i++) + formatter.InputDigit(input[i]); + } + + private static void ExerciseReadOnlySurface(PhoneNumber number) + { + PhoneUtil.IsValidNumber(number); + PhoneUtil.IsPossibleNumber(number); + PhoneUtil.GetNumberType(number); + PhoneUtil.GetRegionCodeForNumber(number); + + PhoneUtil.Format(number, PhoneNumberFormat.E164); + PhoneUtil.Format(number, PhoneNumberFormat.INTERNATIONAL); + PhoneUtil.Format(number, PhoneNumberFormat.NATIONAL); + PhoneUtil.Format(number, PhoneNumberFormat.RFC3966); + PhoneUtil.FormatOutOfCountryCallingNumber(number, "US"); + + Geocoder.GetDescriptionForNumber(number, Locale.English); + CarrierMapper.GetNameForNumber(number, Locale.English); + TimeZonesMapper.GetTimeZonesForNumber(number); + ShortInfo.IsPossibleShortNumber(number); + } + } +} diff --git a/csharp/PhoneNumbers.Fuzz/README.md b/csharp/PhoneNumbers.Fuzz/README.md new file mode 100644 index 000000000..5f70a1f61 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/README.md @@ -0,0 +1,58 @@ +# Fuzzing + +Coverage-guided fuzzing of the public parsing surface, using [SharpFuzz] to instrument +`PhoneNumbers.dll` and [libFuzzer] to drive it through the [libfuzzer-dotnet] bridge. + +`Program.cs` reads one input as ``: the first byte selects the default +region, the rest is the string handed to `Parse` and friends. `NumberParseException` is the +documented failure for input that is not a phone number, so it is caught; anything else that +escapes is a crash. + +This project is not in `PhoneNumbers.slnx` — see the comment in the `.csproj`. + +## Running it locally + +Needs clang (to build the bridge) and the .NET SDK. Linux only: the libfuzzer-dotnet bridge does +not support macOS. + +```bash +# 1. Build the libFuzzer bridge. +git clone https://github.com/Metalnem/libfuzzer-dotnet libfuzzer-dotnet-src +clang -fsanitize=fuzzer libfuzzer-dotnet-src/libfuzzer-dotnet.cc -o libfuzzer-dotnet + +# 2. Publish the target and instrument the library under test. +dotnet publish csharp/PhoneNumbers.Fuzz -c Release -o fuzz-out +dotnet tool restore +dotnet sharpfuzz fuzz-out/PhoneNumbers.dll + +# 3. Fuzz. Crashes are written to artifacts/. +mkdir -p artifacts +./libfuzzer-dotnet -timeout=10 -artifact_prefix=artifacts/ \ + --target_path=dotnet --target_arg=fuzz-out/PhoneNumbers.Fuzz.dll \ + csharp/PhoneNumbers.Fuzz/corpus +``` + +Only `PhoneNumbers.dll` is instrumented. Instrumenting `PhoneNumbers.Fuzz.dll` itself, SharpFuzz, +or dnlib would report coverage for the harness rather than the library. + +Add `-max_total_time=` to bound a run, or `-runs=` to bound it by iteration count. + +## Reproducing a crash + +A crash file is just an input. The target runs it directly when it is not hosted by libFuzzer: + +```bash +dotnet fuzz-out/PhoneNumbers.Fuzz.dll artifacts/crash- +``` + +That reproduces under a debugger too, which is usually the fastest way to get a stack trace. Turn +anything it finds into a case in `PhoneNumbers.Test/TestPublicApiRobustness.cs` so it stays fixed. + +## In CI + +`.github/workflows/fuzz.yml` runs this weekly and on demand, seeded from the corpus here and from +the previous run's cached corpus. A crash fails the job and uploads the input as an artifact. + +[SharpFuzz]: https://github.com/Metalnem/sharpfuzz +[libFuzzer]: https://llvm.org/docs/LibFuzzer.html +[libfuzzer-dotnet]: https://github.com/Metalnem/libfuzzer-dotnet diff --git a/csharp/PhoneNumbers.Fuzz/corpus/alpha b/csharp/PhoneNumbers.Fuzz/corpus/alpha new file mode 100644 index 000000000..601a4b999 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/alpha @@ -0,0 +1 @@ +H1-800-FLOWERS \ No newline at end of file diff --git a/csharp/PhoneNumbers.Fuzz/corpus/e164 b/csharp/PhoneNumbers.Fuzz/corpus/e164 new file mode 100644 index 000000000..5335eaf9d --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/e164 @@ -0,0 +1 @@ +A+14156667777 \ No newline at end of file diff --git a/csharp/PhoneNumbers.Fuzz/corpus/extension b/csharp/PhoneNumbers.Fuzz/corpus/extension new file mode 100644 index 000000000..19c30cbe9 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/extension @@ -0,0 +1 @@ +D+1 (650) 253-0000 ext. 1234 \ No newline at end of file diff --git a/csharp/PhoneNumbers.Fuzz/corpus/free-text b/csharp/PhoneNumbers.Fuzz/corpus/free-text new file mode 100644 index 000000000..ce5b6da59 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/free-text @@ -0,0 +1 @@ +GCall me at 650-253-0000 or +44 117 496 0123. \ No newline at end of file diff --git a/csharp/PhoneNumbers.Fuzz/corpus/intl-prefix b/csharp/PhoneNumbers.Fuzz/corpus/intl-prefix new file mode 100644 index 000000000..7150f1864 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/intl-prefix @@ -0,0 +1 @@ +F011 44 20 7496 0123 \ No newline at end of file diff --git a/csharp/PhoneNumbers.Fuzz/corpus/italian-leading-zero b/csharp/PhoneNumbers.Fuzz/corpus/italian-leading-zero new file mode 100644 index 000000000..b8a0887be --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/italian-leading-zero @@ -0,0 +1 @@ +I+390612345678 \ No newline at end of file diff --git a/csharp/PhoneNumbers.Fuzz/corpus/national b/csharp/PhoneNumbers.Fuzz/corpus/national new file mode 100644 index 000000000..936d2c822 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/national @@ -0,0 +1 @@ +B6502530000 \ No newline at end of file diff --git a/csharp/PhoneNumbers.Fuzz/corpus/rfc3966 b/csharp/PhoneNumbers.Fuzz/corpus/rfc3966 new file mode 100644 index 000000000..c8c0e24af --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/rfc3966 @@ -0,0 +1 @@ +Ctel:+1-201-555-0123;ext=1234 \ No newline at end of file diff --git a/csharp/PhoneNumbers.Fuzz/corpus/short-code b/csharp/PhoneNumbers.Fuzz/corpus/short-code new file mode 100644 index 000000000..f08b7b909 --- /dev/null +++ b/csharp/PhoneNumbers.Fuzz/corpus/short-code @@ -0,0 +1 @@ +E83835 \ No newline at end of file diff --git a/csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj b/csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj index b1c6b19b4..5a06fcee6 100644 --- a/csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj +++ b/csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj @@ -24,6 +24,10 @@ + + + all diff --git a/csharp/PhoneNumbers.Test/TestPhoneNumberProperties.cs b/csharp/PhoneNumbers.Test/TestPhoneNumberProperties.cs new file mode 100644 index 000000000..e79e8124e --- /dev/null +++ b/csharp/PhoneNumbers.Test/TestPhoneNumberProperties.cs @@ -0,0 +1,220 @@ +using System; +using System.Globalization; +using System.Linq; +// FsCheck is imported even where only the [Property] attribute is used: the OpenSSF Scorecard +// fuzzing check detects .NET property-based testing by matching "using FsCheck;" (or +// "using FsCheck.Xunit;") in a .cs file, so removing either import silently drops that check to +// zero. See https://github.com/ossf/scorecard/blob/main/docs/checks.md#fuzzing. +using FsCheck; +using FsCheck.Xunit; +using Xunit; + +namespace PhoneNumbers.Test +{ + /// + /// The property-based counterpart to . That class feeds a + /// curated list of hostile strings through the public surface; this one generates them, so the + /// space between the hand-picked cases is covered too, and a failure shrinks to a minimal input. + /// + /// Two kinds of property live here. Most assert only that a call fails in a documented way + /// rather than crashing, which is the same contract the curated tests check. The last few assert + /// real invariants - E.164 round-trips, validity implying possibility, match symmetry - and a + /// failure in those is a genuine bug rather than a hostile-input gap. + /// + /// Uses the production metadata, since that is what ships and is where the awkward regions live. + /// + public class TestPhoneNumberProperties + { + private static readonly PhoneNumberUtil PhoneUtil = PhoneNumberUtil.GetInstance(); + + /// Ordered so a shrunk counterexample names the same region on every run. + private static readonly string[] Regions = + PhoneUtil.GetSupportedRegions().OrderBy(r => r, StringComparer.Ordinal).ToArray(); + + /// Region codes a caller might plausibly pass, valid or not. + private static readonly string[] CallerRegions = + Regions.Concat(new[] { "", " ", "ZZ", "XX", "us", "001", "USA" }).ToArray(); + + [Property(MaxTest = 500)] + public void ParseFailsOnlyWithNumberParseException(string? input, int regionSeed) + { + var region = CallerRegions[Mod(regionSeed, CallerRegions.Length)]; + + try + { + PhoneUtil.Parse(input, region); + } + catch (NumberParseException) + { + // The documented failure for unparseable input. + } + + try + { + PhoneUtil.ParseAndKeepRawInput(input, region); + } + catch (NumberParseException) + { + } + } + + [Property(MaxTest = 500)] + public void NormalizersNeverThrow(string? input) + { + var text = input ?? ""; + + PhoneNumberUtil.Normalize(text); + PhoneNumberUtil.NormalizeDigitsOnly(text); + PhoneNumberUtil.NormalizeDiallableCharsOnly(text); + PhoneNumberUtil.ConvertAlphaCharactersInNumber(text); + PhoneNumberUtil.IsViablePhoneNumber(text); + PhoneNumberUtil.ExtractPossibleNumber(text); + } + + /// + /// Normalizing already-normalized output must be a no-op. A normalizer that keeps rewriting + /// its own output would make callers that normalize defensively behave differently from + /// callers that do not. + /// + [Property(MaxTest = 500)] + public void NormalizeIsIdempotent(string? input) + { + var once = PhoneNumberUtil.Normalize(input ?? ""); + Assert.Equal(once, PhoneNumberUtil.Normalize(once)); + + var digits = PhoneNumberUtil.NormalizeDigitsOnly(input ?? ""); + Assert.Equal(digits, PhoneNumberUtil.NormalizeDigitsOnly(digits)); + } + + [Property(MaxTest = 200)] + public void FindNumbersNeverThrows(string? haystack, int regionSeed) + { + var region = CallerRegions[Mod(regionSeed, CallerRegions.Length)]; + + foreach (var _ in PhoneUtil.FindNumbers(haystack ?? "", region)) + { + // Enumerating is the point: the matcher does its work lazily. + } + } + + [Property(MaxTest = 200)] + public void AsYouTypeFormatterNeverThrows(string? input, int regionSeed) + { + var region = CallerRegions[Mod(regionSeed, CallerRegions.Length)]; + var formatter = PhoneUtil.GetAsYouTypeFormatter(region); + var text = input ?? ""; + + // Per-keystroke by design, and long inputs are covered by the normalizers, so a bounded + // prefix reaches every branch without making the property slow. + for (var i = 0; i < text.Length && i < 200; i++) + formatter.InputDigit(text[i]); + } + + /// + /// The shape that caught the geocoder throwing KeyNotFoundException for Ascension Island, + /// generalised from example numbers to mutations of them. + /// + [Property(MaxTest = 200)] + public void ReadOnlySurfaceNeverThrows(int regionSeed, int digitIndex, int digitValue) + { + var number = Candidate(regionSeed, digitIndex, digitValue); + if (number == null) + return; + + PhoneUtil.IsValidNumber(number); + PhoneUtil.GetNumberType(number); + PhoneUtil.GetRegionCodeForNumber(number); + PhoneUtil.Format(number, PhoneNumberFormat.E164); + PhoneUtil.Format(number, PhoneNumberFormat.INTERNATIONAL); + PhoneUtil.Format(number, PhoneNumberFormat.NATIONAL); + PhoneUtil.Format(number, PhoneNumberFormat.RFC3966); + PhoneUtil.FormatOutOfCountryCallingNumber(number, "US"); + PhoneNumberOfflineGeocoder.GetInstance().GetDescriptionForNumber(number, Locale.English); + PhoneNumberToCarrierMapper.GetInstance().GetNameForNumber(number, Locale.English); + PhoneNumberToTimeZonesMapper.GetInstance().GetTimeZonesForNumber(number); + ShortNumberInfo.GetInstance().IsPossibleShortNumber(number); + } + + /// + /// E.164 is the canonical wire form, so formatting to it and parsing back must land on the + /// same number. Compares the formatted strings rather than the objects, since parsing also + /// populates fields (raw input, country-code source) the original does not carry. + /// + [Property(MaxTest = 200)] + public void ValidNumbersRoundTripThroughE164(int regionSeed, int digitIndex, int digitValue) + { + var number = Candidate(regionSeed, digitIndex, digitValue); + if (number == null || !PhoneUtil.IsValidNumber(number)) + return; + + var e164 = PhoneUtil.Format(number, PhoneNumberFormat.E164); + var reparsed = PhoneUtil.Parse(e164, null); + + Assert.Equal(e164, PhoneUtil.Format(reparsed, PhoneNumberFormat.E164)); + } + + /// + /// Possibility is a length check and validity is the full pattern match, so validity is the + /// strictly stronger claim: anything valid must also be possible. + /// + [Property(MaxTest = 200)] + public void ValidNumbersAreAlsoPossible(int regionSeed, int digitIndex, int digitValue) + { + var number = Candidate(regionSeed, digitIndex, digitValue); + if (number == null || !PhoneUtil.IsValidNumber(number)) + return; + + Assert.True(PhoneUtil.IsPossibleNumber(number), + FormattableString.Invariant($"+{number.CountryCode}{number.NationalNumber} is valid but not possible")); + } + + /// "Could these be the same number" cannot depend on the order of the arguments. + [Property(MaxTest = 200)] + public void IsNumberMatchIsSymmetric(int firstSeed, int secondSeed, int digitIndex, int digitValue) + { + var first = Candidate(firstSeed, digitIndex, digitValue); + var second = Candidate(secondSeed, digitIndex + 1, digitValue + 1); + if (first == null || second == null) + return; + + Assert.Equal(PhoneUtil.IsNumberMatch(first, second), PhoneUtil.IsNumberMatch(second, first)); + } + + /// + /// Builds a number by taking a region's example and rewriting one digit. Wholly random + /// digits are almost never valid for any region, so mutating a known-good number is what + /// keeps the validity-guarded properties from degenerating into no-ops. + /// + private static PhoneNumber? Candidate(int regionSeed, int digitIndex, int digitValue) + { + var example = PhoneUtil.GetExampleNumber(Regions[Mod(regionSeed, Regions.Length)]); + if (example == null) + return null; + + var digits = example.NationalNumber.ToString(CultureInfo.InvariantCulture).ToCharArray(); + digits[Mod(digitIndex, digits.Length)] = (char)('0' + Mod(digitValue, 10)); + + // A leading zero is lost on the way back to ulong, which just yields a shorter number. + if (!ulong.TryParse(new string(digits), NumberStyles.None, CultureInfo.InvariantCulture, out var mutated)) + return null; + + return new PhoneNumber.Builder() + .SetCountryCode(example.CountryCode) + .SetNationalNumber(mutated) + .Build(); + } + + /// + /// Non-negative remainder. Math.Abs is not usable here: the generators produce int.MinValue, + /// which it cannot negate. + /// + private static int Mod(int value, int modulus) + { + if (modulus <= 0) + return 0; + + var remainder = value % modulus; + return remainder < 0 ? remainder + modulus : remainder; + } + } +}