mirror of
https://github.com/radareorg/r4ghidra.git
synced 2026-11-07 06:36:02 -10:00
Fix CI by adding missing sandbox class and regression tests
This commit is contained in:
1 parent
0aef416048
commit
74e6a85dec
4 files changed
+475
No files matched your search
@@ -0,0 +1,134 @@
|
||||
package r4ghidra.repl.sandbox;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/** Capability policy for REPL operations; grains name allowed resources, as in radare2. */
|
||||
public final class R2Sandbox {
|
||||
public static final int NONE = 0;
|
||||
public static final int SOCKET = 1;
|
||||
public static final int DISK = 2;
|
||||
public static final int FILES = 4;
|
||||
public static final int EXEC = 8;
|
||||
public static final int ENVIRON = 16;
|
||||
public static final int NETWORK = 32;
|
||||
public static final int HIDDEN = 64;
|
||||
public static final int ALL = SOCKET | DISK | FILES | EXEC | ENVIRON | NETWORK | HIDDEN;
|
||||
|
||||
private static final Map<String, Integer> GRAINS = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
GRAINS.put("socket", SOCKET);
|
||||
GRAINS.put("disk", DISK);
|
||||
GRAINS.put("files", FILES);
|
||||
GRAINS.put("exec", EXEC);
|
||||
GRAINS.put("environ", ENVIRON);
|
||||
GRAINS.put("network", NETWORK);
|
||||
GRAINS.put("hidden", HIDDEN);
|
||||
}
|
||||
|
||||
private boolean enabled = true;
|
||||
private int grains = NONE;
|
||||
private boolean locked;
|
||||
|
||||
public synchronized boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public synchronized int getGrains() {
|
||||
return grains;
|
||||
}
|
||||
|
||||
public synchronized void setEnabled(boolean value) {
|
||||
if (locked && !value) {
|
||||
throw new IllegalArgumentException("Cannot disable a locked sandbox");
|
||||
}
|
||||
enabled = value;
|
||||
}
|
||||
|
||||
public synchronized void setGrains(int value) {
|
||||
if ((value & ~ALL) != 0) {
|
||||
throw new IllegalArgumentException("Unknown sandbox grain bits");
|
||||
}
|
||||
if (locked && (value & grains) != value) {
|
||||
throw new IllegalArgumentException("Cannot increase locked sandbox permissions");
|
||||
}
|
||||
grains = value;
|
||||
}
|
||||
|
||||
/** Freeze the current permissions as a ceiling for untrusted commands, such as HTTP requests. */
|
||||
public synchronized void lock() {
|
||||
enabled = true;
|
||||
locked = true;
|
||||
}
|
||||
|
||||
public synchronized boolean isRestricted(int resources) {
|
||||
return enabled && (grains & resources) != resources;
|
||||
}
|
||||
|
||||
/** Parse comma-separated permissions, with a leading ! starting from all permissions. */
|
||||
public static int parseGrains(String expression) {
|
||||
String expr = expression.trim().toLowerCase(Locale.ROOT);
|
||||
int result = expr.startsWith("!") ? ALL : NONE;
|
||||
for (String part : expr.split(",", -1)) {
|
||||
String name = part.trim();
|
||||
boolean remove = name.startsWith("!");
|
||||
if (remove) {
|
||||
name = name.substring(1).trim();
|
||||
}
|
||||
Integer grain =
|
||||
switch (name) {
|
||||
case "all" -> ALL;
|
||||
case "none" -> NONE;
|
||||
case "file" -> FILES;
|
||||
default -> GRAINS.get(name);
|
||||
};
|
||||
if (grain == null) {
|
||||
throw new IllegalArgumentException("Invalid sandbox grain: " + part.trim());
|
||||
}
|
||||
if (remove) {
|
||||
result &= ~grain;
|
||||
} else if (grain == NONE) {
|
||||
result = NONE;
|
||||
} else {
|
||||
result |= grain;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String formatGrains(int grains) {
|
||||
if (grains == NONE) {
|
||||
return "none";
|
||||
}
|
||||
if (grains == ALL) {
|
||||
return "all";
|
||||
}
|
||||
StringBuilder result = new StringBuilder();
|
||||
GRAINS.forEach(
|
||||
(name, grain) -> {
|
||||
if ((grains & grain) != 0) {
|
||||
if (result.length() > 0) {
|
||||
result.append(',');
|
||||
}
|
||||
result.append(name);
|
||||
}
|
||||
});
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
public static String getHelp() {
|
||||
return "Usage: e cfg.sandbox.grain=permission[,permission...]\n"
|
||||
+ " all / none allow all / no resources\n"
|
||||
+ " files read files, list directories and inspect file existence\n"
|
||||
+ " disk write, append and delete files (also requires files)\n"
|
||||
+ " exec execute shell commands, external pipes and process exit\n"
|
||||
+ " environ read or modify environment variables\n"
|
||||
+ " hidden access dotfiles and hidden directories (also requires files)\n"
|
||||
+ " socket/network reserved for socket operations / non-local network access\n"
|
||||
+ " !name remove a permission; leading ! starts from all\n"
|
||||
+ " Example: e cfg.sandbox.grain=files,disk\n"
|
||||
+ " JavaScript requires all permissions because it has unrestricted JVM access.\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package r4ghidra.repl.handlers;
|
||||
|
||||
import java.util.List;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONTokener;
|
||||
import r4ghidra.repl.R2Command;
|
||||
import r4ghidra.repl.R2Context;
|
||||
|
||||
/** Regression tests for JSON output from JavaScript values. */
|
||||
public final class R2JsCommandHandlerTest {
|
||||
public static void run(R2Context context) throws Exception {
|
||||
int sandboxFlags = context.getSandboxFlags();
|
||||
context.setSandboxFlags(R2Context.R_SANDBOX_GRAIN_NONE);
|
||||
try {
|
||||
R2JsCommandHandler handler = new R2JsCommandHandler();
|
||||
for (String value :
|
||||
List.of("", "plain", "\"quoted\"", "C:\\new\\test\\", "\\n", "λ 😀", "\u2028\u2029")) {
|
||||
checkString(handler, context, JSONObject.quote(value), value);
|
||||
}
|
||||
for (int i = 0; i < 32; i++) {
|
||||
checkString(handler, context, "String.fromCharCode(" + i + ")", String.valueOf((char) i));
|
||||
}
|
||||
checkString(
|
||||
handler,
|
||||
context,
|
||||
"String.fromCharCode(92,34,44,34,105,110,106,101,99,116,101,100,34,58,116,114,117,101)",
|
||||
"\\\",\"injected\":true");
|
||||
|
||||
check(json(handler, context, "42").equals("42\n"), "number output");
|
||||
check(json(handler, context, "true").equals("true\n"), "boolean output");
|
||||
String nested = json(handler, context, "({text: 'a\\nb\\tc\\\\d', values: [1, true, null]})");
|
||||
JSONObject object = new JSONObject(nested);
|
||||
check(object.getString("text").equals("a\nb\tc\\d"), "nested string");
|
||||
check(object.getJSONArray("values").similar(new JSONArray("[1,true,null]")), "nested array");
|
||||
|
||||
check(
|
||||
new JSONArray(json(handler, context, "['a\\nb', 'c\\\\d']"))
|
||||
.toList()
|
||||
.equals(List.of("a\nb", "c\\d")),
|
||||
"array output");
|
||||
|
||||
// Serializing an object must never evaluate its textual representation as source code.
|
||||
json(handler, context, "var serializedTextExecuted = false");
|
||||
json(handler, context, "new java.lang.StringBuilder('(serializedTextExecuted = true)')");
|
||||
check(
|
||||
json(handler, context, "serializedTextExecuted").equals("false\n"),
|
||||
"object text was evaluated");
|
||||
|
||||
// Circular values retain the handler's string fallback.
|
||||
checkString(
|
||||
handler, context, "var cyclic = {}; cyclic.self = cyclic; cyclic", "[object Object]");
|
||||
|
||||
// Force the fallback with a host value whose textual representation needs escaping.
|
||||
checkString(
|
||||
handler,
|
||||
context,
|
||||
"JSON.stringify = function() { throw new Error('forced'); };"
|
||||
+ "new java.lang.StringBuilder('a\\nb\\tc\\\\d\\\"')",
|
||||
"a\nb\tc\\d\"");
|
||||
System.out.println("JavaScript JSON regression tests passed");
|
||||
} finally {
|
||||
context.setSandboxFlags(sandboxFlags);
|
||||
}
|
||||
}
|
||||
|
||||
private static String json(R2JsCommandHandler handler, R2Context context, String script)
|
||||
throws Exception {
|
||||
return handler.execute(new R2Command("js", "j", List.of(script), null), context);
|
||||
}
|
||||
|
||||
private static void checkString(
|
||||
R2JsCommandHandler handler, R2Context context, String script, String expected)
|
||||
throws Exception {
|
||||
String output = json(handler, context, script);
|
||||
check(output.endsWith("\n"), "missing output newline");
|
||||
for (int i = 0; i < output.length() - 1; i++) {
|
||||
check(output.charAt(i) >= 32, "unescaped control character in JSON output");
|
||||
}
|
||||
JSONTokener tokener = new JSONTokener(output);
|
||||
check(expected.equals(tokener.nextValue()), "JSON string did not round-trip: " + output);
|
||||
check(tokener.nextClean() == 0, "extra data after JSON string: " + output);
|
||||
}
|
||||
|
||||
private static void check(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package r4ghidra.repl.sandbox;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import r4ghidra.repl.R2Context;
|
||||
import r4ghidra.repl.R2REPLImpl;
|
||||
import r4ghidra.repl.config.R2EvalConfig;
|
||||
import r4ghidra.repl.filesystem.R2FileSystem;
|
||||
import r4ghidra.repl.filesystem.R2FileSystemException;
|
||||
import r4ghidra.repl.handlers.R2JsCommandHandler;
|
||||
|
||||
/** Exercises the real parser and filesystem against temporary files and a Ghidra context. */
|
||||
public final class R2SandboxIntegrationTest {
|
||||
public static void run(R2REPLImpl repl, Path directory) throws Exception {
|
||||
Files.createDirectories(directory);
|
||||
Path plain = directory.resolve("plain.txt");
|
||||
Files.writeString(plain, "fixture");
|
||||
Path script = directory.resolve("script with spaces.r2");
|
||||
Files.writeString(script, "echo from-script\n");
|
||||
Path js = directory.resolve("script.r2.js");
|
||||
Files.writeString(js, "1+1");
|
||||
R2Context context = repl.getContext();
|
||||
R2EvalConfig config = context.getEvalConfig();
|
||||
R2FileSystem fs = context.getFileSystem();
|
||||
repl.registerCommand("js", new R2JsCommandHandler());
|
||||
|
||||
check(config.get("cfg.sandbox.grain").equals("none"), "default grain");
|
||||
for (String command :
|
||||
List.of(
|
||||
"!echo denied",
|
||||
"!!echo denied",
|
||||
".!echo denied",
|
||||
"echo denied | cat",
|
||||
"%",
|
||||
"%*",
|
||||
"%j",
|
||||
"%PATH",
|
||||
"%R4GHIDRA_SANDBOX_TEST=denied",
|
||||
". '" + script + "'",
|
||||
". '" + js + "'",
|
||||
"js 1+1",
|
||||
"q 23",
|
||||
"q!!")) {
|
||||
denied(repl, command);
|
||||
}
|
||||
check(repl.executeCommand("!").contains("echo denied"), "history stays available");
|
||||
check(!fs.fileExists(plain.toString()), "blocked metadata");
|
||||
blocked(() -> fs.readFile(plain.toString()));
|
||||
blocked(() -> fs.readBytes(plain.toString()));
|
||||
blocked(() -> fs.listFiles(directory.toString()));
|
||||
|
||||
// Memory files remain usable with no host permissions, including script loading.
|
||||
fs.writeFile("$sandbox", "echo memory-script\n");
|
||||
check(repl.executeCommand(". $sandbox").equals("memory-script\n"), "memory script");
|
||||
repl.executeCommand("echo memory > $output");
|
||||
check(fs.readFile("$output").equals("memory\n"), "memory redirection");
|
||||
fs.writeBytes("$binary", new byte[] {0, (byte) 255});
|
||||
check(fs.readBytes("$binary")[1] == (byte) 255, "memory bytes");
|
||||
|
||||
check(repl.executeCommand("e cfg.sandbox.grain=?").contains("!name"), "grain help");
|
||||
check(config.get("cfg.sandbox.grain").equals("none"), "help does not change policy");
|
||||
repl.executeCommand("e sandbox.grain=files");
|
||||
check(config.get("sandbox.grain").equals("files"), "short alias");
|
||||
check(config.get("cfg.sandbox.grain").equals("files"), "canonical grain");
|
||||
check(fs.readFile(plain.toString()).equals("fixture"), "files permits text reads");
|
||||
check(fs.readBytes(plain.toString()).length == 7, "files permits binary reads");
|
||||
check(fs.fileExists(plain.toString()), "files permits metadata");
|
||||
check(fs.listFiles(directory.toString()).contains(plain.toString()), "files permits listing");
|
||||
check(repl.executeCommand(". '" + script + "'").equals("from-script\n"), "disk script");
|
||||
blocked(() -> fs.writeFile(plain.toString(), "changed"));
|
||||
blocked(() -> fs.writeBytes(plain.toString(), new byte[] {1}));
|
||||
blocked(() -> fs.appendFile(plain.toString(), "changed"));
|
||||
blocked(() -> fs.deleteFile(plain.toString()));
|
||||
denied(repl, "echo changed > " + plain);
|
||||
denied(repl, "echo changed >> " + plain);
|
||||
check(Files.readString(plain).equals("fixture"), "blocked writes leave file intact");
|
||||
denied(repl, "js 1+1");
|
||||
|
||||
// Reject invalid values before they can change either config or the active policy.
|
||||
for (String invalid : List.of("rw", "files,garbage", "files,", "")) {
|
||||
String result = repl.executeCommand("e cfg.sandbox.grain=" + invalid);
|
||||
check(result.startsWith("Error:"), "invalid grain rejected: " + result);
|
||||
check(config.get("cfg.sandbox.grain").equals("files"), "invalid grain preserves config");
|
||||
blocked(() -> fs.writeFile(plain.toString(), "changed"));
|
||||
}
|
||||
check(repl.executeCommand("e cfg.sandbox=typo").startsWith("Error:"), "invalid boolean");
|
||||
check(config.getBoolean("cfg.sandbox"), "invalid boolean preserves sandbox");
|
||||
|
||||
repl.executeCommand("e cfg.sandbox.grain = files, disk");
|
||||
fs.writeFile(plain.toString(), "one");
|
||||
fs.appendFile(plain.toString(), "two");
|
||||
check(fs.readFile(plain.toString()).equals("onetwo"), "disk writes and append");
|
||||
fs.writeBytes(plain.toString(), new byte[] {0, (byte) 255});
|
||||
check(fs.readBytes(plain.toString())[1] == (byte) 255, "disk bytes");
|
||||
fs.deleteFile(plain.toString());
|
||||
check(!Files.exists(plain), "disk deletion");
|
||||
repl.executeCommand("e cfg.sandbox.grain=disk");
|
||||
blocked(() -> fs.writeFile(plain.toString(), "changed"));
|
||||
check(!Files.exists(plain), "disk without files cannot create files");
|
||||
|
||||
testHiddenFiles(repl, directory);
|
||||
|
||||
repl.executeCommand("e cfg.sandbox.grain=exec");
|
||||
check(
|
||||
repl.executeCommand("!!printf '%s' 'hello world'").equals("hello world\n"),
|
||||
"shell arguments");
|
||||
check(repl.executeCommand("!true").contains("code: 0"), "interactive shell");
|
||||
check(repl.executeCommand(".!echo echo from-shell").equals("from-shell\n"), "shell script");
|
||||
check(repl.executeCommand("echo piped | cat").equals("piped\n"), "external pipe");
|
||||
denied(repl, "%PATH");
|
||||
denied(repl, "js 1+1");
|
||||
// Recheck the permission after the left command, before spawning the pipe process.
|
||||
denied(repl, "e cfg.sandbox.grain=none | cat");
|
||||
check(config.get("cfg.sandbox.grain").equals("none"), "pipe left side narrowed permissions");
|
||||
denied(repl, "e cfg.sandbox=false | cat");
|
||||
check(config.getBoolean("cfg.sandbox"), "denied pipe never executes its left side");
|
||||
|
||||
repl.executeCommand("e cfg.sandbox.grain=environ");
|
||||
String environment = repl.executeCommand("%j");
|
||||
check(new org.json.JSONObject(environment).length() > 0, "environment grain");
|
||||
denied(repl, "!!echo denied");
|
||||
|
||||
// Turning the local sandbox off and on preserves the chosen grain.
|
||||
repl.executeCommand("e cfg.sandbox=off");
|
||||
check(!context.isSandboxed(R2Context.R_SANDBOX_GRAIN_ALL), "sandbox off");
|
||||
repl.executeCommand("e cfg.sandbox=ON");
|
||||
check(config.get("cfg.sandbox.grain").equals("environ"), "grain survives toggle");
|
||||
denied(repl, "!!echo denied");
|
||||
repl.executeCommand("e cfg.sandbox.grain=all");
|
||||
check(repl.executeCommand("js 1+1").equals("2\n"), "explicit unrestricted JavaScript");
|
||||
check(repl.executeCommand(". '" + js + "'").equals("2\n"), "JavaScript file");
|
||||
|
||||
context.setSandboxFlags(R2Context.R_SANDBOX_GRAIN_ALL & ~R2Context.R_SANDBOX_GRAIN_FILES);
|
||||
check(config.get("cfg.sandbox.grain").equals("files"), "restriction API synchronizes config");
|
||||
// Suppressed listeners must not bypass sandbox validation or enforcement.
|
||||
config.set("cfg.sandbox.grain", "none", false);
|
||||
blocked(() -> fs.readFile(script.toString()));
|
||||
System.out.println("Sandbox command integration tests passed");
|
||||
}
|
||||
|
||||
private static void testHiddenFiles(R2REPLImpl repl, Path directory) throws Exception {
|
||||
R2FileSystem fs = repl.getContext().getFileSystem();
|
||||
Path hidden = Files.createDirectories(directory.resolve(".hidden"));
|
||||
Path secret = hidden.resolve("secret");
|
||||
Files.writeString(secret, "secret");
|
||||
Path alias = directory.resolve("alias");
|
||||
Files.createSymbolicLink(alias, hidden);
|
||||
repl.executeCommand("e cfg.sandbox.grain=files,disk");
|
||||
for (Path path : List.of(secret, alias.resolve("secret"))) {
|
||||
blocked(() -> fs.readFile(path.toString()));
|
||||
blocked(() -> fs.readBytes(path.toString()));
|
||||
blocked(() -> fs.writeFile(path.toString(), "changed"));
|
||||
blocked(() -> fs.deleteFile(path.toString()));
|
||||
check(!fs.fileExists(path.toString()), "hidden metadata blocked");
|
||||
}
|
||||
blocked(() -> fs.writeBytes(alias.resolve("new").toString(), new byte[] {1}));
|
||||
blocked(() -> fs.listFiles(alias.toString()));
|
||||
List<String> visible = fs.listFiles(directory.toString());
|
||||
check(
|
||||
!visible.contains(hidden.toString()) && !visible.contains(alias.toString()),
|
||||
"hidden listing filtered");
|
||||
check(Files.readString(secret).equals("secret"), "hidden file untouched");
|
||||
check(!Files.exists(hidden.resolve("new")), "hidden target not created");
|
||||
repl.executeCommand("e cfg.sandbox.grain=files,disk,hidden");
|
||||
check(fs.readFile(secret.toString()).equals("secret"), "hidden grant");
|
||||
check(fs.readFile(alias.resolve("secret").toString()).equals("secret"), "hidden symlink grant");
|
||||
}
|
||||
|
||||
private static void denied(R2REPLImpl repl, String command) {
|
||||
String result = repl.executeCommand(command);
|
||||
check(result.startsWith("Error:") && result.contains("sandbox"), command + ": " + result);
|
||||
}
|
||||
|
||||
private static void blocked(Operation operation) throws Exception {
|
||||
try {
|
||||
operation.run();
|
||||
throw new AssertionError("Sandbox operation allowed");
|
||||
} catch (R2FileSystemException expected) {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
private interface Operation {
|
||||
void run() throws Exception;
|
||||
}
|
||||
|
||||
private static void check(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package r4ghidra.repl.sandbox;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Runs without Ghidra or a test framework. */
|
||||
public final class R2SandboxTest {
|
||||
public static void main(String[] args) {
|
||||
check(R2Sandbox.parseGrains("none") == 0, "none");
|
||||
check(R2Sandbox.parseGrains("all") == R2Sandbox.ALL, "all");
|
||||
check(R2Sandbox.parseGrains("file,disk") == (R2Sandbox.FILES | R2Sandbox.DISK), "file alias");
|
||||
check(
|
||||
R2Sandbox.parseGrains("!disk,!exec") == (R2Sandbox.ALL & ~R2Sandbox.DISK & ~R2Sandbox.EXEC),
|
||||
"leading negation");
|
||||
check(R2Sandbox.parseGrains("all,none,environ") == R2Sandbox.ENVIRON, "none resets");
|
||||
check(R2Sandbox.parseGrains("all,!all,files") == R2Sandbox.FILES, "remove all");
|
||||
check(R2Sandbox.parseGrains(" FILES , !disk ") == R2Sandbox.FILES, "spaces and case");
|
||||
for (int mask = 0; mask <= R2Sandbox.ALL; mask++) {
|
||||
check(R2Sandbox.parseGrains(R2Sandbox.formatGrains(mask)) == mask, "round trip " + mask);
|
||||
}
|
||||
for (String invalid :
|
||||
List.of("", "rw", "unknown", "files,", ",exec", "files,,exec", "!!disk")) {
|
||||
rejects(() -> R2Sandbox.parseGrains(invalid));
|
||||
}
|
||||
R2Sandbox policy = new R2Sandbox();
|
||||
check(policy.isRestricted(R2Sandbox.ALL), "default denies host resources");
|
||||
policy.setGrains(R2Sandbox.FILES);
|
||||
check(!policy.isRestricted(R2Sandbox.FILES), "allow reads");
|
||||
check(policy.isRestricted(R2Sandbox.FILES | R2Sandbox.DISK), "write requires both grains");
|
||||
rejects(() -> policy.setGrains(128));
|
||||
check(policy.getGrains() == R2Sandbox.FILES, "invalid bits preserve policy");
|
||||
policy.setEnabled(false);
|
||||
check(!policy.isRestricted(R2Sandbox.ALL), "disabled policy");
|
||||
policy.setEnabled(true);
|
||||
check(policy.isRestricted(R2Sandbox.DISK), "re-enabling preserves grain");
|
||||
policy.lock();
|
||||
rejects(() -> policy.setEnabled(false));
|
||||
rejects(() -> policy.setGrains(R2Sandbox.ALL));
|
||||
policy.setGrains(R2Sandbox.NONE);
|
||||
rejects(() -> policy.setGrains(R2Sandbox.FILES));
|
||||
check(policy.isEnabled() && policy.getGrains() == 0, "locked policy can only narrow");
|
||||
System.out.println("Sandbox policy regression tests passed");
|
||||
}
|
||||
|
||||
private static void rejects(Runnable action) {
|
||||
try {
|
||||
action.run();
|
||||
throw new AssertionError("Invalid policy accepted");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
private static void check(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user