Skip to content

Commit d13f78a

Browse files
committed
Improve validation, error handling, and file processing in CLI commands and test utilities
1 parent 690589c commit d13f78a

7 files changed

Lines changed: 82 additions & 17 deletions

File tree

aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/bootstrap/BootstrapLoader.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import com.google.common.base.Preconditions;
2626
import de.splatgames.aether.datafixers.api.bootstrap.DataFixerBootstrap;
2727
import org.jetbrains.annotations.NotNull;
28+
import org.slf4j.Logger;
29+
import org.slf4j.LoggerFactory;
2830

2931
import java.lang.reflect.Constructor;
3032
import java.util.ServiceLoader;
@@ -69,6 +71,8 @@
6971
*/
7072
public final class BootstrapLoader {
7173

74+
private static final Logger LOG = LoggerFactory.getLogger(BootstrapLoader.class);
75+
7276
/**
7377
* Private constructor to prevent instantiation.
7478
*
@@ -111,6 +115,11 @@ private BootstrapLoader() {
111115
public static DataFixerBootstrap load(@NotNull final String className) {
112116
Preconditions.checkNotNull(className, "className must not be null");
113117

118+
// Security note: Class.forName() will load and instantiate any class on the classpath
119+
// that implements DataFixerBootstrap. Only use bootstrap classes from trusted sources.
120+
LOG.warn("Loading bootstrap class '{}' via reflection. "
121+
+ "Ensure this class is from a trusted source.", className);
122+
114123
try {
115124
final Class<?> clazz = Class.forName(className);
116125

aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,16 @@ public class MigrateCommand implements Callable<Integer> {
434434
@Override
435435
public Integer call() {
436436
try {
437+
// 0. Validate version range
438+
if (this.toVersion < 0) {
439+
System.err.println("Error: --to version must be non-negative, got: " + this.toVersion);
440+
return 1;
441+
}
442+
if (this.fromVersion != null && this.fromVersion < 0) {
443+
System.err.println("Error: --from version must be non-negative, got: " + this.fromVersion);
444+
return 1;
445+
}
446+
437447
// 1. Load bootstrap
438448
final DataFixerBootstrap bootstrap = BootstrapLoader.load(this.bootstrapClass);
439449

@@ -545,8 +555,17 @@ private <T> MigrationResult processFile(
545555

546556
final Instant startTime = Instant.now();
547557

548-
// Read input
549-
final String content = Files.readString(inputFile.toPath());
558+
// Check file size to prevent OOM on very large files
559+
final long fileSize = Files.size(inputFile.toPath());
560+
if (fileSize > 100 * 1024 * 1024) {
561+
throw new IOException("File exceeds maximum size (100MB): " + inputFile);
562+
}
563+
564+
// Read input (strip UTF-8 BOM if present)
565+
String content = Files.readString(inputFile.toPath());
566+
if (content.startsWith("\uFEFF")) {
567+
content = content.substring(1);
568+
}
550569
final T data = handler.parse(content);
551570

552571
// Determine source version
@@ -638,7 +657,14 @@ private void writeOutput(@NotNull final File inputFile, @NotNull final String co
638657
Preconditions.checkNotNull(inputFile, "inputFile must not be null");
639658
Preconditions.checkNotNull(content, "content must not be null");
640659
if (this.output != null) {
641-
// Write to specified output
660+
// Validate output path: reject path traversal via unnormalized segments
661+
final Path outputPath = this.output.toPath();
662+
if (!outputPath.normalize().equals(outputPath)
663+
|| outputPath.toString().contains("..")) {
664+
throw new IOException(
665+
"Output path contains path traversal: " + this.output);
666+
}
667+
642668
if (this.output.isDirectory()) {
643669
final Path outPath = this.output.toPath().resolve(inputFile.getName());
644670
Files.writeString(outPath, content);
@@ -648,17 +674,25 @@ private void writeOutput(@NotNull final File inputFile, @NotNull final String co
648674
throw new IllegalArgumentException(
649675
"Output must be a directory when multiple input files are specified");
650676
}
651-
} else if (this.inputFiles.size() == 1 && this.output == null) {
677+
} else if (this.inputFiles.size() == 1) {
652678
// Single file with no output: stdout
653679
System.out.println(content);
654680
} else {
655-
// Multiple files: in-place with backup
656-
if (this.backup) {
657-
final Path backupPath = inputFile.toPath().resolveSibling(
658-
inputFile.getName() + ".bak");
659-
Files.copy(inputFile.toPath(), backupPath, StandardCopyOption.REPLACE_EXISTING);
681+
// Multiple files: in-place with atomic write via temp file
682+
final Path tempPath = Files.createTempFile(
683+
inputFile.toPath().getParent(), "migrate_", ".tmp");
684+
try {
685+
Files.writeString(tempPath, content);
686+
if (this.backup) {
687+
final Path backupPath = inputFile.toPath().resolveSibling(
688+
inputFile.getName() + ".bak");
689+
Files.move(inputFile.toPath(), backupPath, StandardCopyOption.REPLACE_EXISTING);
690+
}
691+
Files.move(tempPath, inputFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
692+
} catch (final IOException e) {
693+
Files.deleteIfExists(tempPath);
694+
throw e;
660695
}
661-
Files.writeString(inputFile.toPath(), content);
662696
}
663697
}
664698

aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/ValidateCommand.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import picocli.CommandLine.Parameters;
3636

3737
import java.io.File;
38+
import java.io.IOException;
3839
import java.nio.file.Files;
3940
import java.util.List;
4041
import java.util.concurrent.Callable;
@@ -232,6 +233,11 @@ public class ValidateCommand implements Callable<Integer> {
232233
*/
233234
@Override
234235
public Integer call() {
236+
if (this.toVersion < 0) {
237+
System.err.println("Error: --to version must be non-negative, got: " + this.toVersion);
238+
return 1;
239+
}
240+
235241
try {
236242
final DataFixerBootstrap bootstrap = BootstrapLoader.load(this.bootstrapClass);
237243
final DataVersion targetVersion = new DataVersion(this.toVersion);
@@ -314,7 +320,14 @@ private <T> ValidationResult validateFile(
314320
final DataVersion targetVersion
315321
) {
316322
try {
317-
final String content = Files.readString(file.toPath());
323+
final long fileSize = Files.size(file.toPath());
324+
if (fileSize > 100 * 1024 * 1024) {
325+
throw new IOException("File exceeds maximum size (100MB): " + file);
326+
}
327+
String content = Files.readString(file.toPath());
328+
if (content.startsWith("\uFEFF")) {
329+
content = content.substring(1);
330+
}
318331
final T data = handler.parse(content);
319332

320333
final DataVersion fileVersion = VersionExtractor.extract(

aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/format/FormatRegistry.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ private FormatRegistry() {
158158
* @param handler the format handler to register, must not be {@code null}
159159
* @see FormatHandler#formatId()
160160
*/
161-
public static void register(@NotNull final FormatHandler<?> handler) {
161+
public static synchronized void register(@NotNull final FormatHandler<?> handler) {
162162
Preconditions.checkNotNull(handler, "handler must not be null");
163163

164164
final String id = handler.formatId();

aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ public DataFixersSummary summary() {
245245
public DomainDetails domainDetails(@Selector final String domain) {
246246
final AetherDataFixer fixer = this.registry.get(domain);
247247
if (fixer == null) {
248-
return null; // Spring will return 404
248+
// Returning null from @ReadOperation with @Selector produces HTTP 404
249+
// (Spring Boot Actuator convention for missing resources)
250+
return null;
249251
}
250252

251253
try {

aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/assertion/DataResultAssert.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ public DataResultAssert<A> hasErrorMessageMatching(@NotNull final String regex)
326326
@NotNull
327327
public AbstractObjectAssert<?, A> extractingValue() {
328328
this.isSuccess();
329-
return Assertions.assertThat(this.actual.result().orElse(null));
329+
return Assertions.assertThat(this.actual.result().orElseThrow());
330330
}
331331

332332
/**

aether-datafixers-testkit/src/main/java/de/splatgames/aether/datafixers/testkit/assertion/TypedAssert.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,16 @@ public TypedAssert<A> hasValueInstanceOf(@NotNull final Class<?> expectedClass)
215215
@NotNull
216216
public <T> DynamicAssert<T> encodedWith(@NotNull final DynamicOps<T> ops) {
217217
isNotNull();
218-
final Dynamic<T> encoded = this.actual.encode(ops)
219-
.getOrThrow(msg -> new AssertionError("Failed to encode Typed value: " + msg));
220-
return new DynamicAssert<>(encoded);
218+
try {
219+
final Dynamic<T> encoded = this.actual.encode(ops)
220+
.getOrThrow(msg -> new AssertionError("Failed to encode Typed value: " + msg));
221+
return new DynamicAssert<>(encoded);
222+
} catch (final AssertionError e) {
223+
throw e;
224+
} catch (final Exception e) {
225+
// Preserve the original exception stack trace as the cause
226+
throw new AssertionError("Failed to encode Typed value: " + e.getMessage(), e);
227+
}
221228
}
222229

223230
// ==================== Extraction ====================

0 commit comments

Comments
 (0)