244 lines
16 KiB
Java
244 lines
16 KiB
Java
package de.stadtbote.votian;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.asm.AnnotationVisitor;
|
|
import org.springframework.asm.ClassReader;
|
|
import org.springframework.asm.ClassVisitor;
|
|
import org.springframework.asm.ClassWriter;
|
|
import org.springframework.asm.ConstantDynamic;
|
|
import org.springframework.asm.FieldVisitor;
|
|
import org.springframework.asm.Handle;
|
|
import org.springframework.asm.MethodVisitor;
|
|
import org.springframework.asm.Opcodes;
|
|
import org.springframework.asm.RecordComponentVisitor;
|
|
import org.springframework.asm.Type;
|
|
import net.bytebuddy.jar.asm.signature.SignatureReader;
|
|
import net.bytebuddy.jar.asm.signature.SignatureVisitor;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Set;
|
|
import java.util.TreeSet;
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
/** Enforces the database boundary using compiled dependencies, including fully qualified references. */
|
|
class DatabaseAccessArchitectureTest {
|
|
private static final String BASE = "de/stadtbote/votian/";
|
|
private static final String SERVICE = BASE + "database/DatabaseService";
|
|
private static final String SERVICE_TEST = BASE + "database/DatabaseServiceTest";
|
|
private static final String THIS_TEST = BASE + "DatabaseAccessArchitectureTest";
|
|
private static final List<String> FORBIDDEN_PREFIXES = List.of(
|
|
"java/sql/", "javax/sql/", "org/springframework/jdbc/",
|
|
"org/springframework/transaction/", "org/springframework/orm/",
|
|
"org/springframework/data/", "jakarta/persistence/", "javax/persistence/",
|
|
"org/hibernate/", "com/zaxxer/hikari/", "org/mariadb/jdbc/", "com/mysql/",
|
|
"org/postgresql/", "org/h2/", "org/apache/commons/dbcp", "org/apache/tomcat/jdbc/"
|
|
);
|
|
// Timestamp is a value already returned by JDBC, not a connection or execution API.
|
|
private static final Set<String> ALLOWED_VALUE_TYPES = Set.of("java/sql/Timestamp");
|
|
|
|
@Test void productionDatabaseAccessGoesThroughOneService() throws Exception {
|
|
assertNoBypasses(classesDirectory(VotianApplication.class), false);
|
|
}
|
|
|
|
@Test void testsUseTheSameBoundaryExceptTheServiceOwnUnitTests() throws Exception {
|
|
assertNoBypasses(classesDirectory(DatabaseAccessArchitectureTest.class), true);
|
|
}
|
|
|
|
@Test void bytecodeGuardDetectsConstructorFieldsCallsAndReflectiveTypeNames() {
|
|
ClassWriter writer = new ClassWriter(0);
|
|
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ForbiddenAccess", null, "java/lang/Object", null);
|
|
writer.visitField(Opcodes.ACC_PRIVATE, "template", "Lorg/springframework/jdbc/core/JdbcTemplate;", null, null).visitEnd();
|
|
MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "read", "(Ljavax/sql/DataSource;)V", null, null);
|
|
method.visitCode();
|
|
method.visitMethodInsn(Opcodes.INVOKESTATIC, "java/sql/DriverManager", "getConnection", "(Ljava/lang/String;)Ljava/sql/Connection;", false);
|
|
method.visitLdcInsn("jakarta.persistence.EntityManager");
|
|
method.visitInsn(Opcodes.RETURN);
|
|
method.visitMaxs(2, 2);
|
|
method.visitEnd();
|
|
writer.visitEnd();
|
|
var findings = dependencies(writer.toByteArray());
|
|
assertThat(findings).contains("org/springframework/jdbc/core/JdbcTemplate", "javax/sql/DataSource",
|
|
"java/sql/DriverManager", "java/sql/Connection", "jakarta/persistence/EntityManager");
|
|
}
|
|
|
|
@Test void timestampValueMappingRemainsAllowedButGenericJdbcDependenciesDoNot() {
|
|
ClassWriter writer = new ClassWriter(0);
|
|
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ValueMapping", null, "java/lang/Object", null);
|
|
writer.visitField(Opcodes.ACC_PRIVATE, "timestamp", "Ljava/sql/Timestamp;", null, null).visitEnd();
|
|
writer.visitEnd();
|
|
assertThat(dependencies(writer.toByteArray())).isEmpty();
|
|
|
|
writer = new ClassWriter(0);
|
|
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "GenericBypass", null, "java/lang/Object", null);
|
|
writer.visitField(Opcodes.ACC_PRIVATE, "connections", "Ljava/util/List;", "Ljava/util/List<Ljava/sql/Connection;>;", null).visitEnd();
|
|
writer.visitEnd();
|
|
assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection");
|
|
}
|
|
|
|
@Test void pythonLaunchersDoNotOpenTheirOwnDatabaseConnections() throws Exception {
|
|
Path scripts = projectRoot().resolve("scripts");
|
|
assertThat(scripts).isDirectory();
|
|
// Parse source only: do not execute/import launchers or read PHP/local connection files.
|
|
String checker = """
|
|
import ast, pathlib, sys
|
|
drivers = {'pymysql', 'MySQLdb', 'mysql', 'mariadb', 'psycopg', 'psycopg2',
|
|
'sqlite3', 'aiosqlite', 'sqlalchemy', 'asyncpg', 'pg8000', 'pyodbc',
|
|
'jaydebeapi', 'adodbapi', 'peewee', 'tortoise', 'databases', 'duckdb'}
|
|
db_commands = {'mysql', 'mariadb', 'psql', 'sqlite3', 'sqlcmd', 'isql'}
|
|
def forbidden(module):
|
|
return bool(module) and module.split('.')[0] in drivers
|
|
def violations(source):
|
|
problems = []
|
|
for node in ast.walk(ast.parse(source)):
|
|
if isinstance(node, ast.Import):
|
|
problems += [(node.lineno, item.name) for item in node.names if forbidden(item.name)]
|
|
elif isinstance(node, ast.ImportFrom) and forbidden(node.module):
|
|
problems.append((node.lineno, node.module))
|
|
elif isinstance(node, ast.Call) and node.args:
|
|
function = node.func.id if isinstance(node.func, ast.Name) else node.func.attr if isinstance(node.func, ast.Attribute) else ''
|
|
first = node.args[0]
|
|
if function in {'__import__', 'import_module'} and isinstance(first, ast.Constant) and isinstance(first.value, str) and forbidden(first.value):
|
|
problems.append((node.lineno, first.value))
|
|
elif function in {'run', 'Popen', 'call', 'check_call', 'check_output', 'system', 'execvp', 'execvpe'}:
|
|
command = first.elts[0] if isinstance(first, (ast.List, ast.Tuple)) and first.elts else first
|
|
if isinstance(command, ast.Constant) and isinstance(command.value, str):
|
|
executable = pathlib.Path(command.value.split()[0]).name if command.value.split() else ''
|
|
if executable in db_commands:
|
|
problems.append((node.lineno, executable))
|
|
return problems
|
|
assert violations('import pymysql as database')
|
|
assert violations('from mysql import connector')
|
|
assert violations("importlib.import_module('mariadb')")
|
|
assert violations("subprocess.run(['mysql', '--version'])")
|
|
assert not violations("import json; subprocess.run(['java', '-jar', 'app.jar'])")
|
|
findings = []
|
|
for path in sorted(pathlib.Path(sys.argv[1]).rglob('*.py')):
|
|
for line, name in violations(path.read_text(encoding='utf-8')):
|
|
findings.append(f'{path.name}:{line}: direct database dependency {name}')
|
|
print('\\n'.join(findings))
|
|
sys.exit(1 if findings else 0)
|
|
""";
|
|
Process process = new ProcessBuilder("python3", "-c", checker, scripts.toString()).redirectErrorStream(true).start();
|
|
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
|
|
if (!finished) process.destroyForcibly();
|
|
assertThat(finished).as("Python AST architecture check must terminate without starting the application").isTrue();
|
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
|
assertThat(process.exitValue()).as("Python launchers must delegate database work to the Java DatabaseService:%n%s", output).isZero();
|
|
}
|
|
|
|
private static Path projectRoot() {
|
|
Path directory = Path.of("").toAbsolutePath().normalize();
|
|
for (int level = 0; directory != null && level < 5; level++, directory = directory.getParent()) {
|
|
if (Files.isDirectory(directory.resolve("backend")) && Files.isDirectory(directory.resolve("scripts"))) return directory;
|
|
}
|
|
throw new AssertionError("Cannot locate the project scripts directory from Maven working directory");
|
|
}
|
|
|
|
private static Path classesDirectory(Class<?> anchor) throws Exception {
|
|
Path classes = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
|
|
assertThat(classes).as("Architecture check expects compiled class directories").isDirectory();
|
|
return classes;
|
|
}
|
|
|
|
private static boolean belongsTo(String name, String owner) { return name.equals(owner) || name.startsWith(owner + "$"); }
|
|
|
|
private static void assertNoBypasses(Path classes, boolean tests) throws IOException {
|
|
List<String> violations = new ArrayList<>();
|
|
if (!tests) assertThat(classes.resolve(SERVICE + ".class")).as("The central database service must exist").isRegularFile();
|
|
try (var paths = Files.walk(classes)) {
|
|
for (Path path : paths.filter(p -> p.toString().endsWith(".class")).sorted().toList()) {
|
|
byte[] bytes = Files.readAllBytes(path);
|
|
String name = new ClassReader(bytes).getClassName();
|
|
boolean exempt = belongsTo(name, SERVICE) || (tests && (belongsTo(name, SERVICE_TEST) || belongsTo(name, THIS_TEST)));
|
|
if (!exempt) for (String dependency : dependencies(bytes)) violations.add(name.replace('/', '.') + " -> " + dependency.replace('/', '.'));
|
|
}
|
|
}
|
|
assertThat(violations).as("Every DB-access type must remain inside DatabaseService (only DatabaseServiceTest may mock raw JDBC)").isEmpty();
|
|
}
|
|
|
|
private static Set<String> dependencies(byte[] bytes) {
|
|
Set<String> forbidden = new TreeSet<>();
|
|
new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
|
|
void name(String name) {
|
|
if (name == null) return;
|
|
if (name.startsWith("[")) { descriptor(name); return; }
|
|
if (!ALLOWED_VALUE_TYPES.contains(name) && FORBIDDEN_PREFIXES.stream().anyMatch(name::startsWith)) forbidden.add(name);
|
|
}
|
|
void type(Type type) {
|
|
switch (type.getSort()) {
|
|
case Type.OBJECT -> name(type.getInternalName());
|
|
case Type.ARRAY -> type(type.getElementType());
|
|
case Type.METHOD -> { type(type.getReturnType()); for (Type arg : type.getArgumentTypes()) type(arg); }
|
|
default -> { }
|
|
}
|
|
}
|
|
void descriptor(String descriptor) { if (descriptor != null) type(Type.getType(descriptor)); }
|
|
void signature(String signature, boolean field) {
|
|
if (signature == null) return;
|
|
var visitor = new SignatureVisitor(Opcodes.ASM9) {
|
|
@Override public void visitClassType(String name) { name(name); }
|
|
};
|
|
if (field) new SignatureReader(signature).acceptType(visitor); else new SignatureReader(signature).accept(visitor);
|
|
}
|
|
void handle(Handle handle) { name(handle.getOwner()); descriptor(handle.getDesc()); }
|
|
void constant(Object value) {
|
|
if (value instanceof Type type) type(type);
|
|
else if (value instanceof Handle handle) handle(handle);
|
|
else if (value instanceof ConstantDynamic dynamic) {
|
|
descriptor(dynamic.getDescriptor());handle(dynamic.getBootstrapMethod());
|
|
for (int i=0;i<dynamic.getBootstrapMethodArgumentCount();i++) constant(dynamic.getBootstrapMethodArgument(i));
|
|
} else if (value instanceof String text) name(text.replace('.', '/'));
|
|
}
|
|
AnnotationVisitor annotation(String descriptor) {
|
|
descriptor(descriptor);
|
|
return new AnnotationVisitor(Opcodes.ASM9) {
|
|
@Override public void visit(String name, Object value) { constant(value); }
|
|
@Override public void visitEnum(String name, String descriptor, String value) { descriptor(descriptor); }
|
|
@Override public AnnotationVisitor visitAnnotation(String name, String descriptor) { return annotation(descriptor); }
|
|
@Override public AnnotationVisitor visitArray(String name) { return annotation(null); }
|
|
};
|
|
}
|
|
@Override public void visit(int version,int access,String name,String signature,String superName,String[] interfaces) {
|
|
name(superName);if(interfaces!=null)for(String iface:interfaces)name(iface);signature(signature,false);
|
|
}
|
|
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
|
|
@Override public RecordComponentVisitor visitRecordComponent(String name,String descriptor,String signature) {
|
|
descriptor(descriptor);signature(signature,true);
|
|
return new RecordComponentVisitor(Opcodes.ASM9) {
|
|
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
|
|
};
|
|
}
|
|
@Override public FieldVisitor visitField(int access,String name,String descriptor,String signature,Object value) {
|
|
descriptor(descriptor);signature(signature,true);constant(value);
|
|
return new FieldVisitor(Opcodes.ASM9) {
|
|
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
|
|
};
|
|
}
|
|
@Override public MethodVisitor visitMethod(int access,String name,String descriptor,String signature,String[] exceptions) {
|
|
descriptor(descriptor);signature(signature,false);if(exceptions!=null)for(String exception:exceptions)name(exception);
|
|
return new MethodVisitor(Opcodes.ASM9) {
|
|
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
|
|
@Override public AnnotationVisitor visitParameterAnnotation(int parameter,String descriptor,boolean visible) { return annotation(descriptor); }
|
|
@Override public void visitTypeInsn(int opcode,String type) { name(type); }
|
|
@Override public void visitFieldInsn(int opcode,String owner,String name,String descriptor) { name(owner);descriptor(descriptor); }
|
|
@Override public void visitMethodInsn(int opcode,String owner,String name,String descriptor,boolean isInterface) { name(owner);descriptor(descriptor); }
|
|
@Override public void visitInvokeDynamicInsn(String name,String descriptor,Handle bootstrap,Object... arguments) {
|
|
descriptor(descriptor);handle(bootstrap);for(Object argument:arguments)constant(argument);
|
|
}
|
|
@Override public void visitLdcInsn(Object value) { constant(value); }
|
|
@Override public void visitMultiANewArrayInsn(String descriptor,int dimensions) { descriptor(descriptor); }
|
|
@Override public void visitTryCatchBlock(org.springframework.asm.Label start,org.springframework.asm.Label end,org.springframework.asm.Label handler,String type) { name(type); }
|
|
};
|
|
}
|
|
}, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
|
|
return forbidden;
|
|
}
|
|
}
|