Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions sift-core/src/main/java/com/mirkoddd/sift/core/SiftCatalog.java
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,10 @@ public static SiftPattern<Fragment> jwt() {
* It does <b>not</b> perform Luhn algorithm checksum validation.
* <ul>
* <li><b>Visa:</b> Starts with 4, length 16.</li>
* <li><b>Mastercard:</b> Starts with 51-55 (legacy) or 2x (modern 2221-2720 range).
* The modern range uses a structural approximation ({@code 2[2-6]XXXXXXXXXXXXXX})
* that may accept a small number of prefixes outside the strict 2221-2720 boundaries
* (e.g., 2200-2220 and 2721-2699). For strict BIN validation, use a dedicated
* payment library.</li>
* <li><b>Mastercard:</b> Starts with 51-55 (legacy) or 2221-2720 (modern 2-series),
* length 16. The modern 2-series range is matched <i>exactly</i>: prefixes just
* outside the ISO boundaries (e.g., 2220 or 2721) are correctly rejected, and
* legitimate high prefixes (e.g., 2720) are correctly accepted.</li>
* <li><b>American Express:</b> Starts with 34 or 37, length 15.</li>
* </ul>
*
Expand All @@ -300,8 +299,13 @@ public static SiftPattern<Fragment> creditCard() {
SiftPattern<Fragment> mastercard = anyOf(
// Legacy 51-55
Sift.fromAnywhere().character('5').then().range('1', '5').then().exactly(14).digits(),
// Modern 2221-2720 (structural approximation: 2[2-6]XXXXXXXXXXXXXX)
Sift.fromAnywhere().character('2').then().range('2', '6').then().exactly(14).digits()
// Modern 2-series, exact 2221-2720 range (ISO/Mastercard 2017 expansion):
// 2221-2229 | 2230-2299 | 2300-2699 | 2700-2719 | 2720
Sift.fromAnywhere().character('2').followedBy('2').followedBy('2').then().range('1', '9').then().exactly(12).digits(),
Sift.fromAnywhere().character('2').followedBy('2').then().range('3', '9').then().exactly(13).digits(),
Sift.fromAnywhere().character('2').then().range('3', '6').then().exactly(14).digits(),
Sift.fromAnywhere().character('2').followedBy('7').then().range('0', '1').then().exactly(13).digits(),
Sift.fromAnywhere().character('2').followedBy('7').followedBy('2').followedBy('0').then().exactly(12).digits()
);

return anyOf(visa, amex, mastercard)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,21 @@ Arbitrary<String> amexNumbers() {
return Combinators.combine(prefix, body).as((p, b) -> p + b);
}

@Property
void creditCardShouldAcceptMastercard2Series(@ForAll("mastercard2SeriesNumbers") String mc) {
try (SiftCompiledPattern pattern = SiftCatalog.creditCard().sieve()) {
assertTrue(pattern.matchesEntire(mc), "Should accept valid 2-series Mastercard: " + mc);
}
}

@Property
void creditCardShouldRejectMastercard2SeriesOutOfRange(@ForAll("mastercard2SeriesOutOfRangePrefixes") int prefix) {
String number = prefix + "000000000000"; // 4-digit prefix + 12 digits = 16
try (SiftCompiledPattern pattern = SiftCatalog.creditCard().sieve()) {
assertFalse(pattern.matchesEntire(number), "Should reject out-of-range 2-series prefix: " + prefix);
}
}

@Provide
Arbitrary<String> mastercardLegacyNumbers() {
// Legacy 51-55 prefix
Expand All @@ -454,6 +469,21 @@ Arbitrary<String> mastercardLegacyNumbers() {
return Combinators.combine(firstDigit, body).as((d, b) -> "5" + d + b);
}

@Provide
Arbitrary<String> mastercard2SeriesNumbers() {
Arbitrary<Integer> prefix = Arbitraries.integers().between(2221, 2720);
Arbitrary<String> body = Arbitraries.strings().withCharRange('0', '9').ofLength(12);
return Combinators.combine(prefix, body).as((p, b) -> p + b);
}

@Provide
Arbitrary<Integer> mastercard2SeriesOutOfRangePrefixes() {
return Arbitraries.oneOf(
Arbitraries.integers().between(2200, 2220),
Arbitraries.integers().between(2721, 2799)
);
}

@Test
void shouldValidateCreditCard() {
try (SiftCompiledPattern ccPattern = SiftCatalog.creditCard().sieve()) {
Expand All @@ -465,6 +495,12 @@ void shouldValidateCreditCard() {
// Mastercard legacy
assertTrue(ccPattern.matchesEntire("5500005555555559"));
assertTrue(ccPattern.matchesEntire("5105105105105100"));
// Mastercard 2-series boundaries (exact 2221-2720 range)
assertTrue(ccPattern.matchesEntire("2221000000000000"), "Should accept lower boundary 2221");
assertTrue(ccPattern.matchesEntire("2720990000000000"), "Should accept upper boundary 2720");
assertTrue(ccPattern.matchesEntire("2700000000000000"), "Should accept 2700 (regression: previously rejected)");
assertFalse(ccPattern.matchesEntire("2220000000000000"), "Should reject 2220 (just below range)");
assertFalse(ccPattern.matchesEntire("2721000000000000"), "Should reject 2721 (just above range)");

assertFalse(ccPattern.matchesEntire("1234567890123456"), "Should reject unknown prefix");
assertFalse(ccPattern.matchesEntire("411111111111111"), "Should reject Visa too short");
Expand Down