Skip to content
Merged
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
46 changes: 45 additions & 1 deletion .github/workflows/Tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write

steps:
- uses: actions/checkout@v4
Expand All @@ -42,4 +43,47 @@ jobs:
with:
run: ./gradlew allTests --no-daemon


- name: Combine benchmark reports
if: github.event_name == 'pull_request'
run: |
{
echo "## Benchmark Results"
echo ""
cat kotlin-lib/build/reports/benchmark-scan.md
cat kotlin-lib/build/reports/benchmark-init.md
cat kotlin-lib/build/reports/benchmark-consistency.md
} > benchmark.md

- name: Post benchmark results to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const marker = '<!-- benchmark-results -->';
const body = fs.readFileSync('benchmark.md', 'utf8');
const fullBody = marker + '\n' + body;

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const existing = comments.find(c => c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: fullBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: fullBody,
});
}
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,47 @@ HyperScanEngine(Matchers.filterIsInstance<IHyperMatcher>()).use { engine ->
}
```

### Fast startup with a pre-compiled HyperScan database
Compiling regular expressions on every startup can be slow when the matcher set is large.
You can compile once, save the database, and reload it instantly on subsequent runs.

```kotlin
import org.angryscan.common.engine.hyperscan.HyperScanEngine
import org.angryscan.common.engine.hyperscan.IHyperMatcher
import org.angryscan.common.extensions.Matchers
import java.io.File

val matchers = Matchers.filterIsInstance<IHyperMatcher>()
val dbFile = File("hyperscan.db")

// First run — compile and save
HyperScanEngine(matchers).use { engine ->
engine.saveCompiledDatabase(dbFile)
}

// Subsequent runs — load (no compilation)
HyperScanEngine.fromCompiledDatabase(matchers, dbFile).use { engine ->
engine.scan(text).forEach { match ->
println("${match.matcher.name}: ${match.value}")
}
}
```

In-memory `ByteArray` variants are also available:

```kotlin
// Save to bytes
val bytes: ByteArray = engine.saveCompiledDatabase()

// Load from bytes
val fast = HyperScanEngine.fromCompiledDatabase(matchers, bytes)
```

> **Compatibility note:** the saved database is tied to the exact matcher set, their order,
> and the `requireKeywords` flag used during compilation.
> Loading a database with a different configuration will throw `IllegalArgumentException`.
> The binary format is also platform-specific (see Hyperscan documentation).

### Portable detection with KotlinEngine
```kotlin
import org.angryscan.common.engine.kotlin.IKotlinMatcher
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#Kotlin
kotlin.code.style=official

