diff --git a/README.md b/README.md index 7fbaf44..cd98ff6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Install JDK 17, then run: ./gradlew build ``` -The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository and validate its engine pin, catalog, client registration, and matchmaking advice. Ranked battle selection uses a recorded random seed, prioritizes under-sampled pairings involving `myBots`, and falls back to distinct active catalog bots when no advice is available. Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks. +The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. Ranked battle selection uses a recorded random seed, prioritizes under-sampled pairings involving `myBots`, and falls back to distinct active catalog bots when no advice is available. Battle Runner execution, persistence, issue-ops transport, and the runtime container are added in subsequent CH-012 tasks. ## Configuration diff --git a/src/main/java/dev/robocode/rumble/client/BotCachePreparer.java b/src/main/java/dev/robocode/rumble/client/BotCachePreparer.java new file mode 100644 index 0000000..de5562d --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/BotCachePreparer.java @@ -0,0 +1,117 @@ +package dev.robocode.rumble.client; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Materializes and validates the immutable bot sources pinned by a ranked snapshot. + */ +final class BotCachePreparer { + private final RepositoryReader repositoryReader; + + BotCachePreparer(final RepositoryReader repositoryReader) { + this.repositoryReader = repositoryReader; + } + + PreparedBotCache prepare(final RumbleSnapshot snapshot, final ClientConfiguration configuration) + throws IOException { + if (configuration.mode() != ClientMode.RANKED) { + throw new IllegalArgumentException("Bot cache preparation requires ranked mode"); + } + final String sourceCommit = snapshot.catalog().sourceCommit(); + final Path cacheParent = configuration.workDirectory().resolve("cache/bots"); + final Path cacheDirectory = cacheParent.resolve(sourceCommit); + if (Files.exists(cacheDirectory)) { + return validateCache(snapshot.catalog(), cacheDirectory); + } + + Files.createDirectories(cacheParent); + final Path stagingDirectory = Files.createTempDirectory(cacheParent, sourceCommit + "-"); + try { + try (RepositoryReader.RepositoryCheckout checkout = repositoryReader.checkout( + configuration.botsRepository(), sourceCommit)) { + for (final CatalogBot bot : sortedBots(snapshot.catalog())) { + checkout.copyDirectory(bot.path(), stagingDirectory.resolve(bot.path())); + } + } + validateCache(snapshot.catalog(), stagingDirectory); + publish(stagingDirectory, cacheDirectory); + return validateCache(snapshot.catalog(), cacheDirectory); + } finally { + deleteTree(stagingDirectory); + } + } + + private static PreparedBotCache validateCache(final BotCatalog catalog, final Path cacheDirectory) + throws IOException { + final Map paths = new LinkedHashMap<>(); + for (final CatalogBot bot : sortedBots(catalog)) { + final Path botDirectory = cacheDirectory.resolve(bot.path()).normalize(); + if (!botDirectory.startsWith(cacheDirectory)) { + throw new IOException("Bot cache path escapes its source commit: " + bot.path()); + } + final String actualHash = SourceTreeHash.sha256(botDirectory); + if (!actualHash.equals(bot.sourceHash())) { + throw new IllegalArgumentException("Bot source hash mismatch for " + bot.displayName() + + ": expected " + bot.sourceHash() + " but found " + actualHash); + } + paths.put(bot, botDirectory); + } + return new PreparedBotCache(catalog.sourceCommit(), paths); + } + + private static List sortedBots(final BotCatalog catalog) { + return catalog.activeBots().values().stream() + .sorted(Comparator.comparing(CatalogBot::displayName)) + .toList(); + } + + private static void publish(final Path stagingDirectory, final Path cacheDirectory) throws IOException { + try { + Files.move(stagingDirectory, cacheDirectory, StandardCopyOption.ATOMIC_MOVE); + return; + } catch (AtomicMoveNotSupportedException ignored) { + // The staging directory has the same parent, so a regular move remains safely scoped. + } catch (FileAlreadyExistsException exception) { + validateExistingDirectory(cacheDirectory); + return; + } + try { + Files.move(stagingDirectory, cacheDirectory); + } catch (FileAlreadyExistsException exception) { + validateExistingDirectory(cacheDirectory); + } + } + + private static void validateExistingDirectory(final Path cacheDirectory) throws IOException { + if (!Files.isDirectory(cacheDirectory) || Files.isSymbolicLink(cacheDirectory)) { + throw new IOException("Bot cache commit path is not a regular directory: " + cacheDirectory); + } + } + + private static void deleteTree(final Path directory) throws IOException { + if (!Files.exists(directory)) { + return; + } + try (Stream paths = Files.walk(directory)) { + for (final Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} + +record PreparedBotCache(String sourceCommit, Map bots) { + PreparedBotCache { + bots = Map.copyOf(bots); + } +} diff --git a/src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java b/src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java index 947f561..ae3c75b 100644 --- a/src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java +++ b/src/main/java/dev/robocode/rumble/client/GitRepositoryReader.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.Comparator; import java.util.List; import java.util.stream.Stream; @@ -26,6 +27,25 @@ public RepositoryCheckout checkout(final URI repository) throws IOException { } } + @Override + public RepositoryCheckout checkout(final URI repository, final String revision) throws IOException { + final Path directory = Files.createTempDirectory("rumble-client-repository-"); + try { + runGit("init", "--quiet", directory.toString()); + runGit("-C", directory.toString(), "remote", "add", "origin", repository.toString()); + runGit("-C", directory.toString(), "fetch", "--quiet", "--depth", "1", "origin", revision); + runGit("-C", directory.toString(), "checkout", "--quiet", "--detach", "FETCH_HEAD"); + final String actualRevision = runGit("-C", directory.toString(), "rev-parse", "HEAD").trim(); + if (!actualRevision.equals(revision)) { + throw new IOException("Repository returned " + actualRevision + " for requested commit " + revision); + } + return new Checkout(repository, directory, actualRevision); + } catch (IOException exception) { + deleteTree(directory); + throw exception; + } + } + private static String runGit(final String... arguments) throws IOException { final Process process = new ProcessBuilder(prependGit(arguments)).redirectErrorStream(true).start(); final String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); @@ -79,6 +99,27 @@ public List listFiles(final String relativeDirectory) throws IOException } } + @Override + public void copyDirectory(final String relativeDirectory, final Path destination) throws IOException { + final Path source = resolveInsideCheckout(relativeDirectory); + if (!Files.isDirectory(source)) { + throw new IOException("Repository path is not a directory: " + relativeDirectory); + } + try (Stream paths = Files.walk(source)) { + for (final Path path : paths.sorted().toList()) { + if (Files.isSymbolicLink(path)) { + throw new IOException("Bot source must not contain symbolic links: " + relativeDirectory); + } + final Path target = destination.resolve(source.relativize(path).toString()); + if (Files.isDirectory(path)) { + Files.createDirectories(target); + } else if (Files.isRegularFile(path)) { + Files.copy(path, target, StandardCopyOption.COPY_ATTRIBUTES); + } + } + } + } + private Path resolveInsideCheckout(final String relativePath) throws IOException { final Path requested = Path.of(relativePath); if (requested.isAbsolute()) { diff --git a/src/main/java/dev/robocode/rumble/client/RepositoryReader.java b/src/main/java/dev/robocode/rumble/client/RepositoryReader.java index 78e9b98..a076c0a 100644 --- a/src/main/java/dev/robocode/rumble/client/RepositoryReader.java +++ b/src/main/java/dev/robocode/rumble/client/RepositoryReader.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.net.URI; +import java.nio.file.Path; import java.util.List; /** @@ -10,6 +11,15 @@ interface RepositoryReader { RepositoryCheckout checkout(URI repository) throws IOException; + default RepositoryCheckout checkout(final URI repository, final String revision) throws IOException { + final RepositoryCheckout checkout = checkout(repository); + if (!checkout.revision().equals(revision)) { + checkout.close(); + throw new IOException("Repository revision does not match requested commit " + revision); + } + return checkout; + } + /** * A read-only repository revision. Implementations are not required to be thread-safe. */ @@ -22,6 +32,10 @@ interface RepositoryCheckout extends AutoCloseable { List listFiles(String relativeDirectory) throws IOException; + default void copyDirectory(final String relativeDirectory, final Path destination) throws IOException { + throw new IOException("Repository checkout does not support directory export"); + } + @Override void close() throws IOException; } diff --git a/src/main/java/dev/robocode/rumble/client/RumbleClient.java b/src/main/java/dev/robocode/rumble/client/RumbleClient.java index b0d651b..d5371c0 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleClient.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleClient.java @@ -45,11 +45,13 @@ static void run(final String[] arguments, final PrintStream output) throws IOExc final Path configurationPath = arguments.length == 2 ? Path.of(arguments[1]) : DEFAULT_CONFIGURATION_PATH; final ClientConfiguration configuration = new ClientConfigurationLoader().load(configurationPath); if (arguments[0].equals(SYNCHRONIZE_OPTION)) { - final RumbleSnapshot snapshot = new RumbleSynchronizer(new GitRepositoryReader()) - .synchronize(configuration); + final GitRepositoryReader repositoryReader = new GitRepositoryReader(); + final RumbleSnapshot snapshot = new RumbleSynchronizer(repositoryReader).synchronize(configuration); + final PreparedBotCache botCache = new BotCachePreparer(repositoryReader).prepare(snapshot, configuration); output.printf("Synchronized %s at %s.%n", snapshot.canonicalDataRepository(), snapshot.dataRevision()); - output.printf("Accepted behavior version %d, %d active bots, and advice for %d game types.%n", - snapshot.engine().behaviorVersion(), snapshot.catalog().activeBots().size(), snapshot.advice().size()); + output.printf("Accepted behavior version %d, cached %d active bots at %s, and advice for %d game types.%n", + snapshot.engine().behaviorVersion(), botCache.bots().size(), botCache.sourceCommit(), + snapshot.advice().size()); return; } output.printf("Configuration %s is valid for %s mode.%n", configurationPath, configuration.mode().displayName()); @@ -67,6 +69,6 @@ private static void printHelp(final PrintStream output) { output.println(" rumble-client --help"); output.println(); output.println("Use --validate-config to check a local ranked or practice configuration."); - output.println("Use --sync to validate the current canonical ranked input snapshot."); + output.println("Use --sync to validate the current ranked snapshot and prepare its immutable bot cache."); } } diff --git a/src/main/java/dev/robocode/rumble/client/SourceTreeHash.java b/src/main/java/dev/robocode/rumble/client/SourceTreeHash.java new file mode 100644 index 0000000..61f8a71 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/SourceTreeHash.java @@ -0,0 +1,68 @@ +package dev.robocode.rumble.client; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.stream.Stream; + +/** + * Computes the canonical source-tree identity shared with the Rumble bot catalog. + */ +final class SourceTreeHash { + private SourceTreeHash() { + } + + static String sha256(final Path directory) throws IOException { + final MessageDigest digest = sha256Digest(); + for (final Path file : sourceFiles(directory)) { + final String relativePath = directory.relativize(file).toString().replace('\\', '/'); + digest.update(relativePath.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(Files.readAllBytes(file)); + digest.update((byte) 0); + } + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } + + private static List sourceFiles(final Path directory) throws IOException { + if (!Files.isDirectory(directory) || Files.isSymbolicLink(directory)) { + throw new IOException("Bot cache path is not a regular directory: " + directory); + } + try (Stream paths = Files.walk(directory)) { + final List allPaths = paths.sorted(Comparator.comparing( + path -> directory.relativize(path).toString().replace('\\', '/'))).toList(); + for (final Path path : allPaths) { + if (Files.isSymbolicLink(path)) { + throw new IOException("Bot source must not contain symbolic links: " + path); + } + } + return allPaths.stream() + .filter(Files::isRegularFile) + .filter(path -> !containsPycache(directory.relativize(path))) + .toList(); + } + } + + private static boolean containsPycache(final Path relativePath) { + for (final Path segment : relativePath) { + if (segment.toString().equals("__pycache__")) { + return true; + } + } + return false; + } + + private static MessageDigest sha256Digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/src/test/java/dev/robocode/rumble/client/BotCachePreparerTest.java b/src/test/java/dev/robocode/rumble/client/BotCachePreparerTest.java new file mode 100644 index 0000000..5d2e17e --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/BotCachePreparerTest.java @@ -0,0 +1,169 @@ +package dev.robocode.rumble.client; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BotCachePreparerTest { + private static final URI BOTS_REPOSITORY = URI.create("https://github.com/example/rumble-bots"); + private static final String SOURCE_COMMIT = "cccccccccccccccccccccccccccccccccccccccc"; + private static final String SOURCE_HASH = + "sha256:f1fa3ca115fe477cf21020ce2d22c2ea99fba003071b1c17e999102b5d308d5b"; + + @TempDir + Path temporaryDirectory; + + @Test + @Tag("RCL-002") + void testRCL002_IntegrationPositive_materializesExactCommitAndValidatesSourceHash() throws IOException { + final Path sourceRepository = createSourceRepository(); + final TestRepositoryReader repositories = new TestRepositoryReader(sourceRepository, SOURCE_COMMIT); + final ClientConfiguration configuration = configuration(); + + final PreparedBotCache cache = new BotCachePreparer(repositories).prepare( + snapshot(SOURCE_HASH), configuration); + + final Path cachedBot = configuration.workDirectory().resolve("cache/bots") + .resolve(SOURCE_COMMIT).resolve("bots/java/Alpha"); + assertEquals(SOURCE_COMMIT, repositories.requestedRevision()); + assertEquals(SOURCE_COMMIT, cache.sourceCommit()); + assertEquals("class Alpha {}\n", Files.readString(cachedBot.resolve("src/Alpha.java"))); + assertEquals(cachedBot, cache.bots().values().iterator().next()); + + new BotCachePreparer(repositories).prepare(snapshot(SOURCE_HASH), configuration); + assertEquals(1, repositories.checkoutCount()); + } + + @Test + @Tag("RCL-002") + void testRCL002_IntegrationNegative_rejectsHashMismatchWithoutPublishingPartialCache() throws IOException { + final TestRepositoryReader repositories = new TestRepositoryReader(createSourceRepository(), SOURCE_COMMIT); + final ClientConfiguration configuration = configuration(); + final String wrongHash = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + + assertThrows(IllegalArgumentException.class, + () -> new BotCachePreparer(repositories).prepare(snapshot(wrongHash), configuration)); + + assertFalse(Files.exists(configuration.workDirectory().resolve("cache/bots").resolve(SOURCE_COMMIT))); + try (Stream entries = Files.list(configuration.workDirectory().resolve("cache/bots"))) { + assertTrue(entries.findAny().isEmpty()); + } + } + + private Path createSourceRepository() throws IOException { + final Path bot = temporaryDirectory.resolve("source/bots/java/Alpha"); + Files.createDirectories(bot.resolve("src")); + Files.writeString(bot.resolve("Alpha.json"), "{}\n"); + Files.writeString(bot.resolve("src/Alpha.java"), "class Alpha {}\n"); + return temporaryDirectory.resolve("source"); + } + + private ClientConfiguration configuration() { + return new ClientConfiguration(BOTS_REPOSITORY, URI.create("https://github.com/example/rumble-data"), + Optional.of("alice-desktop"), Set.of(), Set.of(GameType.ONE_VS_ONE), 1, + ClientMode.RANKED, temporaryDirectory.resolve("work")); + } + + private static RumbleSnapshot snapshot(final String sourceHash) { + final CatalogBot bot = new CatalogBot("Alpha", "1.0", "JVM", "bots/java/Alpha", sourceHash); + final BotCatalog catalog = new BotCatalog( + URI.create("https://raw.githubusercontent.com/example/rumble-bots/main/bots/index.json"), + SOURCE_COMMIT, Map.of(bot.displayName(), bot)); + return new RumbleSnapshot(URI.create("https://github.com/example/rumble-data"), + "dddddddddddddddddddddddddddddddddddddddd", + new EnginePin(1, "unreleased", "example", Map.of()), catalog, + new ClientRegistration("alice", "alice-desktop"), Map.of()); + } + + private static final class TestRepositoryReader implements RepositoryReader { + private final Path repositoryDirectory; + private final String revision; + private String requestedRevision; + private int checkoutCount; + + private TestRepositoryReader(final Path repositoryDirectory, final String revision) { + this.repositoryDirectory = repositoryDirectory; + this.revision = revision; + } + + String requestedRevision() { + return requestedRevision; + } + + int checkoutCount() { + return checkoutCount; + } + + @Override + public RepositoryCheckout checkout(final URI repository) { + checkoutCount++; + return new RepositoryCheckout() { + @Override + public URI repository() { + return repository; + } + + @Override + public String revision() { + return revision; + } + + @Override + public String read(final String relativePath) throws IOException { + return Files.readString(repositoryDirectory.resolve(relativePath)); + } + + @Override + public List listFiles(final String relativeDirectory) throws IOException { + try (Stream paths = Files.list(repositoryDirectory.resolve(relativeDirectory))) { + return paths.filter(Files::isRegularFile) + .map(path -> repositoryDirectory.relativize(path).toString().replace('\\', '/')) + .sorted() + .toList(); + } + } + + @Override + public void copyDirectory(final String relativeDirectory, final Path destination) throws IOException { + final Path source = repositoryDirectory.resolve(relativeDirectory); + try (Stream paths = Files.walk(source)) { + for (final Path path : paths.sorted().toList()) { + final Path target = destination.resolve(source.relativize(path).toString()); + if (Files.isDirectory(path)) { + Files.createDirectories(target); + } else { + Files.copy(path, target, StandardCopyOption.COPY_ATTRIBUTES); + } + } + } + } + + @Override + public void close() { + } + }; + } + + @Override + public RepositoryCheckout checkout(final URI repository, final String requestedCommit) throws IOException { + requestedRevision = requestedCommit; + return RepositoryReader.super.checkout(repository, requestedCommit); + } + } +}