diff --git a/build.gradle b/build.gradle index 1e210c20..6a30548f 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ dependencies { api 'org.assertj:assertj-swing-junit:3.17.1' api 'org.apache.httpcomponents:httpclient:4.5.14' api 'com.google.code.gson:gson:2.10.1' + api 'com.github.javaparser:javaparser-core:3.25.5' compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' diff --git a/src/main/java/org/hyperskill/hstest/common/ExitCallDetector.java b/src/main/java/org/hyperskill/hstest/common/ExitCallDetector.java new file mode 100644 index 00000000..2405dcad --- /dev/null +++ b/src/main/java/org/hyperskill/hstest/common/ExitCallDetector.java @@ -0,0 +1,201 @@ +package org.hyperskill.hstest.common; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParseResult; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.visitor.VoidVisitorAdapter; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Detects forbidden exit calls in user code that could terminate the JVM. + * This includes System.exit(), exitProcess(), Runtime.exit(), and Runtime.halt(). + */ +public class ExitCallDetector { + + /** + * Result of the exit call detection + */ + public static class DetectionResult { + private final boolean hasExitCalls; + private final List violations; + + public DetectionResult(boolean hasExitCalls, List violations) { + this.hasExitCalls = hasExitCalls; + this.violations = violations; + } + + public boolean hasExitCalls() { + return hasExitCalls; + } + + public List getViolations() { + return violations; + } + + public String getFormattedMessage() { + if (!hasExitCalls) { + return null; + } + StringBuilder sb = new StringBuilder(); + sb.append("Your code contains forbidden exit calls that would terminate the test execution:\n\n"); + for (String violation : violations) { + sb.append(" ").append(violation).append("\n"); + } + sb.append("\nPlease remove all System.exit(), exitProcess(), Runtime.exit(), and Runtime.halt() calls from your code."); + return sb.toString(); + } + } + + /** + * Analyzes a single Java source file for exit calls + */ + public static DetectionResult analyzeFile(File file) throws IOException { + String content = Files.readString(file.toPath()); + return analyzeSourceCode(content, file.getName()); + } + + /** + * Analyzes Java source code string for exit calls + */ + public static DetectionResult analyzeSourceCode(String sourceCode, String fileName) { + List violations = new ArrayList<>(); + + // First, do a simple string-based check as a fast pre-filter + if (!containsSimpleExitPattern(sourceCode)) { + return new DetectionResult(false, violations); + } + + // If simple check finds something, do detailed AST analysis + try { + JavaParser parser = new JavaParser(); + ParseResult parseResult = parser.parse(sourceCode); + + if (parseResult.isSuccessful() && parseResult.getResult().isPresent()) { + CompilationUnit cu = parseResult.getResult().get(); + ExitCallVisitor visitor = new ExitCallVisitor(fileName); + visitor.visit(cu, violations); + } + } catch (Exception e) { + // If parsing fails, fall back to simple string check + violations.addAll(simpleStringAnalysis(sourceCode, fileName)); + } + + return new DetectionResult(!violations.isEmpty(), violations); + } + + /** + * Analyzes all Java files in a directory recursively + */ + public static DetectionResult analyzeDirectory(Path directory) throws IOException { + List allViolations = new ArrayList<>(); + + try (Stream paths = Files.walk(directory)) { + List javaFiles = paths + .filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".java") || p.toString().endsWith(".kt")) + .collect(Collectors.toList()); + + for (Path path : javaFiles) { + DetectionResult result = analyzeFile(path.toFile()); + if (result.hasExitCalls()) { + allViolations.addAll(result.getViolations()); + } + } + } + + return new DetectionResult(!allViolations.isEmpty(), allViolations); + } + + /** + * Fast string-based pre-filter to avoid expensive AST parsing when not needed + */ + private static boolean containsSimpleExitPattern(String sourceCode) { + return sourceCode.contains("exit") || sourceCode.contains("halt"); + } + + /** + * Simple string-based analysis as fallback + */ + private static List simpleStringAnalysis(String sourceCode, String fileName) { + List violations = new ArrayList<>(); + String[] lines = sourceCode.split("\n"); + + for (int i = 0; i < lines.length; i++) { + String line = lines[i].trim(); + + // Skip comments + if (line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) { + continue; + } + + if (line.contains("System.exit")) { + violations.add(fileName + " (line " + (i + 1) + "): System.exit() call detected"); + } + if (line.contains("exitProcess")) { + violations.add(fileName + " (line " + (i + 1) + "): exitProcess() call detected"); + } + if (line.contains("Runtime") && line.contains(".exit")) { + violations.add(fileName + " (line " + (i + 1) + "): Runtime.exit() call detected"); + } + if (line.contains("Runtime") && line.contains(".halt")) { + violations.add(fileName + " (line " + (i + 1) + "): Runtime.halt() call detected"); + } + } + + return violations; + } + + /** + * AST visitor to find method calls + */ + private static class ExitCallVisitor extends VoidVisitorAdapter> { + private final String fileName; + + public ExitCallVisitor(String fileName) { + this.fileName = fileName; + } + + @Override + public void visit(MethodCallExpr methodCall, List violations) { + super.visit(methodCall, violations); + + String methodName = methodCall.getNameAsString(); + + // Check for exit, exitProcess, or halt calls + if (methodName.equals("exit") || methodName.equals("exitProcess") || methodName.equals("halt")) { + + // Check if it's System.exit() + if (methodCall.getScope().isPresent()) { + String scope = methodCall.getScope().get().toString(); + + if (scope.equals("System")) { + int line = methodCall.getBegin().map(pos -> pos.line).orElse(0); + violations.add(fileName + " (line " + line + "): System.exit() call detected"); + } + else if (scope.contains("Runtime")) { + int line = methodCall.getBegin().map(pos -> pos.line).orElse(0); + if (methodName.equals("exit")) { + violations.add(fileName + " (line " + line + "): Runtime.exit() call detected"); + } else if (methodName.equals("halt")) { + violations.add(fileName + " (line " + line + "): Runtime.halt() call detected"); + } + } + } + // Kotlin's exitProcess() has no scope + else if (methodName.equals("exitProcess")) { + int line = methodCall.getBegin().map(pos -> pos.line).orElse(0); + violations.add(fileName + " (line " + line + "): exitProcess() call detected"); + } + } + } + } +} diff --git a/src/main/java/org/hyperskill/hstest/stage/StageTest.java b/src/main/java/org/hyperskill/hstest/stage/StageTest.java index c3982826..06b62d29 100644 --- a/src/main/java/org/hyperskill/hstest/stage/StageTest.java +++ b/src/main/java/org/hyperskill/hstest/stage/StageTest.java @@ -2,6 +2,7 @@ import lombok.Getter; import org.hyperskill.hstest.checker.CheckLibraryVersion; +import org.hyperskill.hstest.common.ExitCallDetector; import org.hyperskill.hstest.common.FileUtils; import org.hyperskill.hstest.common.ReflectionUtils; import org.hyperskill.hstest.dynamic.ClassSearcher; @@ -22,7 +23,10 @@ import org.junit.runner.JUnitCore; import org.junit.runner.Result; +import java.io.File; +import java.io.IOException; import java.lang.reflect.Modifier; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -137,6 +141,28 @@ private void printTestNum(int num) { OutputHandler.print(RED_BOLD + "\nStart test " + num + totalTests + RESET); } + /** + * Checks user code for forbidden exit calls before running tests + */ + private void checkForExitCalls() { + // Only check Java files (other languages handled differently in Docker) + if (!hasJavaSolution(FileUtils.cwd())) { + return; + } + + try { + Path currentDir = new File(FileUtils.cwd()).toPath(); + ExitCallDetector.DetectionResult result = ExitCallDetector.analyzeDirectory(currentDir); + + if (result.hasExitCalls()) { + throw new WrongAnswer(result.getFormattedMessage()); + } + } catch (IOException e) { + // If we can't read files, just continue (fail safely) + // The SecurityManager will catch it at runtime if needed + } + } + @Test public final void start() { int currTest = 0; @@ -149,6 +175,9 @@ public final void start() { ReflectionUtils.setupCwd(this); } + // Check for exit calls before running any tests + checkForExitCalls(); + List testRuns = initTests(); for (TestRun testRun : testRuns) {