version=1.4.8
version=1.5.0
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ package org.angryscan.common.constants
*/
expect object CardBins {
/**
* A list of card BIN patterns used for card issuer identification.
* Each string in the list represents a BIN pattern that can be matched against
* A set of card BIN patterns used for card issuer identification.
* Each string in the set represents a BIN pattern that can be matched against
* the beginning of a card number to identify the card issuer.
*/
val cardBins: List<String>
val cardBins: Set<String>
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.angryscan.common.engine.kotlin

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.angryscan.common.engine.IScanEngine
import org.angryscan.common.engine.Match

Expand All @@ -9,41 +10,35 @@ class KotlinEngine(
@Serializable override val matchers: List<IKotlinMatcher>,
val requireKeywords: Boolean = true
) : IScanEngine {
override fun scan(text: String): List<Match> {
return matchers.flatMap { pattern ->
regexDetector(
text,
pattern
)
@Transient
private val compiledPatterns: List<Pair<Regex, IKotlinMatcher>> =
matchers.flatMap { matcher ->
matcher.getJavaPatterns(requireKeywords).map { pattern ->
pattern.toRegex(matcher.regexOptions) to matcher
}
}
}

fun regexDetector(text: String, pattern: IKotlinMatcher): List<Match> {
val matches = pattern
.getJavaPatterns(requireKeywords)
.flatMap { str ->
str.toRegex(
pattern.regexOptions
).findAll(text)
.filter { pattern.check(it.value) }
.map { match ->
Match(
value = match.value,
before = text.substring(
maxOf(0, match.range.first - 10),
match.range.first
),
after = "$text ".substring(
match.range.last + 1,
minOf(match.range.last + 11, text.length)
),
startPosition = match.range.first.toLong(),
endPosition = match.range.last.toLong(),
matcher = pattern
)
}
}
return matches.distinct()
override fun scan(text: String): List<Match> {
return compiledPatterns.flatMap { (regex, matcher) ->
regex.findAll(text)
.filter { matcher.check(it.value) }
.map { match ->
Match(
value = match.value,
before = text.substring(
maxOf(0, match.range.first - 10),
match.range.first
),
after = if (match.range.last + 1 < text.length)
text.substring(match.range.last + 1, minOf(match.range.last + 11, text.length))
else "",
startPosition = match.range.first.toLong(),
endPosition = match.range.last.toLong(),
matcher = matcher
)
}
.toList()
}.distinct()
}

override fun close() {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,12 @@ object APOFPODPO : IHyperMatcher, IKotlinMatcher {
private val validCityCodes = setOf("APO", "FPO", "DPO")
private val validStateCodes = setOf("AA", "AE", "AP")

private val CHECK_MILITARY_ADDRESS_REGEX =
Regex("""(APO|FPO|DPO)\s+(AA|AE|AP)\s+(\d{5})(?:-(\d{4}))?""", RegexOption.IGNORE_CASE)

override fun check(value: String): Boolean {
// Extract address parts
val match = Regex("""(APO|FPO|DPO)\s+(AA|AE|AP)\s+(\d{5})(?:-(\d{4}))?""", RegexOption.IGNORE_CASE).find(value)
val match = CHECK_MILITARY_ADDRESS_REGEX.find(value)
if (match == null) return false

val cityCode = match.groupValues[1].uppercase()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ object AddressUS : IHyperMatcher, IKotlinMatcher {
"UT", "VA", "VT", "WA", "WI", "WV", "WY"
)

private val STATE_ZIP_REGEX = Regex("""\s+([a-zA-Z]{2})\s+(\d{5})""", RegexOption.IGNORE_CASE)
private val LEADING_HOUSE_NUMBER_REGEX = Regex("""^\d{1,8}\s*""")

override fun check(value: String): Boolean {
// Check that state code is valid (support any case)
val stateMatch = Regex("""\s+([a-zA-Z]{2})\s+(\d{5})""", RegexOption.IGNORE_CASE).find(value)
val stateMatch = STATE_ZIP_REGEX.find(value)
if (stateMatch == null) return false

val stateCode = stateMatch.groupValues[1].uppercase()
Expand All @@ -65,7 +68,7 @@ object AddressUS : IHyperMatcher, IKotlinMatcher {
// Extract part before state code
val beforeState = stateMatch.range.first
val addressWithNumber = value.substring(0, beforeState)
val addressPart = addressWithNumber.replace(Regex("""^\d{1,8}\s*"""), "").trim()
val addressPart = addressWithNumber.replace(LEADING_HOUSE_NUMBER_REGEX, "").trim()
if (addressPart.length < 7) return false

return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ class CardNumber(val checkCardBins: Boolean = true) : IHyperMatcher, IKotlinMatc
ExpressionOption.MULTILINE
)

companion object {
private val NON_DIGIT_REGEX = Regex("[^0-9]")
}

override fun check(value: String): Boolean {
val cleanCard = value.replace("[^0-9]".toRegex(), "")
val cleanCard = value.replace(NON_DIGIT_REGEX, "")
return cleanCard != "0000000000000000"
&& (!checkCardBins || isBinValid(cleanCard))
&& isCardValid(cleanCard)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ object CryptoSeedPhrase : IKotlinMatcher {
RegexOption.MULTILINE
)

private val WHITESPACE_SPLIT_REGEX = Regex("\\s+")

override fun check(value: String): Boolean {
val words = value.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
val words = value.trim().split(WHITESPACE_SPLIT_REGEX).filter { it.isNotEmpty() }

if (words.isEmpty()) return false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ object DODID : IHyperMatcher, IKotlinMatcher {
ExpressionOption.UTF8
)

private val CHECK_DOD_ID_TEN_DIGITS_REGEX = Regex("""\d{10}""")

override fun check(value: String): Boolean {
// Extract 10-digit number from match (may include keywords)
val numberPattern = Regex("""\d{10}""")
val match = numberPattern.find(value)
val match = CHECK_DOD_ID_TEN_DIGITS_REGEX.find(value)
if (match == null) return false

val digits = match.value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,51 +89,58 @@ object DriverLicenseUS : IHyperMatcher, IKotlinMatcher {
ExpressionOption.UTF8
)

private val CHECK_CO = Regex("""\d{2}-\d{3}-\d{4}""", RegexOption.IGNORE_CASE)
private val CHECK_FL = Regex("""[A-Z]-\d{3}-\d{3}-\d{3}-\d{3}""", RegexOption.IGNORE_CASE)
private val CHECK_ND = Regex("""[A-Z]{3}-\d{2}-\d{4}""", RegexOption.IGNORE_CASE)
private val CHECK_NJ = Regex("""[A-Z]\d{14}""", RegexOption.IGNORE_CASE)
private val CHECK_WA = Regex("""[A-Z]{3}\*\*[A-Z]{2}\d{3}[A-Z]\d""", RegexOption.IGNORE_CASE)
private val CHECK_WI = Regex("""[A-Z]\d{3}-\d{4}-\d{4}-\d{2}""", RegexOption.IGNORE_CASE)

override fun check(value: String): Boolean {
// Check match against one of the formats

// Colorado: ##-###-####
if (value.matches(Regex("""\d{2}-\d{3}-\d{4}""", RegexOption.IGNORE_CASE))) {
if (value.matches(CHECK_CO)) {
val digits = value.replace("-", "").filter { it.isDigit() }
if (digits.length == 9 && !digits.all { it == '0' } && !digits.all { it == digits[0] }) {
return true
}
}

// Florida: L-###-###-###-###
if (value.matches(Regex("""[A-Z]-\d{3}-\d{3}-\d{3}-\d{3}""", RegexOption.IGNORE_CASE))) {
if (value.matches(CHECK_FL)) {
val digits = value.filter { it.isDigit() }
if (digits.length == 12 && !digits.all { it == '0' } && !digits.all { it == digits[0] }) {
return true
}
}

// North Dakota: ABC-12-3456
if (value.matches(Regex("""[A-Z]{3}-\d{2}-\d{4}""", RegexOption.IGNORE_CASE))) {
if (value.matches(CHECK_ND)) {
val digits = value.filter { it.isDigit() }
if (digits.length == 6 && !digits.all { it == '0' } && !digits.all { it == digits[0] }) {
return true
}
}

// New Jersey: A + 14 digits
if (value.matches(Regex("""[A-Z]\d{14}""", RegexOption.IGNORE_CASE))) {
if (value.matches(CHECK_NJ)) {
val digits = value.filter { it.isDigit() }
if (digits.length == 14 && !digits.all { it == '0' } && !digits.all { it == digits[0] }) {
return true
}
}

// Washington: DOE**MJ501P1
if (value.matches(Regex("""[A-Z]{3}\*\*[A-Z]{2}\d{3}[A-Z]\d""", RegexOption.IGNORE_CASE))) {
if (value.matches(CHECK_WA)) {
val digits = value.filter { it.isDigit() }
if (digits.length == 4 && !digits.all { it == '0' } && !digits.all { it == digits[0] }) {
return true
}
}

// Wisconsin: J525-4209-0465-05
if (value.matches(Regex("""[A-Z]\d{3}-\d{4}-\d{4}-\d{2}""", RegexOption.IGNORE_CASE))) {
if (value.matches(CHECK_WI)) {
val digits = value.filter { it.isDigit() }
if (digits.length == 13 && !digits.all { it == '0' } && !digits.all { it == digits[0] }) {
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,18 @@ object EIN : IHyperMatcher, IKotlinMatcher {
}
}

private val CHECK_NUMBER_PATTERN =
Regex("""(?:[07][1-7]|1[0-6]|2[0-7]|[35][0-9]|[468][0-8]|9[0-589])-?\d{7}""")
private val NON_DIGIT_HYPHEN_REGEX = Regex("[^0-9-]")
private val FORMAT_REGEX = Regex("""\d{2}-\d{7}""")

override fun check(value: String): Boolean {
// Extract EIN number from the match (may include keywords)
val numberPattern = Regex("""(?:[07][1-7]|1[0-6]|2[0-7]|[35][0-9]|[468][0-8]|9[0-589])-?\d{7}""")
val match = numberPattern.find(value)
val match = CHECK_NUMBER_PATTERN.find(value)
if (match == null) return false

// Extract only digits and hyphens from the matched number
val cleaned = match.value.replace(Regex("[^0-9-]"), "")
val cleaned = match.value.replace(NON_DIGIT_HYPHEN_REGEX, "")

// Check format: must be digits, possibly with one hyphen
val digitsOnly = cleaned.replace("-", "")
Expand All @@ -146,7 +150,7 @@ object EIN : IHyperMatcher, IKotlinMatcher {
if (hyphenCount > 1) return false
if (hyphenCount == 1) {
// Hyphen must be after the first two digits
if (!cleaned.matches(Regex("""\d{2}-\d{7}"""))) return false
if (!cleaned.matches(FORMAT_REGEX)) return false
}

// Check prefix (first two digits of the entire number)
Expand Down
Loading
Loading