Skip to content

Commit 885ddb3

Browse files
authored
Use static analysis to prevent users from calling sys.exit-like funcs (#159)
* Add javaparser * Create ExitCallDetector.java * Check for exit calls in StageTest * fix
1 parent 1d35bb5 commit 885ddb3

3 files changed

Lines changed: 231 additions & 0 deletions

File tree

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies {
2727
api 'org.assertj:assertj-swing-junit:3.17.1'
2828
api 'org.apache.httpcomponents:httpclient:4.5.14'
2929
api 'com.google.code.gson:gson:2.10.1'
30+
api 'com.github.javaparser:javaparser-core:3.25.5'
3031

3132
compileOnly 'org.projectlombok:lombok:1.18.30'
3233
annotationProcessor 'org.projectlombok:lombok:1.18.30'
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package org.hyperskill.hstest.common;
2+
3+
import com.github.javaparser.JavaParser;
4+
import com.github.javaparser.ParseResult;
5+
import com.github.javaparser.ast.CompilationUnit;
6+
import com.github.javaparser.ast.expr.MethodCallExpr;
7+
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
8+
9+
import java.io.File;
10+
import java.io.IOException;
11+
import java.nio.file.Files;
12+
import java.nio.file.Path;
13+
import java.util.ArrayList;
14+
import java.util.List;
15+
import java.util.stream.Collectors;
16+
import java.util.stream.Stream;
17+
18+
/**
19+
* Detects forbidden exit calls in user code that could terminate the JVM.
20+
* This includes System.exit(), exitProcess(), Runtime.exit(), and Runtime.halt().
21+
*/
22+
public class ExitCallDetector {
23+
24+
/**
25+
* Result of the exit call detection
26+
*/
27+
public static class DetectionResult {
28+
private final boolean hasExitCalls;
29+
private final List<String> violations;
30+
31+
public DetectionResult(boolean hasExitCalls, List<String> violations) {
32+
this.hasExitCalls = hasExitCalls;
33+
this.violations = violations;
34+
}
35+
36+
public boolean hasExitCalls() {
37+
return hasExitCalls;
38+
}
39+
40+
public List<String> getViolations() {
41+
return violations;
42+
}
43+
44+
public String getFormattedMessage() {
45+
if (!hasExitCalls) {
46+
return null;
47+
}
48+
StringBuilder sb = new StringBuilder();
49+
sb.append("Your code contains forbidden exit calls that would terminate the test execution:\n\n");
50+
for (String violation : violations) {
51+
sb.append(" ").append(violation).append("\n");
52+
}
53+
sb.append("\nPlease remove all System.exit(), exitProcess(), Runtime.exit(), and Runtime.halt() calls from your code.");
54+
return sb.toString();
55+
}
56+
}
57+
58+
/**
59+
* Analyzes a single Java source file for exit calls
60+
*/
61+
public static DetectionResult analyzeFile(File file) throws IOException {
62+
String content = Files.readString(file.toPath());
63+
return analyzeSourceCode(content, file.getName());
64+
}
65+
66+
/**
67+
* Analyzes Java source code string for exit calls
68+
*/
69+
public static DetectionResult analyzeSourceCode(String sourceCode, String fileName) {
70+
List<String> violations = new ArrayList<>();
71+
72+
// First, do a simple string-based check as a fast pre-filter
73+
if (!containsSimpleExitPattern(sourceCode)) {
74+
return new DetectionResult(false, violations);
75+
}
76+
77+
// If simple check finds something, do detailed AST analysis
78+
try {
79+
JavaParser parser = new JavaParser();
80+
ParseResult<CompilationUnit> parseResult = parser.parse(sourceCode);
81+
82+
if (parseResult.isSuccessful() && parseResult.getResult().isPresent()) {
83+
CompilationUnit cu = parseResult.getResult().get();
84+
ExitCallVisitor visitor = new ExitCallVisitor(fileName);
85+
visitor.visit(cu, violations);
86+
}
87+
} catch (Exception e) {
88+
// If parsing fails, fall back to simple string check
89+
violations.addAll(simpleStringAnalysis(sourceCode, fileName));
90+
}
91+
92+
return new DetectionResult(!violations.isEmpty(), violations);
93+
}
94+
95+
/**
96+
* Analyzes all Java files in a directory recursively
97+
*/
98+
public static DetectionResult analyzeDirectory(Path directory) throws IOException {
99+
List<String> allViolations = new ArrayList<>();
100+
101+
try (Stream<Path> paths = Files.walk(directory)) {
102+
List<Path> javaFiles = paths
103+
.filter(Files::isRegularFile)
104+
.filter(p -> p.toString().endsWith(".java") || p.toString().endsWith(".kt"))
105+
.collect(Collectors.toList());
106+
107+
for (Path path : javaFiles) {
108+
DetectionResult result = analyzeFile(path.toFile());
109+
if (result.hasExitCalls()) {
110+
allViolations.addAll(result.getViolations());
111+
}
112+
}
113+
}
114+
115+
return new DetectionResult(!allViolations.isEmpty(), allViolations);
116+
}
117+
118+
/**
119+
* Fast string-based pre-filter to avoid expensive AST parsing when not needed
120+
*/
121+
private static boolean containsSimpleExitPattern(String sourceCode) {
122+
return sourceCode.contains("exit") || sourceCode.contains("halt");
123+
}
124+
125+
/**
126+
* Simple string-based analysis as fallback
127+
*/
128+
private static List<String> simpleStringAnalysis(String sourceCode, String fileName) {
129+
List<String> violations = new ArrayList<>();
130+
String[] lines = sourceCode.split("\n");
131+
132+
for (int i = 0; i < lines.length; i++) {
133+
String line = lines[i].trim();
134+
135+
// Skip comments
136+
if (line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) {
137+
continue;
138+
}
139+
140+
if (line.contains("System.exit")) {
141+
violations.add(fileName + " (line " + (i + 1) + "): System.exit() call detected");
142+
}
143+
if (line.contains("exitProcess")) {
144+
violations.add(fileName + " (line " + (i + 1) + "): exitProcess() call detected");
145+
}
146+
if (line.contains("Runtime") && line.contains(".exit")) {
147+
violations.add(fileName + " (line " + (i + 1) + "): Runtime.exit() call detected");
148+
}
149+
if (line.contains("Runtime") && line.contains(".halt")) {
150+
violations.add(fileName + " (line " + (i + 1) + "): Runtime.halt() call detected");
151+
}
152+
}
153+
154+
return violations;
155+
}
156+
157+
/**
158+
* AST visitor to find method calls
159+
*/
160+
private static class ExitCallVisitor extends VoidVisitorAdapter<List<String>> {
161+
private final String fileName;
162+
163+
public ExitCallVisitor(String fileName) {
164+
this.fileName = fileName;
165+
}
166+
167+
@Override
168+
public void visit(MethodCallExpr methodCall, List<String> violations) {
169+
super.visit(methodCall, violations);
170+
171+
String methodName = methodCall.getNameAsString();
172+
173+
// Check for exit, exitProcess, or halt calls
174+
if (methodName.equals("exit") || methodName.equals("exitProcess") || methodName.equals("halt")) {
175+
176+
// Check if it's System.exit()
177+
if (methodCall.getScope().isPresent()) {
178+
String scope = methodCall.getScope().get().toString();
179+
180+
if (scope.equals("System")) {
181+
int line = methodCall.getBegin().map(pos -> pos.line).orElse(0);
182+
violations.add(fileName + " (line " + line + "): System.exit() call detected");
183+
}
184+
else if (scope.contains("Runtime")) {
185+
int line = methodCall.getBegin().map(pos -> pos.line).orElse(0);
186+
if (methodName.equals("exit")) {
187+
violations.add(fileName + " (line " + line + "): Runtime.exit() call detected");
188+
} else if (methodName.equals("halt")) {
189+
violations.add(fileName + " (line " + line + "): Runtime.halt() call detected");
190+
}
191+
}
192+
}
193+
// Kotlin's exitProcess() has no scope
194+
else if (methodName.equals("exitProcess")) {
195+
int line = methodCall.getBegin().map(pos -> pos.line).orElse(0);
196+
violations.add(fileName + " (line " + line + "): exitProcess() call detected");
197+
}
198+
}
199+
}
200+
}
201+
}

src/main/java/org/hyperskill/hstest/stage/StageTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import lombok.Getter;
44
import org.hyperskill.hstest.checker.CheckLibraryVersion;
5+
import org.hyperskill.hstest.common.ExitCallDetector;
56
import org.hyperskill.hstest.common.FileUtils;
67
import org.hyperskill.hstest.common.ReflectionUtils;
78
import org.hyperskill.hstest.dynamic.ClassSearcher;
@@ -22,7 +23,10 @@
2223
import org.junit.runner.JUnitCore;
2324
import org.junit.runner.Result;
2425

26+
import java.io.File;
27+
import java.io.IOException;
2528
import java.lang.reflect.Modifier;
29+
import java.nio.file.Path;
2630
import java.util.ArrayList;
2731
import java.util.List;
2832
import java.util.stream.Collectors;
@@ -137,6 +141,28 @@ private void printTestNum(int num) {
137141
OutputHandler.print(RED_BOLD + "\nStart test " + num + totalTests + RESET);
138142
}
139143

144+
/**
145+
* Checks user code for forbidden exit calls before running tests
146+
*/
147+
private void checkForExitCalls() {
148+
// Only check Java files (other languages handled differently in Docker)
149+
if (!hasJavaSolution(FileUtils.cwd())) {
150+
return;
151+
}
152+
153+
try {
154+
Path currentDir = new File(FileUtils.cwd()).toPath();
155+
ExitCallDetector.DetectionResult result = ExitCallDetector.analyzeDirectory(currentDir);
156+
157+
if (result.hasExitCalls()) {
158+
throw new WrongAnswer(result.getFormattedMessage());
159+
}
160+
} catch (IOException e) {
161+
// If we can't read files, just continue (fail safely)
162+
// The SecurityManager will catch it at runtime if needed
163+
}
164+
}
165+
140166
@Test
141167
public final void start() {
142168
int currTest = 0;
@@ -149,6 +175,9 @@ public final void start() {
149175
ReflectionUtils.setupCwd(this);
150176
}
151177

178+
// Check for exit calls before running any tests
179+
checkForExitCalls();
180+
152181
List<TestRun> testRuns = initTests();
153182

154183
for (TestRun testRun : testRuns) {

0 commit comments

Comments
 (0)