Skip to content

Commit 567b980

Browse files
committed
Enhance class name validation and adjust JaCoCo coverage thresholds
1 parent 2096794 commit 567b980

3 files changed

Lines changed: 124 additions & 10 deletions

File tree

src/main/docs/decision-log.adoc

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
=== [RC-FN-001] Allow hyphenated descriptor class names
2+
3+
* Date: 2025-10-28
4+
* Context:
5+
** The runtime compiler recently introduced stricter validation that rejected binary names containing hyphens.
6+
** Java reserves `module-info` and `package-info` descriptors, and downstream uses rely on compiling them through the cached compiler.
7+
** We must prevent injection of directory traversal or shell-sensitive characters while honouring legitimate descriptor forms.
8+
* Decision Statement:
9+
** Relax the class name validation to accept hyphenated segments such as `module-info` and `package-info`, while maintaining segment level controls for other characters.
10+
* Notes/Links:
11+
** Change implemented in `src/main/java/net/openhft/compiler/CachedCompiler.java`.
12+
13+
=== [RC-TEST-002] Align coverage gate with achieved baseline
14+
15+
* Date: 2025-10-28
16+
* Context:
17+
** The enforced JaCoCo minimums were 83 % line and 76 % branch coverage, below both the documentation target and the current test suite capability.
18+
** Recent test additions raise the baseline to ~85 % line and branch coverage, but still fall short of the historical 90 % goal.
19+
** Failing builds on the higher 90 % target blocks releases without immediate scope to add more tests.
20+
* Decision Statement:
21+
** Increase the JaCoCo enforcement thresholds to 85 % for line and branch coverage so the build reflects the present safety net while keeping headroom for future improvements.
22+
* *Alternatives Considered:*
23+
** Retain the 90 % requirement:
24+
*** _Pros:_ Preserves the original aspiration.
25+
*** _Cons:_ The build fails despite the current suite, causing friction for ongoing work.
26+
** Keep legacy 83/76 % thresholds:
27+
*** _Pros:_ No configuration change needed.
28+
*** _Cons:_ Enforcement would lag the actual quality level, risking future regressions.
29+
* *Rationale for Decision:*
30+
** Setting the guard at 85 % matches the measurable baseline and ensures regression detection without blocking releases.
31+
** The documentation and configuration now stay consistent, supporting future increments once more tests land.
32+
* *Impact & Consequences:*
33+
** Build pipelines now fail if coverage slips below the new 85 % thresholds.
34+
** Documentation for requirement JRC-TEST-014 is updated to the same value.
35+
* Notes/Links:
36+
** Thresholds maintained in `pom.xml`.
37+
** Updated requirement: `src/main/docs/project-requirements.adoc`.

src/main/java/net/openhft/compiler/CachedCompiler.java

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@
1212
import javax.tools.DiagnosticListener;
1313
import javax.tools.JavaFileObject;
1414
import javax.tools.StandardJavaFileManager;
15-
import java.io.Closeable;
16-
import java.io.File;
17-
import java.io.IOException;
18-
import java.io.PrintWriter;
15+
import java.io.*;
16+
import java.nio.charset.StandardCharsets;
17+
import java.nio.file.Path;
1918
import java.util.*;
2019
import java.util.concurrent.ConcurrentHashMap;
2120
import java.util.concurrent.ConcurrentMap;
2221
import java.util.function.Consumer;
2322
import java.util.function.Function;
23+
import java.util.regex.Pattern;
2424

2525
import static net.openhft.compiler.CompilerUtils.*;
2626

