Skip to content

Commit cd3351d

Browse files
authored
Merge branch 'develop' into feature/125-implement-pingreq-pingresp-support
2 parents 10dc0da + dfa4a52 commit cd3351d

19 files changed

Lines changed: 713 additions & 12 deletions

File tree

acl-engine/src/main/java/javasabr/mqtt/acl/engine/exception/AclConfigurationException.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ public class AclConfigurationException extends RuntimeException {
55
public AclConfigurationException(String message) {
66
super(message);
77
}
8+
9+
public AclConfigurationException(String message, Throwable cause) {
10+
super(message, cause);
11+
}
812
}

acl-mug-dsl/build.gradle

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
plugins {
2+
id("groovy")
3+
id("java-library")
4+
id("configure-java")
5+
}
6+
7+
dependencies {
8+
api projects.aclEngine
9+
api libs.rlib.collections
10+
11+
implementation libs.mug
12+
implementation libs.mug.dot.parse
13+
14+
testImplementation testFixtures(projects.model)
15+
testImplementation projects.testSupport
16+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package javasabr.mqtt.acl.mug.dsl.loader;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
import java.util.Map;
7+
import javasabr.mqtt.acl.engine.builder.RuleContainerBuilder;
8+
import javasabr.mqtt.acl.engine.exception.AclConfigurationException;
9+
import javasabr.mqtt.acl.engine.model.rule.AclRule;
10+
import javasabr.mqtt.acl.mug.dsl.parser.GaclParser;
11+
import javasabr.mqtt.model.acl.Operation;
12+
import javasabr.rlib.collections.array.Array;
13+
import javasabr.rlib.collections.array.ArrayBuilder;
14+
import lombok.RequiredArgsConstructor;
15+
16+
@RequiredArgsConstructor
17+
public class AclRulesLoader {
18+
19+
private final GaclParser parser;
20+
21+
public Map<Operation, Array<AclRule>> load(Path aclConfigPath) {
22+
if (Files.notExists(aclConfigPath)) {
23+
throw new AclConfigurationException("Config file:[%s] doesn't exist".formatted(aclConfigPath));
24+
}
25+
String content;
26+
try {
27+
content = Files.readString(aclConfigPath);
28+
} catch (IOException e) {
29+
throw new AclConfigurationException("Failed to read ACL file:[%s]".formatted(aclConfigPath), e);
30+
}
31+
Array<AclRule> rules = new ArrayBuilder<>(AclRule.class)
32+
.add(parser.parse(content))
33+
.build();
34+
return RuleContainerBuilder.groupRulesByOperation(rules);
35+
}
36+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package javasabr.mqtt.acl.mug.dsl.parser;
2+
3+
import com.google.common.labs.parse.Parser;
4+
import com.google.mu.util.CharPredicate;
5+
import java.util.List;
6+
import java.util.function.BiFunction;
7+
import java.util.stream.Collectors;
8+
import javasabr.mqtt.acl.engine.exception.AclConfigurationException;
9+
import javasabr.mqtt.acl.engine.model.condition.AllOfCondition;
10+
import javasabr.mqtt.acl.engine.model.condition.AnyOfCondition;
11+
import javasabr.mqtt.acl.engine.model.condition.ClientIdCondition;
12+
import javasabr.mqtt.acl.engine.model.condition.IpAddressCondition;
13+
import javasabr.mqtt.acl.engine.model.condition.MqttUserCondition;
14+
import javasabr.mqtt.acl.engine.model.condition.TopicCondition;
15+
import javasabr.mqtt.acl.engine.model.condition.UserNameCondition;
16+
import javasabr.mqtt.acl.engine.model.matcher.TopicFilterMatcher;
17+
import javasabr.mqtt.acl.engine.model.matcher.TopicMatcher;
18+
import javasabr.mqtt.acl.engine.model.matcher.TopicNameMatcher;
19+
import javasabr.mqtt.acl.engine.model.matcher.UserMatchers;
20+
import javasabr.mqtt.acl.engine.model.matcher.ValueMatcher;
21+
import javasabr.mqtt.acl.engine.model.matcher.dynamic.DynamicTopicMatcher;
22+
import javasabr.mqtt.acl.engine.model.rule.AclRule;
23+
import javasabr.mqtt.acl.engine.model.rule.AllowPublishAclRule;
24+
import javasabr.mqtt.acl.engine.model.rule.AllowSubscribeAclRule;
25+
import javasabr.mqtt.acl.engine.model.rule.DenyPublishAclRule;
26+
import javasabr.mqtt.acl.engine.model.rule.DenySubscribeAclRule;
27+
import javasabr.mqtt.model.topic.TopicFilter;
28+
import javasabr.mqtt.model.topic.TopicName;
29+
import javasabr.mqtt.model.topic.TopicValidator;
30+
import javasabr.rlib.collections.array.Array;
31+
import javasabr.rlib.collections.array.ArrayBuilder;
32+
import javasabr.rlib.collections.array.ArrayCollectors;
33+
import lombok.AccessLevel;
34+
import lombok.experimental.FieldDefaults;
35+
36+
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
37+
public class GaclParser {
38+
39+
CharPredicate whitespace = CharPredicate.anyOf(" \t\r\n");
40+
41+
Parser<String> string = Parser.anyOf(
42+
Parser.quotedByWithEscapes('"', '"', Parser.chars(1)),
43+
Parser.quotedByWithEscapes('\'', '\'', Parser.chars(1)));
44+
45+
Parser<ValueMatcher<String>> userMatcher = Parser.anyOf(
46+
Parser
47+
.word("startsWith")
48+
.then(string.between("(", ")"))
49+
.map(UserMatchers::startsWith),
50+
Parser
51+
.word("contains")
52+
.then(string.between("(", ")"))
53+
.map(UserMatchers::contains),
54+
Parser
55+
.word("eq")
56+
.then(string.between("(", ")"))
57+
.map(UserMatchers::eq),
58+
Parser
59+
.word("regex")
60+
.then(string.between("(", ")"))
61+
.map(UserMatchers::regex),
62+
Parser
63+
.word("anyValue")
64+
.followedBy("()")
65+
.thenReturn(ValueMatcher.MATCH_ANY_STRING));
66+
67+
Parser<String> identityType = Parser.anyOf(
68+
Parser.word("userName"),
69+
Parser.word("clientId"),
70+
Parser.word("ipAddress"));
71+
72+
Parser<String> identityBlockType = Parser.anyOf(
73+
Parser.word("userNames"),
74+
Parser.word("clientIds"),
75+
Parser.word("ipAddresses"));
76+
77+
Parser<List<MqttUserCondition>> userCondition = Parser.define(uc -> Parser.<List<MqttUserCondition>>anyOf(
78+
Parser
79+
.word("anyUser")
80+
.followedBy("()")
81+
.thenReturn(List.of(MqttUserCondition.MATCH_ANY)),
82+
Parser.sequence(identityType, userMatcher, (id, matcher) -> List.of(toUserCondition(id, matcher))),
83+
Parser.sequence(
84+
identityBlockType,
85+
userMatcher
86+
.atLeastOnce()
87+
.between("{", "}"),
88+
(id, matchers) -> matchers
89+
.stream()
90+
.map(matcher -> toUserCondition(id, matcher))
91+
.collect(Collectors.toList())),
92+
Parser
93+
.word("allOf")
94+
.then(uc
95+
.atLeastOnce()
96+
.between("{", "}")
97+
.map(lists -> {
98+
Array<MqttUserCondition> flat = lists
99+
.stream()
100+
.flatMap(List::stream)
101+
.collect(ArrayCollectors.toArray(MqttUserCondition.class));
102+
return List.of(new AllOfCondition(flat));
103+
})),
104+
Parser
105+
.word("anyOf")
106+
.then(uc
107+
.atLeastOnce()
108+
.between("{", "}")
109+
.map(lists -> {
110+
Array<MqttUserCondition> flat = lists
111+
.stream()
112+
.flatMap(List::stream)
113+
.collect(ArrayCollectors.toArray(MqttUserCondition.class));
114+
return List.of(new AnyOfCondition(flat));
115+
}))));
116+
117+
Parser<MqttUserCondition> usersSection = Parser
118+
.word("users")
119+
.then(userCondition
120+
.atLeastOnce()
121+
.between("{", "}")
122+
.map(lists -> {
123+
Array<MqttUserCondition> flat = lists
124+
.stream()
125+
.flatMap(List::stream)
126+
.collect(ArrayCollectors.toArray(MqttUserCondition.class));
127+
if (flat.isEmpty()) {
128+
return MqttUserCondition.MATCH_NONE;
129+
}
130+
if (flat.size() == 1) {
131+
return flat.get(0);
132+
}
133+
return new AnyOfCondition(flat);
134+
}));
135+
136+
Parser<TopicMatcher> topicMatcher = Parser.anyOf(
137+
Parser
138+
.word("eq")
139+
.then(string.between("(", ")"))
140+
.map(s -> {
141+
if (!TopicValidator.validateTopicName(s)) {
142+
throw new AclConfigurationException("Invalid topic name:[%s]".formatted(s));
143+
}
144+
return (TopicMatcher) new TopicNameMatcher(TopicName.valueOf(s));
145+
}),
146+
Parser
147+
.word("match")
148+
.then(string.between("(", ")"))
149+
.map(s -> {
150+
if (!TopicValidator.validateTopicFilter(s)) {
151+
throw new AclConfigurationException("Invalid topic filter:[%s]".formatted(s));
152+
}
153+
return (TopicMatcher) new TopicFilterMatcher(TopicFilter.valueOf(s));
154+
}),
155+
Parser
156+
.word("dynamic")
157+
.then(string.between("(", ")"))
158+
.map(s -> {
159+
try {
160+
return DynamicTopicMatcher.autoBuild(s);
161+
} catch (RuntimeException e) {
162+
throw new AclConfigurationException("Invalid dynamic topic pattern", e);
163+
}
164+
}),
165+
Parser
166+
.word("anyTopic")
167+
.followedBy("()")
168+
.thenReturn(TopicMatcher.MATCH_ANY));
169+
170+
Parser<Array<TopicMatcher>> topicsSection = Parser
171+
.word("topics")
172+
.then(topicMatcher
173+
.atLeastOnce()
174+
.between("{", "}")
175+
.map(matchers -> new ArrayBuilder<>(TopicMatcher.class).add(matchers))
176+
.map(ArrayBuilder::build));
177+
178+
Parser<AclRule> directive = Parser.anyOf(
179+
createDirective("allowPublish", AllowPublishAclRule::new),
180+
createDirective("denyPublish", DenyPublishAclRule::new),
181+
createDirective("allowSubscribe", AllowSubscribeAclRule::new),
182+
createDirective("denySubscribe", DenySubscribeAclRule::new));
183+
184+
public List<AclRule> parse(String input) {
185+
try {
186+
return directive
187+
.atLeastOnce()
188+
.parseSkipping(whitespace, input);
189+
} catch (Parser.ParseException e) {
190+
throw new AclConfigurationException("Syntax error at %s".formatted(toLineColumn(input, e.getSourceIndex())));
191+
}
192+
}
193+
194+
private Parser<AclRule> createDirective(
195+
String keyword,
196+
BiFunction<MqttUserCondition, TopicCondition, AclRule> factory) {
197+
return Parser
198+
.word(keyword)
199+
.then(Parser
200+
.sequence(usersSection, topicsSection, (uc, tm) -> factory.apply(uc, new TopicCondition(tm)))
201+
.between("{", "}"));
202+
}
203+
204+
private MqttUserCondition toUserCondition(String identityType, ValueMatcher<String> matcher) {
205+
return switch (identityType) {
206+
case "userName", "userNames" -> new UserNameCondition(matcher);
207+
case "clientId", "clientIds" -> new ClientIdCondition(matcher);
208+
case "ipAddress", "ipAddresses" -> new IpAddressCondition(matcher);
209+
default -> throw new AclConfigurationException("Unknown identity type: %s".formatted(identityType));
210+
};
211+
}
212+
213+
private String toLineColumn(String input, int index) {
214+
int line = 1;
215+
int col = 1;
216+
for (int i = 0; i < index && i < input.length(); i++) {
217+
if (input.charAt(i) == '\n') {
218+
line++;
219+
col = 1;
220+
} else {
221+
col++;
222+
}
223+
}
224+
return "line %s, column %s".formatted(line, col);
225+
}
226+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package javasabr.mqtt.acl.mug.dsl.loader
2+
3+
import javasabr.mqtt.acl.engine.exception.AclConfigurationException
4+
import javasabr.mqtt.acl.mug.dsl.parser.GaclParser
5+
import spock.lang.Specification
6+
7+
import java.nio.file.Files
8+
import java.nio.file.Path
9+
import java.nio.file.Paths
10+
11+
class AclRulesLoaderTest extends Specification {
12+
13+
GaclParser parser
14+
AclRulesLoader loader
15+
16+
def setup() {
17+
parser = Mock(GaclParser)
18+
loader = new AclRulesLoader(parser)
19+
}
20+
21+
def "should throw exception when file does not exist"() {
22+
given:
23+
Path nonExistent = Paths.get("non-existent.gacl")
24+
25+
when:
26+
loader.load(nonExistent)
27+
28+
then:
29+
thrown(AclConfigurationException)
30+
}
31+
32+
def "should throw exception when file read fails"() {
33+
given:
34+
Path unreadableFile = Files.createTempFile("unreadable", ".gacl")
35+
// Make the file unreadable to trigger IOException during Files.readString
36+
unreadableFile.toFile().setReadable(false)
37+
38+
when:
39+
loader.load(unreadableFile)
40+
41+
then:
42+
thrown(AclConfigurationException)
43+
44+
cleanup:
45+
unreadableFile.toFile().setReadable(true)
46+
Files.deleteIfExists(unreadableFile)
47+
}
48+
49+
def "should load rules successfully"() {
50+
given:
51+
Path tempFile = Files.createTempFile("test", ".gacl")
52+
Files.writeString(tempFile, "mock content")
53+
54+
and:
55+
parser.parse("mock content") >> []
56+
57+
when:
58+
def rules = loader.load(tempFile)
59+
60+
then:
61+
rules != null
62+
63+
cleanup:
64+
Files.deleteIfExists(tempFile)
65+
}
66+
}

0 commit comments

Comments
 (0)