Skip to content

Commit 4575231

Browse files
committed
fix(core): prevent varargs null poisoning and empty string edge cases
This commit hardens the DSL's fail-fast capabilities for the 1.5.2 release: - fix(dsl): add strict isEmpty() checks to anythingBut() and literal() to prevent runtime PatternSyntaxException - fix(dsl): add internal loop validations in anyOf(), group(), and filteringWith() to prevent delayed NPEs from varargs - refactor(core): remove redundant static factory method in NamedCapture - docs(api): align Javadoc for ConnectorStep, QuantifierStep, and TypeStep with the current architecture - docs(api): add missing lookahead/lookbehind examples in SiftPatterns - test: add comprehensive JUnit 5 coverage for all new exception scenarios - docs: update README with 1.5.1/1.5.2 bumps, null-safety feature, and Scarf telemetry
1 parent f640d32 commit 4575231

File tree

11 files changed

+189
-70
lines changed

11 files changed

+189
-70
lines changed

README.md

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
# Sift
2-
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=e931dfa9-02e9-406d-bde7-56f9e0000464" alt=""/>
3-
4-
[![sift-core](https://img.shields.io/maven-central/v/com.mirkoddd/sift-core?label=sift-core)](https://central.sonatype.com/artifact/com.mirkoddd/sift-core)
2+
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=e931dfa9-02e9-406d-bde7-56f9e0000464" alt=""/>[![sift-core](https://img.shields.io/maven-central/v/com.mirkoddd/sift-core?label=sift-core)](https://central.sonatype.com/artifact/com.mirkoddd/sift-core)
53
[![sift-annotations](https://img.shields.io/maven-central/v/com.mirkoddd/sift-annotations?label=sift-annotations)](https://central.sonatype.com/artifact/com.mirkoddd/sift-annotations)
64
[![Java 8+](https://img.shields.io/badge/Java-8+-blue.svg)](https://adoptium.net/) [![Tests](https://github.com/mirkoddd/Sift/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/mirkoddd/Sift/actions)
75
[![Coverage](https://raw.githubusercontent.com/mirkoddd/Sift/main/.github/badges/jacoco.svg)](https://github.com/mirkoddd/Sift/actions)
@@ -22,7 +20,7 @@ Add Sift to your project dependencies:
2220
**Maven:**
2321

2422
```XML
25-
<!-- Replace <latest-version> with the version shown in the Maven Central badge above -->
23+
<!-- Replace <latest-version> with the version shown in the Maven Central badge above -->
2624
<dependency>
2725
<groupId>com.mirkoddd</groupId>
2826
<artifactId>sift-core</artifactId>
@@ -39,12 +37,12 @@ Add Sift to your project dependencies:
3937

4038
```Groovy
4139
// Replace <latest-version> with the version shown in the Maven Central badge above
42-
43-
// Core Engine: Fluent API for Regex generation (Zero external dependencies)
44-
implementation 'com.mirkoddd:sift-core:<latest-version>'
45-
46-
// Optional: Integration with Jakarta Validation / Hibernate Validator
47-
implementation 'com.mirkoddd:sift-annotations:<latest-version>'
40+
41+
// Core Engine: Fluent API for Regex generation (Zero external dependencies)
42+
implementation 'com.mirkoddd:sift-core:<latest-version>'
43+
44+
// Optional: Integration with Jakarta Validation / Hibernate Validator
45+
implementation 'com.mirkoddd:sift-annotations:<latest-version>'
4846
```
4947
## Compatibility
5048

@@ -73,12 +71,9 @@ However, the internal codebase and test suite utilize modern **Java 17** feature
7371

7472
Forget about counting backslashes or memorizing obscure symbols. Sift guides your hand using your IDE's auto-completion.
7573

76-
```Java
77-
78-
import static com.mirkoddd.sift.core.Sift.fromStart;
79-
74+
```Java
8075
// Goal: Match an international username securely
81-
String regex = fromStart()
76+
String regex = Sift.fromStart()
8277
.exactly(1).unicodeLettersUppercaseOnly() // Must start with an uppercase letter
8378
.then()
8479
.between(3, 15).unicodeWordCharacters().withoutBacktracking() // Secure against ReDoS
@@ -87,8 +82,8 @@ Forget about counting backslashes or memorizing obscure symbols. Sift guides you
8782
.andNothingElse()
8883
.shake();
8984

90-
// Result: ^\p{Lu}[\p{L}\p{Nd}_]{3,15}+[0-9]?$
91-
85+
// Result: ^[\p{Lu}][\p{L}\p{Nd}_]{3,15}+[0-9]?$
86+
9287
```
9388

9489
# 2. Seamless Jakarta Validation
@@ -97,29 +92,29 @@ Stop duplicating regex logic in your DTOs. Centralize your rules and reuse them
9792

9893
```Java
9994

100-
// 1. Define your reusable rule (e.g., matching "PROMO123")
101-
public class PromoCodeRule implements SiftRegexProvider {
102-
103-
public String getRegex() {
104-
return Sift.fromStart()
95+
// 1. Define your reusable rule (e.g., matching "PROMO123")
96+
public class PromoCodeRule implements SiftRegexProvider {
97+
98+
public String getRegex() {
99+
return Sift.fromStart()
105100
.atLeast(4).letters()
106101
.then()
107102
.exactly(3).digits()
108103
.andNothingElse()
109104
.shake();
110-
}
111105
}
112-
113-
// 2. Apply it to your models with Type-Safe Flags
114-
public record ApplyPromoRequest(
106+
}
107+
108+
// 2. Apply it to your models with Type-Safe Flags
109+
public record ApplyPromoRequest(
115110
@SiftMatch(
116-
value = PromoCodeRule.class,
117-
flags = {SiftMatchFlag.CASE_INSENSITIVE}, // Allows "promo123" to pass
118-
message = "Invalid promo code format"
111+
value = PromoCodeRule.class,
112+
flags = {SiftMatchFlag.CASE_INSENSITIVE}, // Allows "promo123" to pass
113+
message = "Invalid promo code format"
119114
)
120115
String promoCode
121-
) {}
122-
116+
) {}
117+
123118
```
124119

125120
*Sift compiles the Pattern only once during initialization, ensuring zero performance overhead during validation.*

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
allprojects {
22
group = 'com.mirkoddd'
3-
version = '1.5.1'
3+
version = '1.5.2'
44

55
repositories {
66
mavenCentral()

sift-core/src/main/java/com/mirkoddd/sift/core/NamedCapture.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,4 @@ public String getName() {
5151
public SiftPattern getPattern() {
5252
return pattern;
5353
}
54-
55-
static NamedCapture create(GroupName name, SiftPattern pattern) {
56-
return new NamedCapture(name, pattern);
57-
}
5854
}

sift-core/src/main/java/com/mirkoddd/sift/core/Sift.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ private Sift() {
6060
public static SiftStarter filteringWith(SiftGlobalFlag flag, SiftGlobalFlag... flags) {
6161
Objects.requireNonNull(flag, "Primary flag cannot be null");
6262
Objects.requireNonNull(flags, "Additional flags array cannot be null");
63+
64+
for (SiftGlobalFlag f : flags) {
65+
Objects.requireNonNull(f, "Additional flag cannot be null");
66+
}
67+
6368
SiftGlobalFlag[] allFlags = new SiftGlobalFlag[flags.length + 1];
6469
allFlags[0] = flag;
6570
System.arraycopy(flags, 0, allFlags, 1, flags.length);

sift-core/src/main/java/com/mirkoddd/sift/core/SiftPatterns.java

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
*/
3232
public final class SiftPatterns {
3333

34-
private SiftPatterns() {}
34+
private SiftPatterns() {
35+
}
3536

3637
/**
3738
* Creates a pattern that matches ANY ONE of the provided options (Logical OR).
@@ -49,6 +50,10 @@ public static SiftPattern anyOf(SiftPattern option1, SiftPattern option2, SiftPa
4950
Objects.requireNonNull(option2, "Second option cannot be null");
5051
Objects.requireNonNull(additionalOptions, "Additional options array cannot be null");
5152

53+
for (SiftPattern opt : additionalOptions) {
54+
Objects.requireNonNull(opt, "Additional option cannot be null");
55+
}
56+
5257
return () -> {
5358
StringBuilder sb = new StringBuilder();
5459
sb.append(RegexSyntax.NON_CAPTURING_GROUP_OPEN);
@@ -85,6 +90,16 @@ public static SiftPattern capture(SiftPattern pattern) {
8590
* Creates a <b>Positive Lookahead</b> {@code (?=...)}.
8691
* <p>
8792
* Asserts that the given pattern CAN be matched next, but does not consume any characters.
93+
* <br><b>Example:</b>
94+
* <pre>{@code
95+
* // Match "test" only if followed by "123", without consuming "123"
96+
* String regex = Sift.fromAnywhere()
97+
* .pattern(literal("test"))
98+
* .followedBy(positiveLookahead(literal("123")))
99+
* .shake();
100+
* // Result: test(?=123)
101+
* // "test123" matches "test" at position 0-4 (doesn't consume "123")
102+
* }</pre>
88103
*
89104
* @param pattern The pattern that must follow.
90105
* @return A SiftPattern representing the positive lookahead.
@@ -98,6 +113,16 @@ public static SiftPattern positiveLookahead(SiftPattern pattern) {
98113
* Creates a <b>Negative Lookahead</b> {@code (?!...)}.
99114
* <p>
100115
* Asserts that the given pattern CANNOT be matched next, and does not consume any characters.
116+
* <br><b>Example:</b>
117+
* <pre>{@code
118+
* // Match "test" only if NOT followed by "123"
119+
* String regex = Sift.fromAnywhere()
120+
* .pattern(literal("test"))
121+
* .followedBy(negativeLookahead(literal("123")))
122+
* .shake();
123+
* // Result: test(?!123)
124+
* // "test999" matches "test", but "test123" fails entirely
125+
* }</pre>
101126
*
102127
* @param pattern The pattern that must not follow.
103128
* @return A SiftPattern representing the negative lookahead.
@@ -111,6 +136,16 @@ public static SiftPattern negativeLookahead(SiftPattern pattern) {
111136
* Creates a <b>Positive Lookbehind</b> {@code (?<=...)}.
112137
* <p>
113138
* Asserts that the given pattern CAN be matched immediately before the current position.
139+
* <br><b>Example:</b>
140+
* <pre>{@code
141+
* // Match "123" only if immediately preceded by "EUR"
142+
* String regex = Sift.fromAnywhere()
143+
* .pattern(positiveLookbehind(literal("EUR")))
144+
* .followedBy(literal("123"))
145+
* .shake();
146+
* // Result: (?<=EUR)123
147+
* // Matches "123" in "EUR123", but fails in "USD123"
148+
* }</pre>
114149
*
115150
* @param pattern The pattern that must precede.
116151
* @return A SiftPattern representing the positive lookbehind.
@@ -124,6 +159,16 @@ public static SiftPattern positiveLookbehind(SiftPattern pattern) {
124159
* Creates a <b>Negative Lookbehind</b> {@code (?<!...)}.
125160
* <p>
126161
* Asserts that the given pattern CANNOT be matched immediately before the current position.
162+
* <br><b>Example:</b>
163+
* <pre>{@code
164+
* // Match "123" only if NOT preceded by "EUR"
165+
* String regex = Sift.fromAnywhere()
166+
* .pattern(negativeLookbehind(literal("EUR")))
167+
* .followedBy(literal("123"))
168+
* .shake();
169+
* // Result: (?<!EUR)123
170+
* // Matches "123" in "USD123", but fails in "EUR123"
171+
* }</pre>
127172
*
128173
* @param pattern The pattern that must not precede.
129174
* @return A SiftPattern representing the negative lookbehind.
@@ -143,11 +188,11 @@ public static SiftPattern negativeLookbehind(SiftPattern pattern) {
143188
* @param pattern The pattern to capture within this group.
144189
* @return A NamedCapture definition.
145190
* @throws IllegalArgumentException if {@code groupName} is null, empty, starts with a digit, or contains non-alphanumeric characters (e.g., spaces, underscores, or symbols).
146-
* */
191+
*/
147192
public static NamedCapture capture(String groupName, SiftPattern pattern) {
148193
Objects.requireNonNull(pattern, "Pattern to capture cannot be null");
149194
GroupName validatedName = GroupName.of(groupName);
150-
return NamedCapture.create(validatedName, pattern);
195+
return new NamedCapture(validatedName, pattern);
151196
}
152197

153198
/**
@@ -164,6 +209,10 @@ public static SiftPattern group(SiftPattern first, SiftPattern... then) {
164209
Objects.requireNonNull(first, "First pattern in group cannot be null");
165210
Objects.requireNonNull(then, "Additional patterns array cannot be null");
166211

212+
for (SiftPattern opt : then) {
213+
Objects.requireNonNull(opt, "Additional option cannot be null");
214+
}
215+
167216
return () -> {
168217
StringBuilder sb = new StringBuilder();
169218
sb.append(RegexSyntax.NON_CAPTURING_GROUP_OPEN);
@@ -190,6 +239,11 @@ public static SiftPattern group(SiftPattern first, SiftPattern... then) {
190239
*/
191240
public static SiftPattern literal(String text) {
192241
Objects.requireNonNull(text, "Literal text cannot be null");
242+
243+
if (text.isEmpty()) {
244+
throw new IllegalArgumentException("Literal text cannot be empty. Use zero-width assertions if intentional.");
245+
}
246+
193247
return () -> {
194248
StringBuilder sb = new StringBuilder();
195249
RegexEscaper.escapeString(text, sb);
@@ -209,6 +263,11 @@ public static SiftPattern literal(String text) {
209263
*/
210264
public static SiftPattern anythingBut(String chars) {
211265
Objects.requireNonNull(chars, "Excluded characters string cannot be null");
266+
267+
if (chars.isEmpty()) {
268+
throw new IllegalArgumentException("Excluded characters string cannot be empty");
269+
}
270+
212271
return () -> {
213272
StringBuilder sb = new StringBuilder();
214273
sb.append(RegexSyntax.CLASS_OPEN);

sift-core/src/main/java/com/mirkoddd/sift/core/dsl/ConnectorStep.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
* <ul>
2424
* <li>Refine the current token (using {@code including} or {@code excluding}).</li>
2525
* <li>Append a new literal or pattern (using {@code followedBy(...)}).</li>
26-
* <li>Transition back to a quantifier for a new token (using {@code followedBy()}).</li>
27-
* <li>Finalize the regex structure (using {@code untilEnd()}).</li>
26+
* <li>Transition back to a quantifier for a new token (using {@code then()}).</li>
27+
* <li>Assert a word boundary (using {@code wordBoundary()}).</li>
28+
* <li>Finalize the regex structure (using {@code andNothingElse()}).</li>
2829
* </ul>
2930
*/
3031
public interface ConnectorStep extends SiftPattern {

sift-core/src/main/java/com/mirkoddd/sift/core/dsl/QuantifierStep.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,17 @@
1818
import com.mirkoddd.sift.core.NamedCapture;
1919

2020
/**
21-
* Defines <b>HOW MANY TIMES</b> the next element should appear.
21+
* Defines <b>HOW MANY TIMES</b> the next element should appear, or injects structural elements.
2222
* <p>
2323
* This interface extends {@link TypeStep}, which allows for a concise syntax:
2424
* if no quantifier is explicitly chosen, it defaults to matching <b>exactly once</b>.
2525
* <p>
2626
* <b>Flow Examples:</b>
2727
* <ul>
28-
* <li>Explicit: {@code .exactly(3).digits()}</li>
29-
* <li>Implicit: {@code .digits()} (implies exactly 1)</li>
30-
* <li>Sugar: {@code .withOptional(pattern)} (applies quantifier directly to argument)</li>
28+
* <li>Explicit Quantity: {@code .exactly(3).digits()}</li>
29+
* <li>Implicit Quantity: {@code .digits()} (implies exactly 1)</li>
30+
* <li>Named Capture: {@code .namedCapture(groupDefinition)} (injects a named group)</li>
31+
* <li>Backreference: {@code .backreference(groupDefinition)} (references a previous group)</li>
3132
* </ul>
3233
*/
3334
public interface QuantifierStep extends TypeStep<ConnectorStep> {

sift-core/src/main/java/com/mirkoddd/sift/core/dsl/TypeStep.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@
1818
/**
1919
* Defines the <b>TYPE</b> of character or pattern to match.
2020
* <p>
21-
* This interface represents the state immediately after a quantifier has been set
22-
* (e.g., {@code exactly(3)} or {@code oneOrMore()}). The method selected here defines
23-
* <i>what</i> exactly applies to that quantifier.
21+
* This interface represents the state where the exact token to be matched is selected.
22+
* It can be reached in two ways:
23+
* <ul>
24+
* <li><b>Explicitly:</b> Immediately after a quantifier has been set (e.g., {@code .exactly(3).digits()}).</li>
25+
* <li><b>Implicitly:</b> Directly from a state that expects a token (e.g., {@code .digits()}), which defaults to matching exactly once.</li>
26+
* </ul>
2427
* <p>
2528
* Selecting a type consumes the pending quantifier and transitions the builder to the
26-
* {@link ConnectorStep}.
29+
* next structural link (either a {@link ConnectorStep} or a {@link VariableConnectorStep},
30+
* depending on the generic type {@code T}).
2731
*/
2832
public interface TypeStep<T extends ConnectorStep> {
2933

0 commit comments

Comments
 (0)