@@ -39,18 +39,25 @@ public class CachedCompiler implements Closeable {
3939
/**
4040
* Writer used when no alternative is supplied.
4141
*/
42-
private static final PrintWriter DEFAULT_WRITER = new PrintWriter(System.err);
42+
private static final PrintWriter DEFAULT_WRITER = createDefaultWriter();
4343
/**
4444
* Default compiler flags including debug symbols.
4545
*/
4646
private static final List<String> DEFAULT_OPTIONS = Arrays.asList("-g", "-nowarn");
47+
private static final Pattern CLASS_NAME_PATTERN = Pattern.compile("[\\p{Alnum}_$.\\-]+");
48+
private static final Pattern CLASS_NAME_SEGMENT_PATTERN = Pattern.compile("[\\p{Alnum}_$]+(?:-[\\p{Alnum}_$]+)*");
4749

4850
private final Map<ClassLoader, Map<String, Class<?>>> loadedClassesMap = Collections.synchronizedMap(new WeakHashMap<>());
4951
private final Map<ClassLoader, MyJavaFileManager> fileManagerMap = Collections.synchronizedMap(new WeakHashMap<>());
5052
/**
5153
* Optional testing hook to replace the file manager implementation.
54+
* <p>
55+
* This field remains {@code public} to preserve binary compatibility with callers that
56+
* accessed it directly in previous releases. Prefer {@link #setFileManagerOverride(Function)}
57+
* for source-compatible code.
5258
*/
53-
public Function<StandardJavaFileManager, MyJavaFileManager> fileManagerOverride;
59+
@SuppressWarnings("WeakerAccess")
60+
public volatile Function<StandardJavaFileManager, MyJavaFileManager> fileManagerOverride;
5461

5562
@Nullable
5663
private final File sourceDir;
@@ -84,7 +91,7 @@ public CachedCompiler(@Nullable File sourceDir,
8491
@NotNull List<String> options) {
8592
this.sourceDir = sourceDir;
8693
this.classDir = classDir;
87-
this.options = options;
94+
this.options = Collections.unmodifiableList(new ArrayList<>(options));
8895
}
8996

9097
/**
@@ -111,6 +118,7 @@ public void close() {
111118
* @throws ClassNotFoundException if the compiled class cannot be defined
112119
*/
113120
public Class<?> loadFromJava(@NotNull String className, @NotNull String javaCode) throws ClassNotFoundException {
121+
validateClassName(className);
114122
return loadFromJava(getClass().getClassLoader(), className, javaCode, DEFAULT_WRITER);
115123
}
116124

@@ -127,6 +135,7 @@ public Class<?> loadFromJava(@NotNull String className, @NotNull String javaCode
127135
public Class<?> loadFromJava(@NotNull ClassLoader classLoader,
128136
@NotNull String className,
129137
@NotNull String javaCode) throws ClassNotFoundException {
138+
validateClassName(className);
130139
return loadFromJava(classLoader, className, javaCode, DEFAULT_WRITER);
131140
}
132141

@@ -144,6 +153,7 @@ public Class<?> loadFromJava(@NotNull ClassLoader classLoader,
144153
Map<String, byte[]> compileFromJava(@NotNull String className,
145154
@NotNull String javaCode,
146155
MyJavaFileManager fileManager) {
156+
validateClassName(className);
147157
return compileFromJava(className, javaCode, DEFAULT_WRITER, fileManager);
148158
}
149159

@@ -162,10 +172,11 @@ Map<String, byte[]> compileFromJava(@NotNull String className,
162172
@NotNull String javaCode,
163173
final @NotNull PrintWriter writer,
164174
MyJavaFileManager fileManager) {
175+
validateClassName(className);
165176
Iterable<? extends JavaFileObject> compilationUnits;
166177
if (sourceDir != null) {
167178
String filename = className.replaceAll("\\.", '\\' + File.separator) + ".java";
168-
File file = new File(sourceDir, filename);
179+
File file = safeResolve(sourceDir, filename);
169180
writeText(file, javaCode);
170181
if (s_standardJavaFileManager == null)
171182
s_standardJavaFileManager = s_compiler.getStandardFileManager(null, null, null);
@@ -199,6 +210,7 @@ public void report(Diagnostic<? extends JavaFileObject> diagnostic) {
199210
}
200211
}
201212

213+
202214
/**
203215
* Compile and load using a specific class loader and writer. The
204216
* compilation result is cached against the loader for future calls.
@@ -223,7 +235,7 @@ public Class<?> loadFromJava(@NotNull ClassLoader classLoader,
223235
else
224236
clazz = loadedClasses.get(className);
225237
}
226-
PrintWriter printWriter = (writer == null ? DEFAULT_WRITER : writer);
238+
PrintWriter printWriter = writer == null ? DEFAULT_WRITER : writer;
227239
if (clazz != null)
228240
return clazz;
229241

@@ -236,14 +248,15 @@ public Class<?> loadFromJava(@NotNull ClassLoader classLoader,
236248
final Map<String, byte[]> compiled = compileFromJava(className, javaCode, printWriter, fileManager);
237249
for (Map.Entry<String, byte[]> entry : compiled.entrySet()) {
238250
String className2 = entry.getKey();
251+
validateClassName(className2);
239252
synchronized (loadedClassesMap) {
240253
if (loadedClasses.containsKey(className2))
241254
continue;
242255
}
243256
byte[] bytes = entry.getValue();
244257
if (classDir != null) {
245258
String filename = className2.replaceAll("\\.", '\\' + File.separator) + ".class";
246-
boolean changed = writeBytes(new File(classDir, filename), bytes);
259+
boolean changed = writeBytes(safeResolve(classDir, filename), bytes);
247260
if (changed) {
248261
LOG.info("Updated {} in {}", className2, classDir);
249262
}
@@ -281,9 +294,46 @@ public void updateFileManagerForClassLoader(ClassLoader classLoader, Consumer<My
281294
}
282295
}
283296

297+
public void setFileManagerOverride(Function<StandardJavaFileManager, MyJavaFileManager> fileManagerOverride) {
298+
this.fileManagerOverride = fileManagerOverride;
299+
}
300+
284301
private @NotNull MyJavaFileManager getFileManager(StandardJavaFileManager fm) {
285302
return fileManagerOverride != null
286303
? fileManagerOverride.apply(fm)
287304
: new MyJavaFileManager(fm);
288305
}
306+
307+
private static void validateClassName(String className) {
308+
Objects.requireNonNull(className, "className");
309+
if (!CLASS_NAME_PATTERN.matcher(className).matches()) {
310+
throw new IllegalArgumentException("Invalid class name: " + className);
311+
}
312+
for (String segment : className.split("\\.", -1)) {
313+
if (!CLASS_NAME_SEGMENT_PATTERN.matcher(segment).matches()) {
314+
throw new IllegalArgumentException("Invalid class name: " + className);
315+
}
316+
}
317+
}
318+
319+
static File safeResolve(File root, String relativePath) {
320+
Objects.requireNonNull(root, "root");
321+
Objects.requireNonNull(relativePath, "relativePath");
322+
Path base = root.toPath().toAbsolutePath().normalize();
323+
Path candidate = base.resolve(relativePath).normalize();
324+
if (!candidate.startsWith(base)) {
325+
throw new IllegalArgumentException("Attempted path traversal for " + relativePath);
326+
}
327+
return candidate.toFile();
328+
}
329+
330+
private static PrintWriter createDefaultWriter() {
331+
OutputStreamWriter writer = new OutputStreamWriter(System.err, StandardCharsets.UTF_8);
332+
return new PrintWriter(writer, true) {
333+
@Override
334+
public void close() {
335+
flush(); // never close System.err
336+
}
337+
};
338+
}
289339
}

src/main/java/net/openhft/compiler/CompilerUtils.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
import java.lang.reflect.InvocationTargetException;
2020
import java.lang.reflect.Method;
2121
import java.nio.charset.Charset;
22+
import java.nio.file.Path;
2223
import java.util.Arrays;
24+
import java.util.Objects;
2325

2426
/**
2527
* Provides static utility methods for runtime Java compilation, dynamic class loading,
@@ -148,6 +150,25 @@ public static boolean addClassPath(@NotNull String dir) {
148150
return true;
149151
}
150152

153+
/**
154+
* Normalizes relative paths and rejects traversal attempts beyond the current root.
155+
*
156+
* @param path value to sanitize.
157+
* @return normalized path when safe.
158+
* @throws IllegalArgumentException if the path attempts to traverse upward ("..").
159+
*/
160+
static Path sanitizePath(@NotNull Path path) {
161+
Objects.requireNonNull(path, "path");
162+
Path normalized = path.normalize();
163+
if (normalized.isAbsolute()) {
164+
return normalized;
165+
}
166+
if (normalized.getNameCount() > 0 && "..".equals(normalized.getName(0).toString())) {
167+
throw new IllegalArgumentException("Path traversal attempt: " + path);
168+
}
169+
return normalized;
170+
}
171+
151172
/**
152173
* Define a class for byte code.
153174
*
@@ -303,6 +324,12 @@ public static boolean writeBytes(@NotNull File file, @NotNull byte[] bytes) {
303324
if (bak != null)
304325
bak.renameTo(file);
305326
throw new IllegalStateException("Unable to write " + file, e);
327+
} finally {
328+
if (bak != null && bak.exists() && file.exists()) {
329+
if (!bak.delete()) {
330+
LOGGER.debug("Unable to delete backup {}", bak);
331+
}
332+
}
306333
}
307334
return true;
308335
}

0 commit comments

Comments
 (0)