From 853b4b15ce1f575c237f26bca4e499bd5fbe2618 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 11:34:35 -0600
Subject: [PATCH 01/30] Add a verified VotingPlugin artifact store
---
.../control/artifact/ArtifactStore.java | 411 ++++++++++++++++++
.../control/artifact/ArtifactStoreTest.java | 197 +++++++++
2 files changed, 608 insertions(+)
create mode 100644 src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
create mode 100644 src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
new file mode 100644
index 0000000..7d964d2
--- /dev/null
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -0,0 +1,411 @@
+package com.bencodez.votingplugin.control.artifact;
+
+import com.bencodez.votingplugin.control.DurableFiles;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Enumeration;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * Private, content-addressed staging for administrator-supplied VotingPlugin JARs.
+ *
+ *
This deliberately has no HTTP knowledge: callers receive only an opaque SHA-256
+ * identifier and must separately authorize any deployment operation.
+ */
+public final class ArtifactStore {
+ public static final long MAX_UPLOAD_BYTES = 64L * 1024L * 1024L;
+ public static final long MAX_STORED_BYTES = 512L * 1024L * 1024L;
+ public static final int MAX_STORED_ARTIFACTS = 32;
+ private static final int MAX_ENTRY_COUNT = 8_192;
+ private static final long MAX_ENTRY_BYTES = 32L * 1024L * 1024L;
+ private static final long MAX_EXPANDED_BYTES = 128L * 1024L * 1024L;
+ private static final long MAX_COMPRESSION_RATIO = 200L;
+ private static final int MAX_PLUGIN_YML_BYTES = 64 * 1024;
+ private static final Set DIRECTORY_PERMISSIONS = Set.of(
+ PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
+ private static final Set FILE_PERMISSIONS = Set.of(
+ PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
+
+ private final Path directory;
+ private final long maximumStoredBytes;
+ private final int maximumStoredArtifacts;
+
+ /** Creates or opens an empty private directory owned by Control. */
+ public ArtifactStore(Path directory) throws IOException {
+ this(directory, MAX_STORED_BYTES, MAX_STORED_ARTIFACTS);
+ }
+
+ ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts) throws IOException {
+ if (directory == null) throw rejected();
+ if (maximumStoredBytes < 1 || maximumStoredArtifacts < 1) throw rejected();
+ this.directory = directory.toAbsolutePath().normalize();
+ this.maximumStoredBytes = maximumStoredBytes;
+ this.maximumStoredArtifacts = maximumStoredArtifacts;
+ try {
+ createPrivateDirectory(this.directory);
+ removeIncompleteUploads();
+ } catch (ArtifactException failure) {
+ throw failure;
+ } catch (IOException | RuntimeException failure) {
+ throw rejected();
+ }
+ }
+
+ private void removeIncompleteUploads() throws IOException {
+ try (var files = Files.list(directory)) {
+ for (Path file : files.toList()) {
+ String name = file.getFileName().toString();
+ if (!name.startsWith("upload-") || !name.endsWith(".part")) continue;
+ if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
+ throw rejected();
+ }
+ Files.delete(file);
+ }
+ }
+ DurableFiles.forceDirectory(directory);
+ }
+
+ /**
+ * Streams, verifies and publishes one JAR. When supplied, the claimed digest must
+ * match; otherwise the store establishes it. The returned identifier is the
+ * lowercase SHA-256, not a filesystem path.
+ */
+ public synchronized Artifact upload(InputStream source, String displayFilename, String claimedSha256)
+ throws IOException {
+ return upload(source, displayFilename, claimedSha256, Set.of());
+ }
+
+ /** Uploads while preserving every artifact referenced by retained durable deployments. */
+ public synchronized Artifact upload(InputStream source, String displayFilename, String claimedSha256,
+ Set protectedArtifactIds) throws IOException {
+ if (source == null || !isSafeDisplayFilename(displayFilename)
+ || claimedSha256 != null && !isSha256(claimedSha256)
+ || protectedArtifactIds == null || protectedArtifactIds.stream().anyMatch(id -> !isSha256(id))) {
+ throw rejected();
+ }
+ Path temporary = null;
+ boolean published = false;
+ try {
+ verifyDirectory();
+ temporary = Files.createTempFile(directory, "upload-", ".part");
+ setPermissions(temporary, FILE_PERMISSIONS);
+ DigestAndSize digest = copyBounded(source, temporary);
+ String actual = digest.sha256();
+ if (claimedSha256 != null && !actual.equals(claimedSha256)) throw rejected();
+ inspectJar(temporary);
+
+ Path artifact = artifactPath(actual);
+ if (Files.exists(artifact, LinkOption.NOFOLLOW_LINKS)) {
+ verifyExistingArtifact(artifact, actual);
+ return new Artifact(actual, displayFilename, digest.size());
+ }
+ ensureCapacity(digest.size(), protectedArtifactIds);
+ publish(temporary, artifact);
+ published = true;
+ return new Artifact(actual, displayFilename, digest.size());
+ } catch (ArtifactException failure) {
+ throw failure;
+ } catch (IOException | RuntimeException failure) {
+ throw rejected();
+ } finally {
+ if (!published && temporary != null) deleteTemporary(temporary);
+ }
+ }
+
+ private void ensureCapacity(long incomingBytes, Set protectedArtifactIds) throws IOException {
+ List stored = new ArrayList<>();
+ long bytes = 0;
+ try (var files = Files.list(directory)) {
+ for (Path file : files.toList()) {
+ String name = file.getFileName().toString();
+ if (!name.matches("[0-9a-f]{64}\\.jar")) continue;
+ if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) throw rejected();
+ long size = Files.size(file);
+ bytes = Math.addExact(bytes, size);
+ stored.add(new StoredFile(file, name.substring(0, 64), size, Files.getLastModifiedTime(file).toMillis()));
+ }
+ } catch (ArithmeticException failure) {
+ throw rejected();
+ }
+ stored.sort(Comparator.comparingLong(StoredFile::modified).thenComparing(item -> item.path().toString()));
+ int count = stored.size();
+ for (StoredFile candidate : stored) {
+ if (count < maximumStoredArtifacts && bytes <= maximumStoredBytes - incomingBytes) break;
+ if (protectedArtifactIds.contains(candidate.artifactId())) continue;
+ Files.delete(candidate.path());
+ DurableFiles.forceDirectory(directory);
+ bytes -= candidate.size();
+ count--;
+ }
+ if (count >= maximumStoredArtifacts || bytes > maximumStoredBytes - incomingBytes) throw rejected();
+ }
+
+ /** Opens a verified immutable artifact by its opaque identifier. */
+ public InputStream open(String artifactId) throws IOException {
+ if (!isSha256(artifactId)) throw rejected();
+ try {
+ verifyDirectory();
+ Path artifact = artifactPath(artifactId);
+ verifyExistingArtifact(artifact, artifactId);
+ return Channels.newInputStream(FileChannel.open(artifact,
+ Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)));
+ } catch (IOException failure) {
+ throw rejected();
+ }
+ }
+
+ /** Returns verified metadata without exposing the private backing path. */
+ public Artifact describe(String artifactId) throws IOException {
+ if (!isSha256(artifactId)) throw rejected();
+ try {
+ verifyDirectory();
+ Path artifact = artifactPath(artifactId);
+ verifyExistingArtifact(artifact, artifactId);
+ return new Artifact(artifactId, "VotingPlugin.jar", Files.size(artifact));
+ } catch (IOException failure) {
+ throw rejected();
+ }
+ }
+
+ private static DigestAndSize copyBounded(InputStream source, Path target) throws IOException {
+ MessageDigest digest = sha256();
+ long total = 0;
+ byte[] bytes = new byte[16 * 1024];
+ try (FileChannel output = FileChannel.open(target, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ for (int read; (read = source.read(bytes)) != -1;) {
+ if (read == 0) continue;
+ total = Math.addExact(total, read);
+ if (total > MAX_UPLOAD_BYTES) throw rejected();
+ digest.update(bytes, 0, read);
+ ByteBuffer buffer = ByteBuffer.wrap(bytes, 0, read);
+ while (buffer.hasRemaining()) output.write(buffer);
+ }
+ output.force(true);
+ } catch (ArithmeticException failure) {
+ throw rejected();
+ }
+ return new DigestAndSize(hex(digest.digest()), total);
+ }
+
+ private static void inspectJar(Path file) throws IOException {
+ int entries = 0;
+ long expanded = 0;
+ int pluginYmlEntries = 0;
+ byte[] pluginYml = null;
+ Set entryNames = new HashSet<>();
+ try (ZipFile zip = new ZipFile(file.toFile(), StandardCharsets.UTF_8)) {
+ Enumeration extends ZipEntry> enumeration = zip.entries();
+ while (enumeration.hasMoreElements()) {
+ ZipEntry entry = enumeration.nextElement();
+ if (++entries > MAX_ENTRY_COUNT || !isSafeZipEntryName(entry.getName())
+ || !entryNames.add(entry.getName())) throw rejected();
+ long declaredSize = entry.getSize();
+ long compressedSize = entry.getCompressedSize();
+ if (declaredSize < 0 || declaredSize > MAX_ENTRY_BYTES || compressedSize < 0
+ || (compressedSize > 0 && declaredSize > compressedSize * MAX_COMPRESSION_RATIO)) {
+ throw rejected();
+ }
+ expanded = addBounded(expanded, declaredSize, MAX_EXPANDED_BYTES);
+ if ("plugin.yml".equals(entry.getName())) {
+ if (++pluginYmlEntries != 1 || entry.isDirectory() || declaredSize > MAX_PLUGIN_YML_BYTES) {
+ throw rejected();
+ }
+ pluginYml = readExactly(zip.getInputStream(entry), declaredSize);
+ } else if (!entry.isDirectory()) {
+ consumeBounded(zip.getInputStream(entry), declaredSize);
+ }
+ }
+ } catch (ArtifactException failure) {
+ throw failure;
+ } catch (IOException | RuntimeException failure) {
+ throw rejected();
+ }
+ if (entries == 0 || pluginYmlEntries != 1 || pluginYml == null || !declaresVotingPlugin(pluginYml)) throw rejected();
+ }
+
+ private static void consumeBounded(InputStream input, long expected) throws IOException {
+ long actual = 0;
+ byte[] buffer = new byte[8192];
+ try (input) {
+ for (int read; (read = input.read(buffer)) != -1;) {
+ actual = addBounded(actual, read, expected);
+ }
+ }
+ if (actual != expected) throw rejected();
+ }
+
+ private static byte[] readExactly(InputStream input, long expected) throws IOException {
+ byte[] result = new byte[(int) expected];
+ int offset = 0;
+ try (input) {
+ while (offset < result.length) {
+ int read = input.read(result, offset, result.length - offset);
+ if (read == -1) break;
+ if (read == 0) continue;
+ offset = Math.toIntExact(addBounded(offset, read, expected));
+ }
+ if (offset == result.length && input.read() != -1) throw rejected();
+ }
+ if (offset != expected) throw rejected();
+ return result;
+ }
+
+ private static boolean declaresVotingPlugin(byte[] pluginYml) throws IOException {
+ final String text;
+ try {
+ text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(pluginYml)).toString();
+ } catch (CharacterCodingException failure) {
+ throw rejected();
+ }
+ boolean found = false;
+ for (String line : text.split("\\r?\\n", -1)) {
+ if (line.startsWith("name:")) {
+ if (found) return false;
+ found = true;
+ String value = line.substring("name:".length()).trim();
+ int comment = value.indexOf('#');
+ if (comment >= 0) value = value.substring(0, comment).trim();
+ if (!("VotingPlugin".equals(value) || "\"VotingPlugin\"".equals(value)
+ || "'VotingPlugin'".equals(value))) return false;
+ }
+ }
+ return found;
+ }
+
+ private void publish(Path temporary, Path artifact) throws IOException {
+ try {
+ Files.move(temporary, artifact, StandardCopyOption.ATOMIC_MOVE);
+ } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) {
+ Files.move(temporary, artifact);
+ } catch (java.nio.file.FileAlreadyExistsException collision) {
+ verifyExistingArtifact(artifact, artifact.getFileName().toString().substring(0, 64));
+ return;
+ }
+ setPermissions(artifact, FILE_PERMISSIONS);
+ try (FileChannel channel = FileChannel.open(artifact, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
+ channel.force(true);
+ }
+ DurableFiles.forceDirectory(directory);
+ }
+
+ private void verifyExistingArtifact(Path artifact, String expectedHash) throws IOException {
+ if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)
+ || Files.size(artifact) > MAX_UPLOAD_BYTES || !hash(artifact).equals(expectedHash)) throw rejected();
+ }
+
+ private static String hash(Path path) throws IOException {
+ MessageDigest digest = sha256();
+ long total = 0;
+ try (InputStream input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
+ byte[] buffer = new byte[16 * 1024];
+ for (int read; (read = input.read(buffer)) != -1;) {
+ total = addBounded(total, read, MAX_UPLOAD_BYTES);
+ digest.update(buffer, 0, read);
+ }
+ }
+ return hex(digest.digest());
+ }
+
+ private void verifyDirectory() throws IOException {
+ if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw rejected();
+ rejectSymlinkedAncestors(directory);
+ }
+
+ private static void createPrivateDirectory(Path directory) throws IOException {
+ Path parent = directory.getParent();
+ if (parent == null) throw rejected();
+ rejectSymlinkedAncestors(parent);
+ if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
+ if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw rejected();
+ } else {
+ Files.createDirectory(directory);
+ }
+ setPermissions(directory, DIRECTORY_PERMISSIONS);
+ DurableFiles.forceDirectory(parent);
+ }
+
+ private static void rejectSymlinkedAncestors(Path path) throws IOException {
+ for (Path current = path.toAbsolutePath().normalize(); current != null; current = current.getParent()) {
+ if (Files.isSymbolicLink(current)) throw rejected();
+ }
+ }
+
+ private Path artifactPath(String sha256) { return directory.resolve(sha256 + ".jar"); }
+
+ private static boolean isSafeDisplayFilename(String filename) {
+ return filename != null && filename.length() <= 120 && filename.matches("[A-Za-z0-9][A-Za-z0-9 ._-]{0,115}\\.jar")
+ && !filename.contains("..") && !filename.chars().anyMatch(Character::isISOControl);
+ }
+
+ private static boolean isSafeZipEntryName(String name) {
+ if (name == null || name.isEmpty() || name.length() > 512 || name.startsWith("/") || name.startsWith("\\")
+ || name.indexOf('\\') >= 0 || name.indexOf('\u0000') >= 0) return false;
+ for (String component : name.split("/", -1)) if (component.equals(".") || component.equals("..")) return false;
+ return true;
+ }
+
+ private static boolean isSha256(String value) { return value != null && value.matches("[0-9a-f]{64}"); }
+
+ private static long addBounded(long current, long added, long maximum) throws IOException {
+ try {
+ long result = Math.addExact(current, added);
+ if (result > maximum) throw rejected();
+ return result;
+ } catch (ArithmeticException failure) {
+ throw rejected();
+ }
+ }
+
+ private static MessageDigest sha256() throws IOException {
+ try { return MessageDigest.getInstance("SHA-256"); }
+ catch (NoSuchAlgorithmException impossible) { throw new IOException("Artifact upload rejected", impossible); }
+ }
+
+ private static String hex(byte[] bytes) {
+ StringBuilder value = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) value.append(String.format(Locale.ROOT, "%02x", b));
+ return value.toString();
+ }
+
+ private static void setPermissions(Path path, Set permissions) throws IOException {
+ try { Files.setPosixFilePermissions(path, permissions); }
+ catch (UnsupportedOperationException ignored) { /* Not available on Windows or some network filesystems. */ }
+ }
+
+ private static void deleteTemporary(Path temporary) {
+ try { Files.deleteIfExists(temporary); } catch (IOException ignored) { /* Private temporary cleanup only. */ }
+ }
+
+ private static ArtifactException rejected() { return new ArtifactException("Artifact upload rejected"); }
+
+ public record Artifact(String artifactId, String displayFilename, long size) { }
+
+ public static final class ArtifactException extends IOException {
+ private ArtifactException(String message) { super(message); }
+ }
+
+ private record DigestAndSize(String sha256, long size) { }
+ private record StoredFile(Path path, String artifactId, long size, long modified) { }
+}
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
new file mode 100644
index 0000000..722a44b
--- /dev/null
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -0,0 +1,197 @@
+package com.bencodez.votingplugin.control.artifact;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ArtifactStoreTest {
+ @TempDir Path directory;
+
+ @Test void streamsVerifiesAndPublishesAnImmutableContentAddressedVotingPluginJar() throws Exception {
+ byte[] jar = jar("name: VotingPlugin\nversion: 7.1.2\n", "plugin/Main.class", new byte[] {1, 2, 3});
+ String sha256 = sha256(jar);
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"));
+
+ ArtifactStore.Artifact first = store.upload(new ByteArrayInputStream(jar), "VotingPlugin-7.1.2.jar", sha256);
+ ArtifactStore.Artifact duplicate = store.upload(new ByteArrayInputStream(jar), "same-content.jar", sha256);
+ ArtifactStore.Artifact serverHashed = store.upload(new ByteArrayInputStream(jar), "browser-upload.jar", null);
+
+ assertEquals(sha256, first.artifactId());
+ assertEquals(jar.length, first.size());
+ assertEquals(sha256, duplicate.artifactId());
+ assertEquals(sha256, serverHashed.artifactId());
+ assertArrayEquals(jar, store.open(sha256).readAllBytes());
+ Path published = directory.resolve("artifacts").resolve(sha256 + ".jar");
+ assertTrue(Files.isRegularFile(published));
+ try (var entries = Files.list(directory.resolve("artifacts"))) {
+ assertEquals(1, entries.count());
+ }
+ }
+
+ @Test void rejectsWrongOrNonLowercaseClaimsAndDoesNotPublishPartialArtifacts() throws Exception {
+ byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"));
+ String actual = sha256(jar);
+
+ assertRejected(() -> store.upload(new ByteArrayInputStream(jar), "VotingPlugin.jar", "0".repeat(64)));
+ assertRejected(() -> store.upload(new ByteArrayInputStream(jar), "VotingPlugin.jar", actual.toUpperCase()));
+ try (var entries = Files.list(directory.resolve("artifacts"))) {
+ assertEquals(0, entries.count());
+ }
+ }
+
+ @Test void rejectsUnsafeDisplayNamesAndNeverUsesThemAsPaths() throws Exception {
+ byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"));
+
+ assertRejected(() -> store.upload(new ByteArrayInputStream(jar), "../VotingPlugin.jar", sha256(jar)));
+ assertRejected(() -> store.upload(new ByteArrayInputStream(jar), "VotingPlugin.zip", sha256(jar)));
+ assertFalse(Files.exists(directory.resolve("VotingPlugin.jar")));
+ }
+
+ @Test void rejectsMissingOrWrongPluginDescriptorAndCompressedBombs() throws Exception {
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"));
+ byte[] missing = jar(null, "plugin/Main.class", new byte[] {1});
+ byte[] wrong = jar("name: AnotherPlugin\n", "plugin/Main.class", new byte[] {1});
+ byte[] ambiguous = jar("name: VotingPlugin\nname: AnotherPlugin\n", "plugin/Main.class", new byte[] {1});
+ byte[] bomb = jar("name: VotingPlugin\n", "data.bin", new byte[1_000_000]);
+
+ assertRejected(() -> store.upload(new ByteArrayInputStream(missing), "VotingPlugin.jar", sha256(missing)));
+ assertRejected(() -> store.upload(new ByteArrayInputStream(wrong), "VotingPlugin.jar", sha256(wrong)));
+ assertRejected(() -> store.upload(new ByteArrayInputStream(ambiguous), "VotingPlugin.jar", sha256(ambiguous)));
+ assertRejected(() -> store.upload(new ByteArrayInputStream(bomb), "VotingPlugin.jar", sha256(bomb)));
+ }
+
+ @Test void removesIncompleteUploadsOnStartupAndRejectsDuplicateEntryNames() throws Exception {
+ Path artifacts = directory.resolve("artifacts");
+ Files.createDirectories(artifacts);
+ Path incomplete = Files.writeString(artifacts.resolve("upload-crashed.part"), "partial");
+ ArtifactStore store = new ArtifactStore(artifacts);
+ assertFalse(Files.exists(incomplete));
+
+ byte[] duplicate = jarWithDuplicateClassNames();
+ assertRejected(() -> store.upload(new ByteArrayInputStream(duplicate), "VotingPlugin.jar", sha256(duplicate)));
+ }
+
+ @Test void rejectsSymlinkedStorageAndExistingArtifactTargets() throws Exception {
+ Path real = directory.resolve("real");
+ Files.createDirectory(real);
+ Path linked = directory.resolve("linked");
+ try {
+ Files.createSymbolicLink(linked, real);
+ } catch (UnsupportedOperationException | IOException unavailable) {
+ return;
+ }
+ assertRejected(() -> new ArtifactStore(linked.resolve("artifacts")));
+
+ byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
+ String sha256 = sha256(jar);
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"));
+ Path published = directory.resolve("artifacts").resolve(sha256 + ".jar");
+ Files.createSymbolicLink(published, directory.resolve("outside.jar"));
+
+ assertRejected(() -> store.upload(new ByteArrayInputStream(jar), "VotingPlugin.jar", sha256));
+ assertRejected(() -> store.open(sha256));
+ }
+
+ @Test void rejectsAStreamThatExceedsTheUploadBound() throws Exception {
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"));
+ InputStream oversized = new InputStream() {
+ private long remaining = ArtifactStore.MAX_UPLOAD_BYTES + 1;
+ @Override public int read(byte[] target, int offset, int length) {
+ if (remaining == 0) return -1;
+ int count = (int) Math.min(length, remaining);
+ Arrays.fill(target, offset, offset + count, (byte) 1);
+ remaining -= count;
+ return count;
+ }
+ @Override public int read() { return remaining-- > 0 ? 1 : -1; }
+ };
+ assertRejected(() -> store.upload(oversized, "VotingPlugin.jar", "0".repeat(64)));
+ try (var entries = Files.list(directory.resolve("artifacts"))) {
+ assertEquals(0, entries.count());
+ }
+ }
+
+ @Test void evictsOnlyUnreferencedArtifactsAndRejectsWhenEverySlotIsProtected() throws Exception {
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"), 1_000_000, 2);
+ byte[] first = jar("name: VotingPlugin\n", "plugin/One.class", new byte[] {1});
+ byte[] second = jar("name: VotingPlugin\n", "plugin/Two.class", new byte[] {2});
+ byte[] third = jar("name: VotingPlugin\n", "plugin/Three.class", new byte[] {3});
+ String firstId = store.upload(new ByteArrayInputStream(first), "first.jar", sha256(first)).artifactId();
+ String secondId = store.upload(new ByteArrayInputStream(second), "second.jar", sha256(second), Set.of(firstId)).artifactId();
+
+ assertRejected(() -> store.upload(new ByteArrayInputStream(third), "third.jar", sha256(third),
+ Set.of(firstId, secondId)));
+ String thirdId = store.upload(new ByteArrayInputStream(third), "third.jar", sha256(third), Set.of(firstId)).artifactId();
+
+ assertArrayEquals(first, store.open(firstId).readAllBytes());
+ assertArrayEquals(third, store.open(thirdId).readAllBytes());
+ assertRejected(() -> store.open(secondId));
+ }
+
+ private static byte[] jar(String pluginYml, String entryName, byte[] content) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(output)) {
+ if (pluginYml != null) {
+ zip.putNextEntry(new ZipEntry("plugin.yml"));
+ zip.write(pluginYml.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ zip.closeEntry();
+ }
+ zip.putNextEntry(new ZipEntry(entryName));
+ zip.write(content);
+ zip.closeEntry();
+ }
+ return output.toByteArray();
+ }
+
+ private static byte[] jarWithDuplicateClassNames() throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(output)) {
+ for (String name : List.of("plugin.yml", "plugin/One.class", "plugin/Two.class")) {
+ zip.putNextEntry(new ZipEntry(name));
+ zip.write("plugin.yml".equals(name) ? "name: VotingPlugin\n".getBytes() : new byte[] {1});
+ zip.closeEntry();
+ }
+ }
+ byte[] bytes = output.toByteArray();
+ byte[] from = "plugin/Two.class".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
+ byte[] to = "plugin/One.class".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
+ for (int offset = 0; offset <= bytes.length - from.length; offset++) {
+ if (java.util.Arrays.equals(bytes, offset, offset + from.length, from, 0, from.length)) {
+ System.arraycopy(to, 0, bytes, offset, to.length);
+ }
+ }
+ return bytes;
+ }
+
+ private static String sha256(byte[] bytes) throws Exception {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
+ }
+
+ private static void assertRejected(ThrowingRunnable operation) {
+ ArtifactStore.ArtifactException failure = assertThrows(ArtifactStore.ArtifactException.class, operation::run);
+ assertEquals("Artifact upload rejected", failure.getMessage());
+ assertFalse(failure.getMessage().contains("/"));
+ }
+
+ @FunctionalInterface private interface ThrowingRunnable { void run() throws Exception; }
+}
From 2705bf087859c41e7674296f247404072668e402 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 12:00:11 -0600
Subject: [PATCH 02/30] Harden verified artifact retention
---
docs/control-management.md | 3 ++
pom.xml | 4 +++
.../control/artifact/ArtifactStore.java | 32 +++++++++++--------
.../domain/ConfigurationOperations.java | 11 +++++++
.../protocol/ManagedConfiguration.java | 7 ++++
src/main/resources/web/app.js | 20 +++++++++---
.../control/artifact/ArtifactStoreTest.java | 21 ++++++++++++
.../domain/ConfigurationOperationsTest.java | 9 ++++++
.../control/http/ControlHttpServerTest.java | 3 ++
9 files changed, 92 insertions(+), 18 deletions(-)
diff --git a/docs/control-management.md b/docs/control-management.md
index 93e9abc..dbad334 100644
--- a/docs/control-management.md
+++ b/docs/control-management.md
@@ -45,9 +45,12 @@ Control accepts only the intersection with its own allow-list.
| `config.vote-sites-sync.v1` | Reward-safe VoteSites merge from one backend to selected targets |
| `config.transport-test.v1` | Typed, bounded proxy-to-backend communication check |
| `config.proxy-method.v1` | Coordinated preview/apply and acknowledged runtime replacement for a supported network proxy method |
+| `config.proxy-method.v2` | HTTP proxy-method selection and HTTP backend quick setup; only paired connectors implementing the HTTP contract advertise it |
| `data.inspect.v1` | Typed read-only data, health, simulation, and diagnostics requests |
Do not infer support from plugin version strings. Check `acceptedCapabilities` for the exact capability.
+Deploy the paired VotingPlugin connector change before enabling Control features that require a new capability. During a
+mixed-version rollout, older nodes remain connected but are excluded from v2 HTTP previews and applies.
## WebUI feature map
diff --git a/pom.xml b/pom.xml
index 09b8c35..187a722 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,6 +11,7 @@
UTF-8
5.11.4
2.18.2
+ 2.6
@@ -19,6 +20,9 @@
com.fasterxml.jackson.datatypejackson-datatype-jsr310${jackson.version}
+
+ org.yamlsnakeyaml${snakeyaml.version}
+
org.junit.jupiterjunit-jupiter${junit.version}test
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 7d964d2..07460da 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -26,6 +26,9 @@
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* Private, content-addressed staging for administrator-supplied VotingPlugin JARs.
@@ -150,15 +153,17 @@ private void ensureCapacity(long incomingBytes, Set protectedArtifactIds
}
stored.sort(Comparator.comparingLong(StoredFile::modified).thenComparing(item -> item.path().toString()));
int count = stored.size();
+ List evictionPlan = new ArrayList<>();
for (StoredFile candidate : stored) {
if (count < maximumStoredArtifacts && bytes <= maximumStoredBytes - incomingBytes) break;
if (protectedArtifactIds.contains(candidate.artifactId())) continue;
- Files.delete(candidate.path());
- DurableFiles.forceDirectory(directory);
+ evictionPlan.add(candidate);
bytes -= candidate.size();
count--;
}
if (count >= maximumStoredArtifacts || bytes > maximumStoredBytes - incomingBytes) throw rejected();
+ for (StoredFile candidate : evictionPlan) Files.delete(candidate.path());
+ if (!evictionPlan.isEmpty()) DurableFiles.forceDirectory(directory);
}
/** Opens a verified immutable artifact by its opaque identifier. */
@@ -279,19 +284,18 @@ private static boolean declaresVotingPlugin(byte[] pluginYml) throws IOException
} catch (CharacterCodingException failure) {
throw rejected();
}
- boolean found = false;
- for (String line : text.split("\\r?\\n", -1)) {
- if (line.startsWith("name:")) {
- if (found) return false;
- found = true;
- String value = line.substring("name:".length()).trim();
- int comment = value.indexOf('#');
- if (comment >= 0) value = value.substring(0, comment).trim();
- if (!("VotingPlugin".equals(value) || "\"VotingPlugin\"".equals(value)
- || "'VotingPlugin'".equals(value))) return false;
- }
+ try {
+ LoaderOptions options = new LoaderOptions();
+ options.setAllowDuplicateKeys(false);
+ options.setAllowRecursiveKeys(false);
+ options.setMaxAliasesForCollections(0);
+ options.setCodePointLimit(MAX_PLUGIN_YML_BYTES);
+ Object document = new Yaml(new SafeConstructor(options)).load(text);
+ return document instanceof java.util.Map, ?> descriptor
+ && "VotingPlugin".equals(descriptor.get("name"));
+ } catch (RuntimeException failure) {
+ throw rejected();
}
- return found;
}
private void publish(Path temporary, Path artifact) throws IOException {
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index 0b04262..85f8e2b 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -480,6 +480,17 @@ private void validateProxyMethodTargets(ValidatedTargets targets, ManagedConfigu
private static void validateConfigurationTargets(ValidatedTargets targets,
ManagedConfiguration configuration) {
+ if (ManagedConfiguration.QUICK_SETUP.equals(configuration.domain())
+ && "proxy-backend".equals(configuration.preset())) {
+ List invalid = targets.nodeIds().stream()
+ .filter(nodeId -> !"BUKKIT".equalsIgnoreCase(targets.platforms().get(nodeId)))
+ .toList();
+ if (!invalid.isEmpty()) {
+ throw new ValidationException("INVALID_TARGET",
+ "Backend proxy settings require Bukkit nodes", invalid);
+ }
+ return;
+ }
if (!ManagedConfiguration.FILE.equals(configuration.domain())) return;
boolean proxyFile = "bungeeconfig.yml".equals(configuration.fileName());
List invalid = targets.nodeIds().stream()
diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
index 210655f..29889d1 100644
--- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
+++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
@@ -116,6 +116,11 @@ public void validateProposal() {
.contains(options.get("method")))) {
throw new IllegalArgumentException("proxy method requires one supported method");
}
+ if (QUICK_SETUP.equals(domain) && "proxy-backend".equals(preset) && options.containsKey("method")
+ && !List.of("PLUGINMESSAGING", "REDIS", "MQTT", "SOCKETS", "MYSQL", "HTTP")
+ .contains(options.get("method"))) {
+ throw new IllegalArgumentException("proxy backend requires a canonical supported method");
+ }
if (QUICK_SETUP.equals(domain) && REWARD_BUILDER.equals(preset)
&& (options.size() != 1 || !options.containsKey("proposal"))) {
throw new IllegalArgumentException("reward builder requires one typed proposal");
@@ -130,6 +135,8 @@ public String capability() {
? "config.vote-sites-sync.v1" : COMMUNICATION_TEST.equals(preset)
? "config.transport-test.v1" : PROXY_METHOD.equals(preset)
? "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" : "config.proxy-method.v1"
+ : "proxy-backend".equals(preset) && "HTTP".equals(options.get("method"))
+ ? "config.proxy-method.v2"
: "config.quick-setup.v1";
default -> throw new IllegalStateException("unsupported configuration domain");
};
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index a06a0fe..274432e 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -2837,9 +2837,10 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 |
fileTargetsForSelection().length > 0 && !busy;
const fileDraftReady = fileReady && fileDraftMatchesCurrentContext();
const syncSelected = quickPreset.value === 'sync-vote-sites';
+ const quickCapability = quickSetupCapability();
const quickReady = authenticated && !busy && (syncSelected
? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0)
- : primaryCapabilities.includes('config.quick-setup.v1') && targets('config.quick-setup.v1').length > 0);
+ : primaryCapabilities.includes(quickCapability) && quickSetupTargets().length > 0);
readConfiguration.disabled = !routingReadReady;
previewConfiguration.disabled = !routingDraftReady;
applyConfiguration.disabled = !routingDraftReady || !approvedPreview;
@@ -2867,6 +2868,16 @@ function backendQuickTargets() {
return targets('config.quick-setup.v1').filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId)));
}
+function quickSetupCapability() {
+ return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP'
+ ? 'config.proxy-method.v2' : 'config.quick-setup.v1';
+}
+
+function quickSetupTargets() {
+ return targets(quickSetupCapability())
+ .filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId)));
+}
+
function clearApprovals() {
approvedPreview = null;
approvedFilePreview = null;
@@ -4036,14 +4047,15 @@ previewQuickSetup.addEventListener('click', async () => {
}
return;
}
+ const nodeIds = quickSetupTargets();
const operation = await startConfigurationOperation('/api/v1/configuration/preview', {
- nodeIds: targets('config.quick-setup.v1'),
+ nodeIds,
configuration: {domain: 'quick-setup', preset: quickPreset.value, options: quickOptions()}
}, quickOperationStatus);
text(quickOperationStatus, operationSummary(operation));
if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) {
approvedQuickPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken,
- nodeIds: targets('config.quick-setup.v1')};
+ nodeIds};
updateConfigurationButtons();
} else if (previewGeneration !== inputGeneration) {
text(quickOperationStatus, 'The targets or setup changed while previewing. Preview again before apply.');
@@ -4067,7 +4079,7 @@ applyQuickSetup.addEventListener('click', async () => {
const operation = await startConfigurationOperation('/api/v1/configuration/apply', {
previewOperationId: approval.operationId, approvalToken: approval.approvalToken
}, quickOperationStatus);
- const currentNodeIds = sync ? selectedVoteSitesTargets() : targets('config.quick-setup.v1');
+ const currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets();
const currentQuickSetup = JSON.stringify({preset: quickPreset.value, options: quickOptions(),
nodeIds: currentNodeIds, sourceId: sync ? voteSitesSourceId : ''});
text(quickOperationStatus, operation.state === 'SUCCEEDED' && submittedQuickSetup !== currentQuickSetup
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 722a44b..ca24955 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -72,11 +72,15 @@ class ArtifactStoreTest {
byte[] missing = jar(null, "plugin/Main.class", new byte[] {1});
byte[] wrong = jar("name: AnotherPlugin\n", "plugin/Main.class", new byte[] {1});
byte[] ambiguous = jar("name: VotingPlugin\nname: AnotherPlugin\n", "plugin/Main.class", new byte[] {1});
+ byte[] quotedDuplicate = jar("name: VotingPlugin\n\"name\": AnotherPlugin\n",
+ "plugin/Main.class", new byte[] {1});
byte[] bomb = jar("name: VotingPlugin\n", "data.bin", new byte[1_000_000]);
assertRejected(() -> store.upload(new ByteArrayInputStream(missing), "VotingPlugin.jar", sha256(missing)));
assertRejected(() -> store.upload(new ByteArrayInputStream(wrong), "VotingPlugin.jar", sha256(wrong)));
assertRejected(() -> store.upload(new ByteArrayInputStream(ambiguous), "VotingPlugin.jar", sha256(ambiguous)));
+ assertRejected(() -> store.upload(new ByteArrayInputStream(quotedDuplicate),
+ "VotingPlugin.jar", sha256(quotedDuplicate)));
assertRejected(() -> store.upload(new ByteArrayInputStream(bomb), "VotingPlugin.jar", sha256(bomb)));
}
@@ -148,6 +152,23 @@ class ArtifactStoreTest {
assertRejected(() -> store.open(secondId));
}
+ @Test void infeasibleCapacityDoesNotEvictAnUnprotectedArtifact() throws Exception {
+ byte[] first = jar("name: VotingPlugin\n", "plugin/One.class", new byte[] {1});
+ byte[] second = jar("name: VotingPlugin\n", "plugin/Two.class", new byte[] {2});
+ byte[] third = jar("name: VotingPlugin\n", "plugin/Three.class",
+ "larger incoming artifact payload".repeat(20).getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ assertTrue(third.length > first.length);
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"), first.length + second.length, 3);
+ String firstId = store.upload(new ByteArrayInputStream(first), "first.jar", sha256(first)).artifactId();
+ String secondId = store.upload(new ByteArrayInputStream(second), "second.jar", sha256(second)).artifactId();
+
+ assertRejected(() -> store.upload(new ByteArrayInputStream(third), "third.jar", sha256(third),
+ Set.of(secondId)));
+
+ assertArrayEquals(first, store.open(firstId).readAllBytes());
+ assertArrayEquals(second, store.open(secondId).readAllBytes());
+ }
+
private static byte[] jar(String pluginYml, String entryName, byte[] content) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output)) {
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index 8d63481..a94dd87 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -880,6 +880,15 @@ class ConfigurationOperationsTest {
null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "HTTP"));
http.validateProposal();
assertEquals(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, http.capability());
+
+ ManagedConfiguration backendHttp = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null,
+ List.of(), null, null, "proxy-backend", Map.of("server", "lobby", "method", "HTTP"));
+ assertEquals(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, backendHttp.capability());
+ assertThrows(ValidationException.class,
+ () -> operations.createPreview(List.of("proxy-a"), backendHttp));
+ ManagedConfiguration lowercaseHttp = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null,
+ List.of(), null, null, "proxy-backend", Map.of("server", "lobby", "method", "http"));
+ assertThrows(IllegalArgumentException.class, lowercaseHttp::validateProposal);
}
@Test void proxyMethodApplyKeepsEvictedBackendsAsFailedDependencies() throws Exception {
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 9b58334..b7faee0 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -130,6 +130,9 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)"));
assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'"));
assertTrue(script.body().contains("config.proxy-method.v2"));
+ assertTrue(script.body().contains("function quickSetupTargets()"));
+ assertTrue(script.body().contains("nodeIds = quickSetupTargets()"));
+ assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()"));
assertTrue(web.body().contains("Add a simple vote reward"));
assertTrue(web.body().contains("First-run setup"));
assertTrue(web.body().contains("Node enrollment"));
From 5aed21c8ed8a37bcc2ecb6add89f3e1d65ecb7ac Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 12:14:40 -0600
Subject: [PATCH 03/30] Make artifact publication recoverable
---
.../control/artifact/ArtifactStore.java | 104 ++++++++++++++++--
src/main/resources/web/app.js | 6 +-
.../control/artifact/ArtifactStoreTest.java | 32 ++++++
.../control/http/ControlHttpServerTest.java | 1 +
4 files changed, 133 insertions(+), 10 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 07460da..520e7b8 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -53,6 +53,7 @@ public final class ArtifactStore {
private final Path directory;
private final long maximumStoredBytes;
private final int maximumStoredArtifacts;
+ private final IoAction afterPublishMove;
/** Creates or opens an empty private directory owned by Control. */
public ArtifactStore(Path directory) throws IOException {
@@ -60,11 +61,17 @@ public ArtifactStore(Path directory) throws IOException {
}
ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts) throws IOException {
+ this(directory, maximumStoredBytes, maximumStoredArtifacts, path -> { });
+ }
+
+ ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts,
+ IoAction afterPublishMove) throws IOException {
if (directory == null) throw rejected();
- if (maximumStoredBytes < 1 || maximumStoredArtifacts < 1) throw rejected();
+ if (maximumStoredBytes < 1 || maximumStoredArtifacts < 1 || afterPublishMove == null) throw rejected();
this.directory = directory.toAbsolutePath().normalize();
this.maximumStoredBytes = maximumStoredBytes;
this.maximumStoredArtifacts = maximumStoredArtifacts;
+ this.afterPublishMove = afterPublishMove;
try {
createPrivateDirectory(this.directory);
removeIncompleteUploads();
@@ -79,6 +86,21 @@ private void removeIncompleteUploads() throws IOException {
try (var files = Files.list(directory)) {
for (Path file : files.toList()) {
String name = file.getFileName().toString();
+ if (name.matches("evict-[0-9a-f]{64}\\.part")) {
+ if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
+ throw rejected();
+ }
+ String artifactId = name.substring("evict-".length(), name.length() - ".part".length());
+ verifyExistingArtifact(file, artifactId);
+ Path original = artifactPath(artifactId);
+ if (Files.exists(original, LinkOption.NOFOLLOW_LINKS)) {
+ verifyExistingArtifact(original, artifactId);
+ Files.delete(file);
+ } else {
+ move(file, original, false);
+ }
+ continue;
+ }
if (!name.startsWith("upload-") || !name.endsWith(".part")) continue;
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
throw rejected();
@@ -123,8 +145,8 @@ public synchronized Artifact upload(InputStream source, String displayFilename,
verifyExistingArtifact(artifact, actual);
return new Artifact(actual, displayFilename, digest.size());
}
- ensureCapacity(digest.size(), protectedArtifactIds);
- publish(temporary, artifact);
+ List evictionPlan = planCapacity(digest.size(), protectedArtifactIds);
+ publishWithRollback(temporary, artifact, evictionPlan);
published = true;
return new Artifact(actual, displayFilename, digest.size());
} catch (ArtifactException failure) {
@@ -136,7 +158,7 @@ public synchronized Artifact upload(InputStream source, String displayFilename,
}
}
- private void ensureCapacity(long incomingBytes, Set protectedArtifactIds) throws IOException {
+ private List planCapacity(long incomingBytes, Set protectedArtifactIds) throws IOException {
List stored = new ArrayList<>();
long bytes = 0;
try (var files = Files.list(directory)) {
@@ -162,8 +184,61 @@ private void ensureCapacity(long incomingBytes, Set protectedArtifactIds
count--;
}
if (count >= maximumStoredArtifacts || bytes > maximumStoredBytes - incomingBytes) throw rejected();
- for (StoredFile candidate : evictionPlan) Files.delete(candidate.path());
- if (!evictionPlan.isEmpty()) DurableFiles.forceDirectory(directory);
+ return List.copyOf(evictionPlan);
+ }
+
+ private void publishWithRollback(Path temporary, Path artifact, List evictionPlan)
+ throws IOException {
+ List quarantined = new ArrayList<>();
+ try {
+ for (StoredFile candidate : evictionPlan) {
+ Path backup = directory.resolve("evict-" + candidate.artifactId() + ".part");
+ if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS)) throw rejected();
+ move(candidate.path(), backup, false);
+ quarantined.add(new QuarantinedFile(candidate.path(), backup));
+ }
+ if (!quarantined.isEmpty()) DurableFiles.forceDirectory(directory);
+ publish(temporary, artifact);
+ } catch (IOException | RuntimeException failure) {
+ IOException rollbackFailure = rollbackPublication(artifact, quarantined);
+ if (rollbackFailure != null) failure.addSuppressed(rollbackFailure);
+ throw failure;
+ }
+ for (QuarantinedFile file : quarantined) deleteTemporary(file.backup());
+ if (!quarantined.isEmpty()) {
+ try { DurableFiles.forceDirectory(directory); }
+ catch (IOException ignored) { /* The canonical artifact is already durable; retry cleanup on startup. */ }
+ }
+ }
+
+ private IOException rollbackPublication(Path artifact, List quarantined) {
+ IOException failure = null;
+ try {
+ if (Files.exists(artifact, LinkOption.NOFOLLOW_LINKS)) {
+ if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)) {
+ throw rejected();
+ }
+ Files.delete(artifact);
+ }
+ } catch (IOException problem) {
+ failure = problem;
+ }
+ for (int index = quarantined.size() - 1; index >= 0; index--) {
+ QuarantinedFile file = quarantined.get(index);
+ try {
+ if (Files.exists(file.backup(), LinkOption.NOFOLLOW_LINKS)
+ && !Files.exists(file.original(), LinkOption.NOFOLLOW_LINKS)) {
+ move(file.backup(), file.original(), false);
+ }
+ } catch (IOException problem) {
+ if (failure == null) failure = problem; else failure.addSuppressed(problem);
+ }
+ }
+ try { DurableFiles.forceDirectory(directory); }
+ catch (IOException problem) {
+ if (failure == null) failure = problem; else failure.addSuppressed(problem);
+ }
+ return failure;
}
/** Opens a verified immutable artifact by its opaque identifier. */
@@ -300,13 +375,12 @@ private static boolean declaresVotingPlugin(byte[] pluginYml) throws IOException
private void publish(Path temporary, Path artifact) throws IOException {
try {
- Files.move(temporary, artifact, StandardCopyOption.ATOMIC_MOVE);
- } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) {
- Files.move(temporary, artifact);
+ move(temporary, artifact, false);
} catch (java.nio.file.FileAlreadyExistsException collision) {
verifyExistingArtifact(artifact, artifact.getFileName().toString().substring(0, 64));
return;
}
+ afterPublishMove.run(artifact);
setPermissions(artifact, FILE_PERMISSIONS);
try (FileChannel channel = FileChannel.open(artifact, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
channel.force(true);
@@ -314,6 +388,16 @@ private void publish(Path temporary, Path artifact) throws IOException {
DurableFiles.forceDirectory(directory);
}
+ private static void move(Path source, Path target, boolean replace) throws IOException {
+ try {
+ if (replace) Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ else Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
+ } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) {
+ if (replace) Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
+ else Files.move(source, target);
+ }
+ }
+
private void verifyExistingArtifact(Path artifact, String expectedHash) throws IOException {
if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)
|| Files.size(artifact) > MAX_UPLOAD_BYTES || !hash(artifact).equals(expectedHash)) throw rejected();
@@ -412,4 +496,6 @@ public static final class ArtifactException extends IOException {
private record DigestAndSize(String sha256, long size) { }
private record StoredFile(Path path, String artifactId, long size, long modified) { }
+ private record QuarantinedFile(Path original, Path backup) { }
+ @FunctionalInterface interface IoAction { void run(Path path) throws IOException; }
}
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index 274432e..48bec09 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -3982,7 +3982,11 @@ async function loadQuickSetupValues(automatic = false) {
if (generation !== inputGeneration || preset !== quickPreset.value || nodeId !== selectedServerId
|| sessionId !== nodeIndex.get(nodeId)?.sessionId
|| selector !== JSON.stringify(quickReadOptions())) {
- text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.');
+ if (!quickSetupValuesLoaded()) {
+ text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.');
+ readQuickSetup.hidden = false;
+ updateConfigurationButtons();
+ }
return;
}
const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index ca24955..85ad26b 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -17,6 +17,7 @@
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
@@ -169,6 +170,37 @@ class ArtifactStoreTest {
assertArrayEquals(second, store.open(secondId).readAllBytes());
}
+ @Test void publicationFailureRestoresEvictionsAndRemovesTheRejectedArtifact() throws Exception {
+ AtomicBoolean failAfterMove = new AtomicBoolean();
+ byte[] first = jar("name: VotingPlugin\n", "plugin/One.class", new byte[] {1});
+ byte[] second = jar("name: VotingPlugin\n", "plugin/Two.class", new byte[] {2});
+ ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"), 1_000_000, 1,
+ path -> { if (failAfterMove.get()) throw new IOException("simulated post-move failure"); });
+ String firstId = store.upload(new ByteArrayInputStream(first), "first.jar", sha256(first)).artifactId();
+
+ failAfterMove.set(true);
+ String secondId = sha256(second);
+ assertRejected(() -> store.upload(new ByteArrayInputStream(second), "second.jar", secondId));
+
+ assertArrayEquals(first, store.open(firstId).readAllBytes());
+ assertRejected(() -> store.open(secondId));
+ }
+
+ @Test void startupRestoresAnInterruptedEvictionQuarantine() throws Exception {
+ Path artifacts = directory.resolve("artifacts");
+ byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
+ ArtifactStore store = new ArtifactStore(artifacts);
+ String artifactId = store.upload(new ByteArrayInputStream(jar), "VotingPlugin.jar", sha256(jar)).artifactId();
+ Path canonical = artifacts.resolve(artifactId + ".jar");
+ Path quarantine = artifacts.resolve("evict-" + artifactId + ".part");
+ Files.move(canonical, quarantine);
+
+ ArtifactStore recovered = new ArtifactStore(artifacts);
+
+ assertArrayEquals(jar, recovered.open(artifactId).readAllBytes());
+ assertFalse(Files.exists(quarantine));
+ }
+
private static byte[] jar(String pluginYml, String entryName, byte[] content) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output)) {
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index b7faee0..f0705cc 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -127,6 +127,7 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("configurationContent.setAttribute('aria-busy', 'true')"));
assertTrue(script.body().contains("readFileConfiguration.hidden = false;"));
assertTrue(script.body().contains("readQuickSetup.hidden = false;"));
+ assertTrue(script.body().contains("if (!quickSetupValuesLoaded()) {\n text(quickOperationStatus, 'The server or setup changed while reading."));
assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)"));
assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'"));
assertTrue(script.body().contains("config.proxy-method.v2"));
From 00014390d4c8366d7c73fec6b65f29a7e14bb01e Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 12:35:51 -0600
Subject: [PATCH 04/30] Make artifact eviction recovery transactional
---
.../control/artifact/ArtifactStore.java | 120 +++++++++++++-----
.../domain/ConfigurationOperations.java | 12 +-
src/main/resources/web/app.js | 17 ++-
.../control/artifact/ArtifactStoreTest.java | 52 +++++++-
.../domain/ConfigurationOperationsTest.java | 7 +
.../control/http/ControlHttpServerTest.java | 4 +
6 files changed, 176 insertions(+), 36 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 520e7b8..099c601 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -24,6 +24,7 @@
import java.util.List;
import java.util.Locale;
import java.util.Set;
+import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.yaml.snakeyaml.LoaderOptions;
@@ -83,24 +84,11 @@ public ArtifactStore(Path directory) throws IOException {
}
private void removeIncompleteUploads() throws IOException {
+ recoverEvictionTransactions();
try (var files = Files.list(directory)) {
for (Path file : files.toList()) {
String name = file.getFileName().toString();
- if (name.matches("evict-[0-9a-f]{64}\\.part")) {
- if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
- throw rejected();
- }
- String artifactId = name.substring("evict-".length(), name.length() - ".part".length());
- verifyExistingArtifact(file, artifactId);
- Path original = artifactPath(artifactId);
- if (Files.exists(original, LinkOption.NOFOLLOW_LINKS)) {
- verifyExistingArtifact(original, artifactId);
- Files.delete(file);
- } else {
- move(file, original, false);
- }
- continue;
- }
+ if (name.startsWith("evict-")) throw rejected();
if (!name.startsWith("upload-") || !name.endsWith(".part")) continue;
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
throw rejected();
@@ -111,6 +99,43 @@ private void removeIncompleteUploads() throws IOException {
DurableFiles.forceDirectory(directory);
}
+ private void recoverEvictionTransactions() throws IOException {
+ List files;
+ try (var entries = Files.list(directory)) { files = entries.toList(); }
+ for (Path marker : files) {
+ String name = marker.getFileName().toString();
+ if (!name.matches("evict-[0-9a-f]{32}-[0-9a-f]{64}\\.(?:pending|committed)")) continue;
+ if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker)
+ || Files.size(marker) != 0) throw rejected();
+ String transaction = name.substring("evict-".length(), "evict-".length() + 32);
+ String incomingId = name.substring("evict-".length() + 33, "evict-".length() + 33 + 64);
+ boolean committed = name.endsWith(".committed");
+ List quarantined = new ArrayList<>();
+ for (Path candidate : files) {
+ String candidateName = candidate.getFileName().toString();
+ if (!candidateName.matches("evict-" + transaction + "-[0-9a-f]{64}\\.part")) continue;
+ String artifactId = candidateName.substring("evict-".length() + 33,
+ "evict-".length() + 33 + 64);
+ verifyExistingArtifact(candidate, artifactId);
+ quarantined.add(new QuarantinedFile(artifactPath(artifactId), candidate));
+ }
+ Path incoming = artifactPath(incomingId);
+ if (committed) {
+ verifyExistingArtifact(incoming, incomingId);
+ for (QuarantinedFile file : quarantined) Files.delete(file.backup());
+ } else {
+ if (Files.exists(incoming, LinkOption.NOFOLLOW_LINKS)) {
+ verifyExistingArtifact(incoming, incomingId);
+ Files.delete(incoming);
+ }
+ restoreQuarantined(quarantined);
+ }
+ DurableFiles.forceDirectory(directory);
+ Files.delete(marker);
+ DurableFiles.forceDirectory(directory);
+ }
+ }
+
/**
* Streams, verifies and publishes one JAR. When supplied, the claimed digest must
* match; otherwise the store establishes it. The returned identifier is the
@@ -133,6 +158,7 @@ public synchronized Artifact upload(InputStream source, String displayFilename,
boolean published = false;
try {
verifyDirectory();
+ recoverEvictionTransactions();
temporary = Files.createTempFile(directory, "upload-", ".part");
setPermissions(temporary, FILE_PERMISSIONS);
DigestAndSize digest = copyBounded(source, temporary);
@@ -190,28 +216,48 @@ private List planCapacity(long incomingBytes, Set protectedA
private void publishWithRollback(Path temporary, Path artifact, List evictionPlan)
throws IOException {
List quarantined = new ArrayList<>();
+ String transaction = UUID.randomUUID().toString().replace("-", "");
+ String incomingId = artifact.getFileName().toString().substring(0, 64);
+ Path pending = directory.resolve("evict-" + transaction + "-" + incomingId + ".pending");
+ Path committed = directory.resolve("evict-" + transaction + "-" + incomingId + ".committed");
try {
+ createTransactionMarker(pending);
for (StoredFile candidate : evictionPlan) {
- Path backup = directory.resolve("evict-" + candidate.artifactId() + ".part");
+ Path backup = directory.resolve("evict-" + transaction + "-" + candidate.artifactId() + ".part");
if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS)) throw rejected();
move(candidate.path(), backup, false);
quarantined.add(new QuarantinedFile(candidate.path(), backup));
}
if (!quarantined.isEmpty()) DurableFiles.forceDirectory(directory);
publish(temporary, artifact);
+ move(pending, committed, false);
+ DurableFiles.forceDirectory(directory);
} catch (IOException | RuntimeException failure) {
- IOException rollbackFailure = rollbackPublication(artifact, quarantined);
+ IOException rollbackFailure = rollbackPublication(artifact, quarantined, pending, committed);
if (rollbackFailure != null) failure.addSuppressed(rollbackFailure);
throw failure;
}
- for (QuarantinedFile file : quarantined) deleteTemporary(file.backup());
- if (!quarantined.isEmpty()) {
- try { DurableFiles.forceDirectory(directory); }
- catch (IOException ignored) { /* The canonical artifact is already durable; retry cleanup on startup. */ }
+ try {
+ for (QuarantinedFile file : quarantined) Files.delete(file.backup());
+ DurableFiles.forceDirectory(directory);
+ Files.delete(committed);
+ DurableFiles.forceDirectory(directory);
+ } catch (IOException ignored) {
+ /* The committed marker makes remaining cleanup deterministic on startup. */
+ }
+ }
+
+ private void createTransactionMarker(Path marker) throws IOException {
+ try (FileChannel channel = FileChannel.open(marker, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE,
+ LinkOption.NOFOLLOW_LINKS)) {
+ setPermissions(marker, FILE_PERMISSIONS);
+ channel.force(true);
}
+ DurableFiles.forceDirectory(directory);
}
- private IOException rollbackPublication(Path artifact, List quarantined) {
+ private IOException rollbackPublication(Path artifact, List quarantined,
+ Path pending, Path committed) {
IOException failure = null;
try {
if (Files.exists(artifact, LinkOption.NOFOLLOW_LINKS)) {
@@ -223,16 +269,13 @@ private IOException rollbackPublication(Path artifact, List qua
} catch (IOException problem) {
failure = problem;
}
- for (int index = quarantined.size() - 1; index >= 0; index--) {
- QuarantinedFile file = quarantined.get(index);
- try {
- if (Files.exists(file.backup(), LinkOption.NOFOLLOW_LINKS)
- && !Files.exists(file.original(), LinkOption.NOFOLLOW_LINKS)) {
- move(file.backup(), file.original(), false);
- }
- } catch (IOException problem) {
- if (failure == null) failure = problem; else failure.addSuppressed(problem);
- }
+ try { restoreQuarantined(quarantined); }
+ catch (IOException problem) {
+ if (failure == null) failure = problem; else failure.addSuppressed(problem);
+ }
+ for (Path marker : List.of(pending, committed)) try { Files.deleteIfExists(marker); }
+ catch (IOException problem) {
+ if (failure == null) failure = problem; else failure.addSuppressed(problem);
}
try { DurableFiles.forceDirectory(directory); }
catch (IOException problem) {
@@ -241,6 +284,19 @@ private IOException rollbackPublication(Path artifact, List qua
return failure;
}
+ private void restoreQuarantined(List quarantined) throws IOException {
+ for (int index = quarantined.size() - 1; index >= 0; index--) {
+ QuarantinedFile file = quarantined.get(index);
+ if (!Files.exists(file.backup(), LinkOption.NOFOLLOW_LINKS)) continue;
+ if (Files.exists(file.original(), LinkOption.NOFOLLOW_LINKS)) {
+ verifyExistingArtifact(file.original(), file.original().getFileName().toString().substring(0, 64));
+ Files.delete(file.backup());
+ } else {
+ move(file.backup(), file.original(), false);
+ }
+ }
+ }
+
/** Opens a verified immutable artifact by its opaque identifier. */
public InputStream open(String artifactId) throws IOException {
if (!isSha256(artifactId)) throw rejected();
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index 85f8e2b..e9372ef 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -261,13 +261,23 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) {
else operation.claimSessions.put(nodeId, previousClaimSession);
throw e;
}
- return new ConfigurationTask(operation.id, operation.type, operation.configuration,
+ return new ConfigurationTask(operation.id, operation.type, configurationForTask(operation),
operation.expectedRevisions.get(nodeId), attemptId);
}
}
return null;
}
+ private static ManagedConfiguration configurationForTask(StoredOperation operation) {
+ ManagedConfiguration configuration = operation.configuration;
+ if ("READ".equals(operation.type) && ManagedConfiguration.QUICK_SETUP.equals(configuration.domain())
+ && "proxy-backend".equals(configuration.preset()) && configuration.options().containsKey("method")) {
+ return new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), null, null,
+ configuration.preset(), Map.of());
+ }
+ return configuration;
+ }
+
private boolean deferProxyMethodApply(StoredOperation operation, NodeStatus node) {
if (!"APPLY".equals(operation.type)
|| !ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain())
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index 48bec09..64fb29f 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -1619,7 +1619,11 @@ async function autoLoadTab(tab) {
} finally { finishAutoLoad(tab); }
return;
}
- if (tab === 'quick-setup' && !approvedQuickPreview && !configurationOperationsInFlight
+ if (tab === 'quick-setup' && configurationOperationsInFlight) {
+ autoLoadPending.add(tab);
+ return;
+ }
+ if (tab === 'quick-setup' && !approvedQuickPreview
&& (autoSitesState.textContent === 'Not loaded' || voteLoggingState.textContent === 'Not loaded'
|| quickPresetReadable() && !loadedQuickSetup)) {
autoLoadInFlight.add(tab);
@@ -3279,6 +3283,11 @@ async function startConfigurationOperation(path, body, statusElement = operation
configurationOperationsInFlight--;
updateConfigurationButtons();
updateExtendedButtons();
+ if (configurationOperationsInFlight === 0 && autoLoadPending.has('quick-setup')
+ && !autoLoadInFlight.has('quick-setup')) {
+ autoLoadPending.delete('quick-setup');
+ void autoLoadTab('quick-setup');
+ }
}
}
@@ -3924,6 +3933,10 @@ function quickReadOptions() {
return quickPreset.value === 'vote-site' ? {name: quickName.value.trim()} : {};
}
+function quickReadConfigurationOptions() {
+ return quickPreset.value === 'proxy-backend' ? {method: quickMethod.value} : quickReadOptions();
+}
+
function populateQuickState(options) {
if (quickPreset.value === 'proxy-backend') {
quickName.value = options.server || '';
@@ -3974,7 +3987,7 @@ async function loadQuickSetupValues(automatic = false) {
try {
const operation = await startConfigurationOperation('/api/v1/configuration/read', {
nodeIds: [selectedServerId],
- configuration: {domain: 'quick-setup', preset, options: quickReadOptions()}
+ configuration: {domain: 'quick-setup', preset, options: quickReadConfigurationOptions()}
}, quickOperationStatus);
const result = Object.values(operation.results).find(item =>
item.success && item.configuration?.preset === preset && item.configuration?.options);
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 85ad26b..7ac491b 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -189,18 +189,68 @@ class ArtifactStoreTest {
@Test void startupRestoresAnInterruptedEvictionQuarantine() throws Exception {
Path artifacts = directory.resolve("artifacts");
byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/New.class", new byte[] {2});
ArtifactStore store = new ArtifactStore(artifacts);
String artifactId = store.upload(new ByteArrayInputStream(jar), "VotingPlugin.jar", sha256(jar)).artifactId();
+ String incomingId = sha256(incoming);
+ String transaction = "1".repeat(32);
Path canonical = artifacts.resolve(artifactId + ".jar");
- Path quarantine = artifacts.resolve("evict-" + artifactId + ".part");
+ Path quarantine = artifacts.resolve("evict-" + transaction + "-" + artifactId + ".part");
Files.move(canonical, quarantine);
+ Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
+ Files.createFile(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending"));
ArtifactStore recovered = new ArtifactStore(artifacts);
assertArrayEquals(jar, recovered.open(artifactId).readAllBytes());
+ assertRejected(() -> recovered.open(incomingId));
assertFalse(Files.exists(quarantine));
}
+ @Test void startupFinishesACommittedEvictionWithoutRestoringOldArtifacts() throws Exception {
+ Path artifacts = directory.resolve("committed-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/New.class", new byte[] {2});
+ ArtifactStore store = new ArtifactStore(artifacts);
+ String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
+ String incomingId = sha256(incoming);
+ String transaction = "2".repeat(32);
+ Path quarantine = artifacts.resolve("evict-" + transaction + "-" + oldId + ".part");
+ Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
+ Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
+ Files.createFile(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".committed"));
+
+ ArtifactStore recovered = new ArtifactStore(artifacts);
+
+ assertArrayEquals(incoming, recovered.open(incomingId).readAllBytes());
+ assertRejected(() -> recovered.open(oldId));
+ assertFalse(Files.exists(quarantine));
+ }
+
+ @Test void nextUploadFinishesCommittedEvictionBeforePlanningCapacity() throws Exception {
+ Path artifacts = directory.resolve("active-committed-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {2});
+ byte[] next = jar("name: VotingPlugin\n", "plugin/Next.class", new byte[] {3});
+ ArtifactStore store = new ArtifactStore(artifacts, 1_000_000, 1);
+ String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
+ String incomingId = sha256(incoming);
+ String transaction = "3".repeat(32);
+ Path quarantine = artifacts.resolve("evict-" + transaction + "-" + oldId + ".part");
+ Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
+ Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
+ Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".committed");
+ Files.createFile(marker);
+
+ String nextId = store.upload(new ByteArrayInputStream(next), "next.jar", sha256(next)).artifactId();
+ ArtifactStore recovered = new ArtifactStore(artifacts, 1_000_000, 1);
+
+ assertArrayEquals(next, recovered.open(nextId).readAllBytes());
+ assertRejected(() -> recovered.open(incomingId));
+ assertFalse(Files.exists(quarantine));
+ assertFalse(Files.exists(marker));
+ }
+
private static byte[] jar(String pluginYml, String entryName, byte[] content) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output)) {
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index a94dd87..e2accf3 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -886,6 +886,13 @@ class ConfigurationOperationsTest {
assertEquals(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, backendHttp.capability());
assertThrows(ValidationException.class,
() -> operations.createPreview(List.of("proxy-a"), backendHttp));
+ UUID httpBackendSession = UUID.randomUUID();
+ registry.register(new NodeRegistration("http-backend", httpBackendSession, "HTTP Backend", "BUKKIT",
+ "test", 1, Set.of(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY), Set.of()));
+ operations.createRead(List.of("http-backend"), backendHttp);
+ ConfigurationTask httpRead = operations.claim("http-backend", httpBackendSession);
+ assertEquals("proxy-backend", httpRead.configuration().preset());
+ assertEquals(Map.of(), httpRead.configuration().options());
ManagedConfiguration lowercaseHttp = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null,
List.of(), null, null, "proxy-backend", Map.of("server", "lobby", "method", "http"));
assertThrows(IllegalArgumentException.class, lowercaseHttp::validateProposal);
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index f0705cc..f55d4d8 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -134,6 +134,10 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("function quickSetupTargets()"));
assertTrue(script.body().contains("nodeIds = quickSetupTargets()"));
assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()"));
+ assertTrue(script.body().contains("autoLoadPending.add(tab);"));
+ assertTrue(script.body().contains("configurationOperationsInFlight === 0 && autoLoadPending.has('quick-setup')"));
+ assertTrue(script.body().contains("function quickReadConfigurationOptions()"));
+ assertTrue(script.body().contains("options: quickReadConfigurationOptions()"));
assertTrue(web.body().contains("Add a simple vote reward"));
assertTrue(web.body().contains("First-run setup"));
assertTrue(web.body().contains("Node enrollment"));
From c06cf7ef063dbf8805c1e0d6a8379a3171b4f93c Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 13:06:03 -0600
Subject: [PATCH 05/30] Complete artifact rollback recovery safeguards
---
.../control/artifact/ArtifactStore.java | 19 ++++--
.../domain/ConfigurationOperations.java | 14 ++++-
src/main/resources/web/app.js | 63 ++++++++++++++-----
.../control/artifact/ArtifactStoreTest.java | 22 +++++++
.../domain/ConfigurationOperationsTest.java | 22 +++++++
.../control/http/ControlHttpServerTest.java | 17 ++++-
6 files changed, 135 insertions(+), 22 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 099c601..76009b2 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -259,6 +259,14 @@ private void createTransactionMarker(Path marker) throws IOException {
private IOException rollbackPublication(Path artifact, List quarantined,
Path pending, Path committed) {
IOException failure = null;
+ if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
+ try {
+ move(committed, pending, false);
+ DurableFiles.forceDirectory(directory);
+ } catch (IOException problem) {
+ return problem;
+ }
+ }
try {
if (Files.exists(artifact, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)) {
@@ -273,14 +281,17 @@ private IOException rollbackPublication(Path artifact, List qua
catch (IOException problem) {
if (failure == null) failure = problem; else failure.addSuppressed(problem);
}
- for (Path marker : List.of(pending, committed)) try { Files.deleteIfExists(marker); }
- catch (IOException problem) {
- if (failure == null) failure = problem; else failure.addSuppressed(problem);
- }
try { DurableFiles.forceDirectory(directory); }
catch (IOException problem) {
if (failure == null) failure = problem; else failure.addSuppressed(problem);
}
+ if (failure != null) return failure;
+ try {
+ Files.deleteIfExists(pending);
+ DurableFiles.forceDirectory(directory);
+ } catch (IOException problem) {
+ failure = problem;
+ }
return failure;
}
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index e9372ef..ee96058 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -240,6 +240,7 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) {
if ("QUEUED".equals(state) || ("IN_PROGRESS".equals(state) && leased != null
&& !now.isBefore(leased.plus(LEASE)))) {
if (cancelChangedFileRole(operation, node)) continue;
+ if (cancelChangedBackendSetupRole(operation, node)) continue;
if (cancelChangedProxyMethodRole(operation, node)) continue;
if (deferProxyMethodApply(operation, node)) continue;
if (cancelLostCapability(operation, node)) continue;
@@ -377,6 +378,16 @@ private boolean cancelChangedFileRole(StoredOperation operation, NodeStatus node
return true;
}
+ private boolean cancelChangedBackendSetupRole(StoredOperation operation, NodeStatus node) {
+ if (!ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain())
+ || !"proxy-backend".equals(operation.configuration.preset())) return false;
+ String expectedPlatform = operation.targetPlatforms.get(node.nodeId());
+ if ("BUKKIT".equalsIgnoreCase(expectedPlatform) && "BUKKIT".equalsIgnoreCase(node.platform())) return false;
+ automaticCancellation(operation, node.nodeId(), sessionId(node), "TARGET_CHANGED",
+ "Node platform changed after the task was created; create it again", "TARGET_ROLE_CHANGED");
+ return true;
+ }
+
private boolean cancelLostCapability(StoredOperation operation, NodeStatus node) {
if (node.online() && node.acceptedCapabilities().contains(operation.configuration.capability())) return false;
automaticCancellation(operation, node.nodeId(), sessionId(node), "CAPABILITY_LOST",
@@ -556,7 +567,8 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co
if (!Objects.equals(operation.claimSessions.get(nodeId), result.sessionId())) {
throw new ValidationException("SESSION_MISMATCH", "Operation task belongs to another node session", List.of());
}
- if (cancelChangedFileRole(operation, node) || cancelChangedProxyMethodRole(operation, node)
+ if (cancelChangedFileRole(operation, node) || cancelChangedBackendSetupRole(operation, node)
+ || cancelChangedProxyMethodRole(operation, node)
|| cancelLostCapability(operation, node)) {
return view(operation);
}
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index 64fb29f..939f93c 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -1625,12 +1625,12 @@ async function autoLoadTab(tab) {
}
if (tab === 'quick-setup' && !approvedQuickPreview
&& (autoSitesState.textContent === 'Not loaded' || voteLoggingState.textContent === 'Not loaded'
- || quickPresetReadable() && !loadedQuickSetup)) {
+ || quickPresetReadable() && !quickSetupValuesLoaded())) {
autoLoadInFlight.add(tab);
try {
if (autoSitesState.textContent === 'Not loaded') await loadDedicatedSetup('auto-create-vote-sites', true);
if (voteLoggingState.textContent === 'Not loaded') await loadDedicatedSetup('vote-logging', true);
- if (quickPresetReadable() && !loadedQuickSetup) await loadQuickSetupValues(true);
+ if (quickPresetReadable() && !quickSetupValuesLoaded()) await loadQuickSetupValues(true);
} finally { finishAutoLoad(tab); }
return;
}
@@ -2545,13 +2545,24 @@ function renderTransportTest() {
function proxyMethodCandidates() {
return allNodeItems.filter(node => isProxy(node) && node.online &&
- node.acceptedCapabilities.includes('config.proxy-method.v1'));
+ (node.acceptedCapabilities.includes('config.proxy-method.v1')
+ || node.acceptedCapabilities.includes('config.proxy-method.v2')));
}
function proxyMethodCapabilityFor(method) {
return method === 'HTTP' ? 'config.proxy-method.v2' : 'config.proxy-method.v1';
}
+function proxyMethodReadCapability() {
+ const capabilities = nodeCapabilities.get(proxyMethodProxyId) || [];
+ return capabilities.includes('config.proxy-method.v1')
+ ? 'config.proxy-method.v1' : 'config.proxy-method.v2';
+}
+
+function proxyMethodReadNetwork() {
+ return proxyMethodNetwork(proxyMethodReadCapability());
+}
+
function proxyMethodNetworkFor(items, truncatedNodeIds, proxyId, capability = 'config.proxy-method.v1') {
const index = new Map(items.map(node => [node.nodeId, node]));
const proxy = index.get(proxyId);
@@ -2588,7 +2599,7 @@ function renderProxyMethod() {
return option;
}));
proxyMethodProxy.value = proxyMethodProxyId;
- const network = proxyMethodNetwork();
+ const network = proxyMethodReadNetwork();
if (proxyMethodCurrentFor !== proxyMethodProxyId
|| proxyMethodCurrentSessionId !== (network.proxy?.sessionId || '')) {
proxyMethodCurrentFor = '';
@@ -2855,7 +2866,7 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 |
previewQuickSetup.disabled = !quickReady || (quickPresetNeedsRead() && !quickSetupValuesLoaded());
applyQuickSetup.disabled = !quickReady || !approvedQuickPreview;
runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy;
- const methodNetwork = proxyMethodNetwork();
+ const methodNetwork = proxyMethodReadNetwork();
proxyMethodButtons.forEach(button => {
const network = proxyMethodNetwork(proxyMethodCapabilityFor(button.dataset.proxyMethod));
button.disabled = !authenticated || !network.proxyReady || !network.topologyComplete || network.reported.length === 0
@@ -3974,7 +3985,7 @@ function populateQuickState(options) {
}
async function loadQuickSetupValues(automatic = false) {
- if (!quickPresetReadable()) return;
+ if (!quickPresetReadable()) return false;
approvedQuickPreview = null;
loadedQuickSetup = null;
const preset = quickPreset.value;
@@ -4000,7 +4011,7 @@ async function loadQuickSetupValues(automatic = false) {
readQuickSetup.hidden = false;
updateConfigurationButtons();
}
- return;
+ return false;
}
const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId
&& pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : null;
@@ -4021,6 +4032,7 @@ async function loadQuickSetupValues(automatic = false) {
text(quickOperationStatus, `Current values loaded from ${Object.keys(operation.results).find(id => operation.results[id] === result)}.${suffix}`);
readQuickSetup.hidden = true;
updateConfigurationButtons();
+ return true;
} catch (error) {
if (authenticated && generation === inputGeneration && preset === quickPreset.value
&& nodeId === selectedServerId && sessionId === nodeIndex.get(nodeId)?.sessionId
@@ -4030,6 +4042,7 @@ async function loadQuickSetupValues(automatic = false) {
readQuickSetup.hidden = false;
updateConfigurationButtons();
}
+ return false;
}
}
@@ -4158,19 +4171,21 @@ runTransportTest.addEventListener('click', async () => {
async function loadProxyMethod(automatic = false) {
const proxyId = proxyMethodProxyId;
- const sessionId = proxyMethodNetwork().proxy?.sessionId;
+ const readCapability = proxyMethodReadCapability();
+ const sessionId = proxyMethodNetwork(readCapability).proxy?.sessionId;
const requestAuthenticationGeneration = authenticationGeneration;
if (!proxyId) return;
try {
const operation = await startConfigurationOperation('/api/v1/configuration/read', {
nodeIds: [proxyId],
- configuration: {domain: 'quick-setup', preset: 'proxy-method', options: {method: 'PLUGINMESSAGING'}}
+ configuration: {domain: 'quick-setup', preset: 'proxy-method',
+ options: {method: readCapability === 'config.proxy-method.v2' ? 'HTTP' : 'PLUGINMESSAGING'}}
}, proxyMethodStatus);
const result = operation.results[proxyId];
const method = result?.success ? result.configuration?.options?.method : '';
if (!method) throw new Error('The proxy did not return its active communication method.');
if (requestAuthenticationGeneration !== authenticationGeneration || proxyId !== proxyMethodProxyId
- || sessionId !== proxyMethodNetwork().proxy?.sessionId || result?.sessionId !== sessionId) return;
+ || sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId || result?.sessionId !== sessionId) return;
proxyMethodCurrentFor = proxyId;
proxyMethodCurrentSessionId = sessionId;
proxyMethodCurrentValue = method;
@@ -4189,7 +4204,7 @@ proxyMethodProxy.addEventListener('change', () => {
proxyMethodCurrentSessionId = '';
proxyMethodCurrentValue = '';
renderProxyMethod();
- const network = proxyMethodNetwork();
+ const network = proxyMethodReadNetwork();
text(proxyMethodStatus, network.unavailable.length > 0
? `Cannot switch yet. Enroll, update, and connect: ${network.unavailable.map(backend => backend.displayName).join(', ')}.`
: 'Choose a method to preflight every node before applying.');
@@ -4915,10 +4930,8 @@ profilePicker.addEventListener('change', () => {
loadProfile.disabled = !profilePicker.value;
deleteProfile.disabled = !profilePicker.value;
});
-loadProfile.addEventListener('click', () => {
- const profile = readProfiles()[profilePicker.value];
- if (!profile || profile.version !== 1) { text(profileStatus, 'That profile is unavailable or unsupported.'); return; }
- pendingDetectedVoteSite = null;
+
+function applyProfileValues(profile) {
const assign = (field, value, max = 500) => { field.value = String(value ?? '').slice(0, max); };
if ([...quickPreset.options].some(option => option.value === profile.preset)) quickPreset.value = profile.preset;
assign(quickName, profile.name, 64); assign(quickMethod, profile.method, 32);
@@ -4945,10 +4958,28 @@ loadProfile.addEventListener('click', () => {
rewardSiteLabel.hidden = rewardScope.value !== 'site';
copyRewardToSetup.disabled = boundedLines(rewardCommands.value).length === 0;
}
+}
+
+loadProfile.addEventListener('click', async () => {
+ const profile = readProfiles()[profilePicker.value];
+ if (!profile || profile.version !== 1) { text(profileStatus, 'That profile is unavailable or unsupported.'); return; }
+ pendingDetectedVoteSite = null;
+ if ([...quickPreset.options].some(option => option.value === profile.preset)) quickPreset.value = profile.preset;
+ quickName.value = String(profile.name ?? '').slice(0, 64);
+ quickMethod.value = String(profile.method ?? '').slice(0, 32);
loadedQuickSetup = null;
updateQuickFields();
clearApprovals();
- text(profileStatus, `Loaded “${profilePicker.value}”. Load live values first if this preset edits existing configuration.`);
+ text(profileStatus, `Loading live values before applying “${profilePicker.value}”…`);
+ if (quickPresetReadable() && !await loadQuickSetupValues(true)) {
+ text(profileStatus, `Could not load live values for “${profilePicker.value}”. Retry before using this profile.`);
+ return;
+ }
+ applyProfileValues(profile);
+ inputGeneration++;
+ updateQuickFields();
+ clearApprovals();
+ text(profileStatus, `Loaded “${profilePicker.value}” over the confirmed live values. Preview before applying.`);
});
deleteProfile.addEventListener('click', () => {
const name = profilePicker.value;
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 7ac491b..abc7dc5 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -186,6 +186,28 @@ class ArtifactStoreTest {
assertRejected(() -> store.open(secondId));
}
+ @Test void incompleteRollbackRetainsItsPendingRecoveryMarker() throws Exception {
+ Path artifacts = directory.resolve("rollback-artifacts");
+ AtomicBoolean failAfterMove = new AtomicBoolean();
+ byte[] first = jar("name: VotingPlugin\n", "plugin/One.class", new byte[] {1});
+ byte[] second = jar("name: VotingPlugin\n", "plugin/Two.class", new byte[] {2});
+ String firstId = sha256(first);
+ ArtifactStore store = new ArtifactStore(artifacts, 1_000_000, 1, path -> {
+ if (failAfterMove.get()) {
+ Files.writeString(artifacts.resolve(firstId + ".jar"), "corrupt rollback target");
+ throw new IOException("simulated post-move failure");
+ }
+ });
+ store.upload(new ByteArrayInputStream(first), "first.jar", firstId);
+
+ failAfterMove.set(true);
+ assertRejected(() -> store.upload(new ByteArrayInputStream(second), "second.jar", sha256(second)));
+
+ try (var entries = Files.list(artifacts)) {
+ assertTrue(entries.anyMatch(path -> path.getFileName().toString().endsWith(".pending")));
+ }
+ }
+
@Test void startupRestoresAnInterruptedEvictionQuarantine() throws Exception {
Path artifacts = directory.resolve("artifacts");
byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index e2accf3..4552a1c 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -1175,6 +1175,28 @@ class ConfigurationOperationsTest {
assertEquals(false, read.results().get("proxy-a").success());
}
+ @Test void claimCancelsBackendSetupWhenTheNodeChangesRoleWithinItsSession() throws Exception {
+ Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
+ InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
+ UUID session = UUID.randomUUID();
+ Set capabilities = Set.of(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY);
+ registry.register(new NodeRegistration("backend", session, "Backend", "BUKKIT", "test", 1,
+ capabilities, Set.of()));
+ ConfigurationOperations operations = new ConfigurationOperations(registry,
+ new ConfigurationAuditLog(directory, clock), clock);
+ ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null,
+ List.of(), null, null, "proxy-backend", Map.of("method", "HTTP"));
+ UUID operation = operations.createRead(List.of("backend"), selector).operationId();
+
+ registry.register(new NodeRegistration("backend", session, "Backend", "VELOCITY", "test", 1,
+ capabilities, Set.of()));
+
+ assertNull(operations.claim("backend", session));
+ ConfigurationOperations.OperationView view = operations.get(operation);
+ assertEquals("COMPLETED_WITH_ERRORS", view.state());
+ assertEquals("TARGET_CHANGED", view.results().get("backend").code());
+ }
+
@Test void completionCancelsClaimedTaskWhenTheNodeLosesItsCapabilityWithinTheSession() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index f55d4d8..d72d499 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -116,7 +116,7 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("MAX_OPERATION_TARGETS = 100"));
assertTrue(script.body().contains("proxyMethodNetworkSignature(refreshedNetwork)"));
assertTrue(script.body().contains("proxyMethodCurrentSessionId !== (network.proxy?.sessionId || '')"));
- assertTrue(script.body().contains("sessionId !== proxyMethodNetwork().proxy?.sessionId"));
+ assertTrue(script.body().contains("sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId"));
assertTrue(script.body().contains("refreshedNetwork.proxy?.sessionId !== network.proxy.sessionId"));
assertTrue(script.body().contains("if (approvedQuickPreview?.workflow === 'sync-vote-sites') approvedQuickPreview = null;"));
assertTrue(script.body().contains("if (quickPreset.value !== 'sync-vote-sites') return;"));
@@ -216,6 +216,10 @@ class ControlHttpServerTest {
assertTrue(script.body().contains(
"configurationContent.addEventListener('input', () => {\n if (!configurationDirty) {\n configurationDraftNodeId = selectedServerId;"));
assertTrue(script.body().contains("quickPresetNeedsRead() && !quickSetupValuesLoaded()"));
+ assertFalse(script.body().contains("quickPresetReadable() && !loadedQuickSetup"),
+ "Quick-setup autoload must re-read when the loaded vote-site selector changes.");
+ assertTrue(script.body().contains("quickPresetReadable() && !quickSetupValuesLoaded()"),
+ "Quick-setup autoload must validate the loaded selector before deciding it is current.");
assertTrue(script.body().contains("loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId"));
assertTrue(script.body().contains("previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId"));
assertTrue(script.body().contains("sessionId !== nodeIndex.get(nodeId)?.sessionId"));
@@ -681,6 +685,17 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("if (autoLoadInFlight.has(tab)) {\n autoLoadPending.add(tab);"));
assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);"),
"A preset change during an older read must queue a fresh autoload.");
+ assertTrue(script.body().contains("quickPresetReadable() && !await loadQuickSetupValues(true)"),
+ "Loading a saved profile must read live values before enabling the template.");
+ assertTrue(script.body().contains("applyProfileValues(profile);"),
+ "The saved template must be restored after the live read rather than overwritten by it.");
+ assertTrue(script.body().contains("node.acceptedCapabilities.includes('config.proxy-method.v1')\n || node.acceptedCapabilities.includes('config.proxy-method.v2')"),
+ "An HTTP v2-only proxy must be selectable while each method action still checks its exact capability.");
+ assertTrue(script.body().contains("const methodNetwork = proxyMethodNetwork(proxyMethodCapabilityFor(button.dataset.proxyMethod));"),
+ "Method actions must retain their exact per-method capability check.");
+ assertTrue(script.body().contains("const readCapability = proxyMethodReadCapability();"));
+ assertTrue(script.body().contains("readCapability === 'config.proxy-method.v2' ? 'HTTP' : 'PLUGINMESSAGING'"),
+ "A v2-only proxy must read its current method through the capability it advertises.");
assertTrue(script.body().contains("autoLoadPending.clear();"));
int globalShortcut = script.body().indexOf("function openGlobalShortcut(destination)");
int selectConfigView = script.body().indexOf("setConfigView(destination.configView);", globalShortcut);
From 9f43a48f9b8141b3bef34f1d59266715e09a7f44 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 13:14:38 -0600
Subject: [PATCH 06/30] Sync method-aware settings reads
---
src/main/resources/web/app.js | 11 ++++++-----
.../control/http/ControlHttpServerTest.java | 5 ++++-
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index 939f93c..c0719ab 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -2958,7 +2958,7 @@ function quickSetupValuesLoaded() {
return loadedQuickSetup?.nodeId === selectedServerId
&& loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId
&& loadedQuickSetup.preset === quickPreset.value
- && loadedQuickSetup.selector === JSON.stringify(quickReadOptions());
+ && loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions());
}
function updatePluginSuggestions() {
@@ -3991,7 +3991,7 @@ async function loadQuickSetupValues(automatic = false) {
const preset = quickPreset.value;
const nodeId = selectedServerId;
const sessionId = nodeIndex.get(nodeId)?.sessionId;
- const selector = JSON.stringify(quickReadOptions());
+ const selector = JSON.stringify(quickReadConfigurationOptions());
const generation = inputGeneration;
readQuickSetup.hidden = true;
text(quickOperationStatus, `Loading current ${preset} settings from ${nodeId}…`);
@@ -4005,7 +4005,7 @@ async function loadQuickSetupValues(automatic = false) {
if (!result) throw new Error('The selected backend did not return guided settings. Update VotingPlugin on that node.');
if (generation !== inputGeneration || preset !== quickPreset.value || nodeId !== selectedServerId
|| sessionId !== nodeIndex.get(nodeId)?.sessionId
- || selector !== JSON.stringify(quickReadOptions())) {
+ || selector !== JSON.stringify(quickReadConfigurationOptions())) {
if (!quickSetupValuesLoaded()) {
text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.');
readQuickSetup.hidden = false;
@@ -4021,7 +4021,8 @@ async function loadQuickSetupValues(automatic = false) {
quickService.value = detected.service;
}
if (detected) pendingDetectedVoteSite = null;
- loadedQuickSetup = {nodeId, sessionId, preset, selector};
+ loadedQuickSetup = {nodeId, sessionId, preset,
+ selector: JSON.stringify(quickReadConfigurationOptions())};
inputGeneration++;
const suffix = preset === 'vote-site' && result.configuration.options.exists === 'false'
? ` This site key does not exist yet; the form is ready to create it.${detected ? ' The detected service was retained.' : ''}`
@@ -4036,7 +4037,7 @@ async function loadQuickSetupValues(automatic = false) {
} catch (error) {
if (authenticated && generation === inputGeneration && preset === quickPreset.value
&& nodeId === selectedServerId && sessionId === nodeIndex.get(nodeId)?.sessionId
- && selector === JSON.stringify(quickReadOptions())) {
+ && selector === JSON.stringify(quickReadConfigurationOptions())) {
loadedQuickSetup = null;
text(quickOperationStatus, `Could not load current ${preset} settings: ${error.message}`);
readQuickSetup.hidden = false;
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index d72d499..1cea6a8 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -138,6 +138,9 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("configurationOperationsInFlight === 0 && autoLoadPending.has('quick-setup')"));
assertTrue(script.body().contains("function quickReadConfigurationOptions()"));
assertTrue(script.body().contains("options: quickReadConfigurationOptions()"));
+ assertTrue(script.body().contains("loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions())"));
+ assertTrue(script.body().contains("selector: JSON.stringify(quickReadConfigurationOptions())"),
+ "The retained selector must reflect the method returned by the live backend read.");
assertTrue(web.body().contains("Add a simple vote reward"));
assertTrue(web.body().contains("First-run setup"));
assertTrue(web.body().contains("Node enrollment"));
@@ -313,7 +316,7 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("Discard unsaved routing changes and load current values?"));
assertTrue(script.body().contains("Discard the unsaved ${configurationFile.value} draft and read/reload the current file for this server?"));
assertTrue(script.body().contains("window.addEventListener('beforeunload'"));
- assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset, selector}"));
+ assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset,"));
assertTrue(script.body().contains("configurationOperationsInFlight"));
assertTrue(script.body().contains("if (selectedCapabilitiesChanged) {\n approvedPreview = null;"));
assertTrue(script.body().contains("approvedPreview.nodeIds.every"));
From 929bd3bb06210722f6907f41102639dcb9534b64 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 14:01:42 -0600
Subject: [PATCH 07/30] Harden artifact collision recovery
---
.../control/artifact/ArtifactStore.java | 66 +++++++---
src/main/resources/web/app.js | 124 +++++++++++++++---
.../control/artifact/ArtifactStoreTest.java | 56 +++++++-
.../control/http/ControlHttpServerTest.java | 42 +++++-
4 files changed, 246 insertions(+), 42 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 76009b2..ae4777f 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -54,6 +54,7 @@ public final class ArtifactStore {
private final Path directory;
private final long maximumStoredBytes;
private final int maximumStoredArtifacts;
+ private final IoAction beforePublishMove;
private final IoAction afterPublishMove;
/** Creates or opens an empty private directory owned by Control. */
@@ -67,11 +68,18 @@ public ArtifactStore(Path directory) throws IOException {
ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts,
IoAction afterPublishMove) throws IOException {
+ this(directory, maximumStoredBytes, maximumStoredArtifacts, path -> { }, afterPublishMove);
+ }
+
+ ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts,
+ IoAction beforePublishMove, IoAction afterPublishMove) throws IOException {
if (directory == null) throw rejected();
- if (maximumStoredBytes < 1 || maximumStoredArtifacts < 1 || afterPublishMove == null) throw rejected();
+ if (maximumStoredBytes < 1 || maximumStoredArtifacts < 1
+ || beforePublishMove == null || afterPublishMove == null) throw rejected();
this.directory = directory.toAbsolutePath().normalize();
this.maximumStoredBytes = maximumStoredBytes;
this.maximumStoredArtifacts = maximumStoredArtifacts;
+ this.beforePublishMove = beforePublishMove;
this.afterPublishMove = afterPublishMove;
try {
createPrivateDirectory(this.directory);
@@ -106,7 +114,13 @@ private void recoverEvictionTransactions() throws IOException {
String name = marker.getFileName().toString();
if (!name.matches("evict-[0-9a-f]{32}-[0-9a-f]{64}\\.(?:pending|committed)")) continue;
if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker)
- || Files.size(marker) != 0) throw rejected();
+ || Files.size(marker) < 1 || Files.size(marker) > 128) throw rejected();
+ String stagedName = Files.readString(marker, StandardCharsets.UTF_8);
+ if (!stagedName.matches("upload-[A-Za-z0-9._-]+\\.part") || stagedName.contains("..")) throw rejected();
+ Path staged = directory.resolve(stagedName);
+ boolean stagedPresent = Files.exists(staged, LinkOption.NOFOLLOW_LINKS);
+ if (stagedPresent && (!Files.isRegularFile(staged, LinkOption.NOFOLLOW_LINKS)
+ || Files.isSymbolicLink(staged))) throw rejected();
String transaction = name.substring("evict-".length(), "evict-".length() + 32);
String incomingId = name.substring("evict-".length() + 33, "evict-".length() + 33 + 64);
boolean committed = name.endsWith(".committed");
@@ -126,7 +140,7 @@ private void recoverEvictionTransactions() throws IOException {
} else {
if (Files.exists(incoming, LinkOption.NOFOLLOW_LINKS)) {
verifyExistingArtifact(incoming, incomingId);
- Files.delete(incoming);
+ if (!stagedPresent) Files.delete(incoming);
}
restoreQuarantined(quarantined);
}
@@ -172,8 +186,7 @@ public synchronized Artifact upload(InputStream source, String displayFilename,
return new Artifact(actual, displayFilename, digest.size());
}
List evictionPlan = planCapacity(digest.size(), protectedArtifactIds);
- publishWithRollback(temporary, artifact, evictionPlan);
- published = true;
+ published = publishWithRollback(temporary, artifact, evictionPlan);
return new Artifact(actual, displayFilename, digest.size());
} catch (ArtifactException failure) {
throw failure;
@@ -213,15 +226,16 @@ private List planCapacity(long incomingBytes, Set protectedA
return List.copyOf(evictionPlan);
}
- private void publishWithRollback(Path temporary, Path artifact, List evictionPlan)
+ private boolean publishWithRollback(Path temporary, Path artifact, List evictionPlan)
throws IOException {
List quarantined = new ArrayList<>();
String transaction = UUID.randomUUID().toString().replace("-", "");
String incomingId = artifact.getFileName().toString().substring(0, 64);
Path pending = directory.resolve("evict-" + transaction + "-" + incomingId + ".pending");
Path committed = directory.resolve("evict-" + transaction + "-" + incomingId + ".committed");
+ boolean moved = false;
try {
- createTransactionMarker(pending);
+ createTransactionMarker(pending, temporary.getFileName().toString());
for (StoredFile candidate : evictionPlan) {
Path backup = directory.resolve("evict-" + transaction + "-" + candidate.artifactId() + ".part");
if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS)) throw rejected();
@@ -229,11 +243,19 @@ private void publishWithRollback(Path temporary, Path artifact, List
quarantined.add(new QuarantinedFile(candidate.path(), backup));
}
if (!quarantined.isEmpty()) DurableFiles.forceDirectory(directory);
- publish(temporary, artifact);
+ moved = publish(temporary, artifact);
+ if (!moved) {
+ restoreQuarantined(quarantined);
+ DurableFiles.forceDirectory(directory);
+ Files.delete(pending);
+ DurableFiles.forceDirectory(directory);
+ return false;
+ }
+ finishPublishedArtifact(artifact);
move(pending, committed, false);
DurableFiles.forceDirectory(directory);
} catch (IOException | RuntimeException failure) {
- IOException rollbackFailure = rollbackPublication(artifact, quarantined, pending, committed);
+ IOException rollbackFailure = rollbackPublication(artifact, quarantined, pending, committed, moved);
if (rollbackFailure != null) failure.addSuppressed(rollbackFailure);
throw failure;
}
@@ -245,19 +267,24 @@ private void publishWithRollback(Path temporary, Path artifact, List
} catch (IOException ignored) {
/* The committed marker makes remaining cleanup deterministic on startup. */
}
+ return moved;
}
- private void createTransactionMarker(Path marker) throws IOException {
+ private void createTransactionMarker(Path marker, String stagedName) throws IOException {
try (FileChannel channel = FileChannel.open(marker, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS)) {
setPermissions(marker, FILE_PERMISSIONS);
+ ByteBuffer contents = ByteBuffer.wrap(stagedName.getBytes(StandardCharsets.UTF_8));
+ while (contents.hasRemaining()) {
+ if (channel.write(contents) <= 0) throw new IOException("Artifact transaction marker could not be written");
+ }
channel.force(true);
}
DurableFiles.forceDirectory(directory);
}
private IOException rollbackPublication(Path artifact, List quarantined,
- Path pending, Path committed) {
+ Path pending, Path committed, boolean removeArtifact) {
IOException failure = null;
if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
try {
@@ -268,7 +295,7 @@ private IOException rollbackPublication(Path artifact, List qua
}
}
try {
- if (Files.exists(artifact, LinkOption.NOFOLLOW_LINKS)) {
+ if (removeArtifact && Files.exists(artifact, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)) {
throw rejected();
}
@@ -440,13 +467,18 @@ private static boolean declaresVotingPlugin(byte[] pluginYml) throws IOException
}
}
- private void publish(Path temporary, Path artifact) throws IOException {
+ private boolean publish(Path temporary, Path artifact) throws IOException {
try {
- move(temporary, artifact, false);
+ beforePublishMove.run(artifact);
+ moveWithoutReplacing(temporary, artifact);
} catch (java.nio.file.FileAlreadyExistsException collision) {
verifyExistingArtifact(artifact, artifact.getFileName().toString().substring(0, 64));
- return;
+ return false;
}
+ return true;
+ }
+
+ private void finishPublishedArtifact(Path artifact) throws IOException {
afterPublishMove.run(artifact);
setPermissions(artifact, FILE_PERMISSIONS);
try (FileChannel channel = FileChannel.open(artifact, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
@@ -465,6 +497,10 @@ private static void move(Path source, Path target, boolean replace) throws IOExc
}
}
+ private static void moveWithoutReplacing(Path source, Path target) throws IOException {
+ Files.move(source, target);
+ }
+
private void verifyExistingArtifact(Path artifact, String expectedHash) throws IOException {
if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)
|| Files.size(artifact) > MAX_UPLOAD_BYTES || !hash(artifact).equals(expectedHash)) throw rejected();
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index c0719ab..00a5182 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -253,6 +253,9 @@ let approvedPreview = null;
let approvedFilePreview = null;
let approvedQuickPreview = null;
let loadedQuickSetup = null;
+let quickSetupDirty = false;
+let quickSetupPreserveReadGeneration = -1;
+const dedicatedSetupDirty = new Set();
let voteSitesSourceId = '';
let voteSitesTargetIds = new Set();
let voteSitesTargetsInitialized = false;
@@ -1079,7 +1082,11 @@ async function loadOperationHistoryOnce() {
operationHistoryItems = retainedOperations.slice(0, MAX_OPERATION_HISTORY).map(operation =>
({...operation, results: Object.fromEntries(Object.entries(operation.results || {}).map(([nodeId, result]) =>
[nodeId, result ? {...result, configuration: null} : result]))}));
- if (observedSuccessfulApply) invalidateConfigurationReads();
+ if (observedSuccessfulApply) {
+ invalidateConfigurationReads();
+ invalidateGuidedSetupReads();
+ if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0);
+ }
const pendingRestarts = new Map();
const restartSessions = body.voteLoggingRestartSessions;
if (restartSessions && typeof restartSessions === 'object' && !Array.isArray(restartSessions)) {
@@ -1240,6 +1247,7 @@ function applyAuthenticatedSession(body) {
approvedFilePreview = null;
approvedQuickPreview = null;
loadedQuickSetup = null;
+ quickSetupDirty = false;
selectedNodes.clear();
voteSitesSourceId = '';
voteSitesTargetIds.clear();
@@ -1403,7 +1411,8 @@ function nodeCard(node) {
selector.className = 'node-select';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
- const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1']
+ const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1',
+ 'config.quick-setup.v1', 'config.proxy-method.v2']
.some(capability => node.acceptedCapabilities.includes(capability));
checkbox.disabled = !node.online || !controllable || node.nodeId === selectedServerId;
checkbox.checked = selectedNodes.has(node.nodeId);
@@ -1624,13 +1633,33 @@ async function autoLoadTab(tab) {
return;
}
if (tab === 'quick-setup' && !approvedQuickPreview
- && (autoSitesState.textContent === 'Not loaded' || voteLoggingState.textContent === 'Not loaded'
- || quickPresetReadable() && !quickSetupValuesLoaded())) {
+ && (!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded'
+ || !dedicatedSetupDirty.has('vote-logging') && voteLoggingState.textContent === 'Not loaded'
+ || quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)
+ && !quickSetupValuesLoaded())) {
autoLoadInFlight.add(tab);
try {
- if (autoSitesState.textContent === 'Not loaded') await loadDedicatedSetup('auto-create-vote-sites', true);
- if (voteLoggingState.textContent === 'Not loaded') await loadDedicatedSetup('vote-logging', true);
- if (quickPresetReadable() && !quickSetupValuesLoaded()) await loadQuickSetupValues(true);
+ const autoLoadGeneration = inputGeneration;
+ if (!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded') {
+ await loadDedicatedSetup('auto-create-vote-sites', true);
+ }
+ if (inputGeneration !== autoLoadGeneration) {
+ autoLoadPending.add(tab);
+ return;
+ }
+ if (!dedicatedSetupDirty.has('vote-logging') && voteLoggingState.textContent === 'Not loaded') {
+ await loadDedicatedSetup('vote-logging', true);
+ }
+ if (inputGeneration !== autoLoadGeneration) {
+ autoLoadPending.add(tab);
+ return;
+ }
+ if (quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)
+ && !quickSetupValuesLoaded()) {
+ const preserveGeneration = quickSetupPreserveReadGeneration;
+ await loadQuickSetupValues(true, quickSetupDirty);
+ if (quickSetupPreserveReadGeneration === preserveGeneration) quickSetupPreserveReadGeneration = -1;
+ }
} finally { finishAutoLoad(tab); }
return;
}
@@ -2683,13 +2712,13 @@ function updateExtendedButtons() {
previewReward.disabled = !quickReady;
applyReward.disabled = !quickReady || !dedicatedSetupApprovals.get('reward-builder');
loadAutoSites.disabled = !quickReady;
- previewAutoSites.disabled = !quickReady;
+ previewAutoSites.disabled = !quickReady || autoSitesState.textContent === 'Not loaded';
applyAutoSites.disabled = !quickReady || !dedicatedSetupApprovals.get('auto-create-vote-sites');
selectAllAutoSitesTargets.disabled = !authenticated || allQuickBackends.length === 0
|| configurationOperationsInFlight > 0;
text(autoSitesTargetCount, `${backendTargets.length} selected ${backendTargets.length === 1 ? 'backend' : 'backends'}`);
loadVoteLogging.disabled = !quickReady;
- previewVoteLogging.disabled = !quickReady;
+ previewVoteLogging.disabled = !quickReady || voteLoggingState.textContent === 'Not loaded';
applyVoteLogging.disabled = !quickReady || !dedicatedSetupApprovals.get('vote-logging');
runDriftCheck.disabled = !driftReady;
createSnapshot.disabled = !lastFileReadOperation;
@@ -2763,6 +2792,7 @@ function resetServerConfigurationForms(status, preserveDirtyDrafts = false) {
}
function resetDedicatedSetupValues() {
+ dedicatedSetupDirty.clear();
autoSitesEnabled.checked = false;
voteLoggingEnabled.checked = false;
voteLoggingDays.value = '30';
@@ -2775,6 +2805,23 @@ function resetDedicatedSetupValues() {
loadVoteLogging.hidden = true;
}
+function invalidateGuidedSetupReads() {
+ loadedQuickSetup = null;
+ if (quickSetupDirty) {
+ readQuickSetup.hidden = false;
+ text(quickOperationStatus, 'Configuration changed elsewhere; your unsaved guided edits were preserved. Load current values to discard them.');
+ }
+ [['auto-create-vote-sites', autoSitesState, autoSitesStatus, loadAutoSites],
+ ['vote-logging', voteLoggingState, voteLoggingStatus, loadVoteLogging]].forEach(([preset, state, status, retry]) => {
+ text(state, 'Not loaded');
+ state.className = 'pill neutral';
+ retry.hidden = !dedicatedSetupDirty.has(preset);
+ if (dedicatedSetupDirty.has(preset)) {
+ text(status, 'Configuration changed elsewhere; your unsaved edits were preserved. Load current values to discard them.');
+ }
+ });
+}
+
function resetServerContextValues(reason, preserveDirtyDrafts = false) {
dedicatedSetupApprovals.clear();
pendingDetectedVoteSite = null;
@@ -2812,6 +2859,7 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) {
text(autoSitesStatus, reason);
text(voteLoggingStatus, reason);
loadedQuickSetup = null;
+ quickSetupDirty = false;
readQuickSetup.hidden = true;
resetServerConfigurationForms(reason, preserveDirtyDrafts);
const preset = quickPreset.value;
@@ -3018,6 +3066,7 @@ function discardAuthenticationState(reason) {
approvedFilePreview = null;
approvedQuickPreview = null;
loadedQuickSetup = null;
+ quickSetupDirty = false;
inputGeneration++;
logout.hidden = true;
sidebarToggle.hidden = true;
@@ -3468,7 +3517,7 @@ async function loadNodesOnce() {
nodeCapabilities.get(node)?.includes(selectedFileCapability()));
const invalidQuickApproval = approvedQuickPreview && approvedQuickPreview.workflow !== 'sync-vote-sites' &&
!approvedQuickPreview.nodeIds.every(node =>
- nodeCapabilities.get(node)?.includes('config.quick-setup.v1'));
+ nodeCapabilities.get(node)?.includes(quickSetupCapability()));
const invalidVoteSitesApproval = approvedQuickPreview?.workflow === 'sync-vote-sites' &&
(!approvedQuickPreview.nodeIds.every(node =>
nodeCapabilities.get(node)?.includes('config.vote-sites-sync.v1')) ||
@@ -3984,7 +4033,7 @@ function populateQuickState(options) {
}
}
-async function loadQuickSetupValues(automatic = false) {
+async function loadQuickSetupValues(automatic = false, preserveDirty = false) {
if (!quickPresetReadable()) return false;
approvedQuickPreview = null;
loadedQuickSetup = null;
@@ -4015,7 +4064,12 @@ async function loadQuickSetupValues(automatic = false) {
}
const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId
&& pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : null;
+ const selectedProxyMethod = preset === 'proxy-backend' ? quickMethod.value : null;
+ const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null;
populateQuickState(result.configuration.options);
+ if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod;
+ if (editedProxyServer != null) quickName.value = editedProxyServer;
+ quickSetupDirty = preserveDirty;
if (detected && result.configuration.options.exists === 'false') {
quickSiteDisplayName.value = detected.service;
quickService.value = detected.service;
@@ -4305,13 +4359,15 @@ async function loadDedicatedSetup(preset, automatic = false) {
voteLoggingMainMysql.checked = options.useMainMySQL !== 'false';
text(voteLoggingState, voteLoggingEnabled.checked ? 'Enabled on primary' : 'Disabled on primary');
}
+ dedicatedSetupDirty.delete(preset);
elements.state.className = `pill ${options.enabled === 'true' ? 'online' : 'neutral'}`;
text(elements.status, operationSummary(operation));
} catch (error) {
- if (requestNodeId === selectedServerId && requestSessionId === nodeIndex.get(requestNodeId)?.sessionId
- && requestGeneration === inputGeneration) {
+ if (authenticated && requestNodeId === selectedServerId
+ && requestSessionId === nodeIndex.get(requestNodeId)?.sessionId) {
text(elements.status, `Could not load current ${preset} settings: ${error.message}`);
elements.retry.hidden = false;
+ if (automatic && requestGeneration !== inputGeneration) void autoLoadTab('quick-setup');
}
}
updateExtendedButtons();
@@ -4399,7 +4455,9 @@ previewVoteLogging.addEventListener('click', () => previewDedicatedSetup('vote-l
applyVoteLogging.addEventListener('click', () => applyDedicatedSetup('vote-logging'));
[autoSitesEnabled, voteLoggingEnabled, voteLoggingDays, voteLoggingMainMysql].forEach(field => {
field.addEventListener('input', () => {
- dedicatedSetupApprovals.delete(field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging');
+ const preset = field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging';
+ dedicatedSetupApprovals.delete(preset);
+ dedicatedSetupDirty.add(preset);
inputGeneration++;
updateExtendedButtons();
});
@@ -4944,7 +5002,8 @@ function applyProfileValues(profile) {
assign(quickMessage, profile.playerMessage, 500); quickProcessRewards.checked = Boolean(profile.processRewards);
quickAutoSites.checked = Boolean(profile.autoSites); quickExtraCheck.checked = Boolean(profile.extraCheck);
quickCountFake.checked = Boolean(profile.countFake); quickHideSiteWarning.checked = Boolean(profile.hideWarning);
- quickDisableUpdates.checked = Boolean(profile.disableUpdates); quickPartyEnabled.checked = Boolean(profile.partyEnabled);
+ quickDisableUpdates.checked = Boolean(profile.disableUpdates);
+ if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);
assign(quickPartyVotes, profile.partyVotes, 6);
assign(quickPartyCommand, profile.partyCommand, 500); assign(quickPartyBroadcast, profile.partyBroadcast, 500);
quickPartyAll.checked = Boolean(profile.partyAll); quickPartyOnline.checked = Boolean(profile.partyOnline);
@@ -4962,8 +5021,10 @@ function applyProfileValues(profile) {
}
loadProfile.addEventListener('click', async () => {
- const profile = readProfiles()[profilePicker.value];
+ const profileName = profilePicker.value;
+ const profile = readProfiles()[profileName];
if (!profile || profile.version !== 1) { text(profileStatus, 'That profile is unavailable or unsupported.'); return; }
+ const profileSignature = JSON.stringify(profile);
pendingDetectedVoteSite = null;
if ([...quickPreset.options].some(option => option.value === profile.preset)) quickPreset.value = profile.preset;
quickName.value = String(profile.name ?? '').slice(0, 64);
@@ -4971,16 +5032,22 @@ loadProfile.addEventListener('click', async () => {
loadedQuickSetup = null;
updateQuickFields();
clearApprovals();
- text(profileStatus, `Loading live values before applying “${profilePicker.value}”…`);
+ text(profileStatus, `Loading live values before applying “${profileName}”…`);
if (quickPresetReadable() && !await loadQuickSetupValues(true)) {
- text(profileStatus, `Could not load live values for “${profilePicker.value}”. Retry before using this profile.`);
+ text(profileStatus, `Could not load live values for “${profileName}”. Retry before using this profile.`);
+ return;
+ }
+ const currentProfile = readProfiles()[profileName];
+ if (profilePicker.value !== profileName || !currentProfile || JSON.stringify(currentProfile) !== profileSignature) {
+ text(profileStatus, 'The selected profile changed while loading live values. Select it again before applying it.');
return;
}
applyProfileValues(profile);
+ quickSetupDirty = true;
inputGeneration++;
updateQuickFields();
clearApprovals();
- text(profileStatus, `Loaded “${profilePicker.value}” over the confirmed live values. Preview before applying.`);
+ text(profileStatus, `Loaded “${profileName}” over the confirmed live values. Preview before applying.`);
});
deleteProfile.addEventListener('click', () => {
const name = profilePicker.value;
@@ -4996,12 +5063,26 @@ deleteProfile.addEventListener('click', () => {
clearOperationHistory.addEventListener('click', loadOperationHistory);
-[quickName, quickMethod, quickSiteDisplayName, quickService, quickUrl, quickDelay,
+[quickSiteDisplayName, quickService, quickUrl, quickDelay,
quickSitePriority, quickSiteMaterial, quickSiteEnabled, quickSiteHidden, quickRewardScope,
quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake,
quickHideSiteWarning, quickDisableUpdates, quickPartyEnabled, quickPartyVotes, quickPartyCommand, quickPartyBroadcast,
quickPartyAll, quickPartyOnline, quickAutoSitesOnly, quickVoteLoggingEnabled, quickVoteLoggingDays,
- quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', clearApprovals));
+ quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', () => {
+ quickSetupDirty = true;
+ clearApprovals();
+}));
+quickMethod.addEventListener('input', clearApprovals);
+quickName.addEventListener('input', () => {
+ if (quickPreset.value !== 'vote-site') quickSetupDirty = true;
+ clearApprovals();
+});
+quickMethod.addEventListener('input', () => {
+ if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) {
+ if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration;
+ void autoLoadTab('quick-setup');
+ }
+});
quickName.addEventListener('input', () => {
if (pendingDetectedVoteSite && pendingDetectedVoteSite.key !== quickName.value.trim()) pendingDetectedVoteSite = null;
updateQuickFields();
@@ -5042,6 +5123,7 @@ configurationFile.addEventListener('input', () => {
});
quickPreset.addEventListener('input', () => {
loadedQuickSetup = null;
+ quickSetupDirty = false;
if (quickPreset.value !== 'vote-site') pendingDetectedVoteSite = null;
updateQuickFields();
clearApprovals();
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index abc7dc5..02f6017 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -186,6 +186,30 @@ class ArtifactStoreTest {
assertRejected(() -> store.open(secondId));
}
+ @Test void publicationCollisionRestoresPlannedEvictionsAndRemovesTheStagedUpload() throws Exception {
+ Path artifacts = directory.resolve("collision-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {2});
+ String oldId = sha256(old);
+ String incomingId = sha256(incoming);
+ AtomicBoolean createCollision = new AtomicBoolean();
+ ArtifactStore store = new ArtifactStore(artifacts, 1_000_000, 1, path -> {
+ if (createCollision.get()) Files.write(path, incoming);
+ }, path -> { });
+ store.upload(new ByteArrayInputStream(old), "old.jar", oldId);
+
+ createCollision.set(true);
+ ArtifactStore.Artifact duplicate = store.upload(new ByteArrayInputStream(incoming), "incoming.jar", incomingId);
+
+ assertEquals(incomingId, duplicate.artifactId());
+ assertArrayEquals(old, store.open(oldId).readAllBytes());
+ assertArrayEquals(incoming, store.open(incomingId).readAllBytes());
+ try (var entries = Files.list(artifacts)) {
+ assertFalse(entries.anyMatch(path -> path.getFileName().toString().startsWith("upload-")
+ || path.getFileName().toString().startsWith("evict-")));
+ }
+ }
+
@Test void incompleteRollbackRetainsItsPendingRecoveryMarker() throws Exception {
Path artifacts = directory.resolve("rollback-artifacts");
AtomicBoolean failAfterMove = new AtomicBoolean();
@@ -220,7 +244,8 @@ class ArtifactStoreTest {
Path quarantine = artifacts.resolve("evict-" + transaction + "-" + artifactId + ".part");
Files.move(canonical, quarantine);
Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
- Files.createFile(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending"));
+ Files.writeString(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending"),
+ "upload-owned.part");
ArtifactStore recovered = new ArtifactStore(artifacts);
@@ -229,6 +254,30 @@ class ArtifactStoreTest {
assertFalse(Files.exists(quarantine));
}
+ @Test void startupPreservesACompetingArtifactAfterAnInterruptedPublishCollision() throws Exception {
+ Path artifacts = directory.resolve("collision-recovery-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {2});
+ ArtifactStore store = new ArtifactStore(artifacts);
+ String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
+ String incomingId = sha256(incoming);
+ String transaction = "4".repeat(32);
+ Path quarantine = artifacts.resolve("evict-" + transaction + "-" + oldId + ".part");
+ Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
+ Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
+ Path staged = Files.write(artifacts.resolve("upload-collision.part"), incoming);
+ Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending");
+ Files.writeString(marker, staged.getFileName().toString());
+
+ ArtifactStore recovered = new ArtifactStore(artifacts);
+
+ assertArrayEquals(old, recovered.open(oldId).readAllBytes());
+ assertArrayEquals(incoming, recovered.open(incomingId).readAllBytes());
+ assertFalse(Files.exists(staged));
+ assertFalse(Files.exists(quarantine));
+ assertFalse(Files.exists(marker));
+ }
+
@Test void startupFinishesACommittedEvictionWithoutRestoringOldArtifacts() throws Exception {
Path artifacts = directory.resolve("committed-artifacts");
byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
@@ -240,7 +289,8 @@ class ArtifactStoreTest {
Path quarantine = artifacts.resolve("evict-" + transaction + "-" + oldId + ".part");
Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
- Files.createFile(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".committed"));
+ Files.writeString(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".committed"),
+ "upload-committed.part");
ArtifactStore recovered = new ArtifactStore(artifacts);
@@ -262,7 +312,7 @@ class ArtifactStoreTest {
Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".committed");
- Files.createFile(marker);
+ Files.writeString(marker, "upload-committed-next.part");
String nextId = store.upload(new ByteArrayInputStream(next), "next.jar", sha256(next)).artifactId();
ArtifactStore recovered = new ArtifactStore(artifacts, 1_000_000, 1);
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 1cea6a8..9ea5cb8 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -130,6 +130,8 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("if (!quickSetupValuesLoaded()) {\n text(quickOperationStatus, 'The server or setup changed while reading."));
assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)"));
assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'"));
+ assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"),
+ "Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field.");
assertTrue(script.body().contains("config.proxy-method.v2"));
assertTrue(script.body().contains("function quickSetupTargets()"));
assertTrue(script.body().contains("nodeIds = quickSetupTargets()"));
@@ -139,6 +141,40 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("function quickReadConfigurationOptions()"));
assertTrue(script.body().contains("options: quickReadConfigurationOptions()"));
assertTrue(script.body().contains("loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions())"));
+ assertTrue(script.body().contains("const autoLoadGeneration = inputGeneration;"));
+ assertTrue(script.body().contains("if (inputGeneration !== autoLoadGeneration) {\n autoLoadPending.add(tab);\n return;\n }"),
+ "A stale dedicated read must fence the remainder of the automatic quick-setup sequence.");
+ assertTrue(script.body().contains("quickMethod.addEventListener('input', () => {\n if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) {"));
+ assertTrue(script.body().contains("if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration;\n void autoLoadTab('quick-setup');"),
+ "Changing a proxy-backend method must schedule a capability-correct reread.");
+ assertTrue(script.body().contains("const selectedProxyMethod = preset === 'proxy-backend' ? quickMethod.value : null;"));
+ assertTrue(script.body().contains("if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod;"),
+ "The capability read must not overwrite the proxy method the operator selected for preview.");
+ assertTrue(script.body().contains("const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null;"));
+ assertTrue(script.body().contains("if (editedProxyServer != null) quickName.value = editedProxyServer;"),
+ "A capability read must preserve an edited proxy destination.");
+ assertTrue(script.body().contains("previewAutoSites.disabled = !quickReady || autoSitesState.textContent === 'Not loaded';"));
+ assertTrue(script.body().contains("previewVoteLogging.disabled = !quickReady || voteLoggingState.textContent === 'Not loaded';"));
+ assertTrue(script.body().contains("if (automatic && requestGeneration !== inputGeneration) void autoLoadTab('quick-setup');"),
+ "Discarded automatic dedicated reads must request a fresh read rather than leave defaults previewable.");
+ assertTrue(script.body().contains("if (observedSuccessfulApply) {\n invalidateConfigurationReads();\n invalidateGuidedSetupReads();"),
+ "Observed external applies must invalidate the dedicated setup cards as well as the main editor.");
+ assertTrue(script.body().contains("if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0);"),
+ "Invalidated dedicated settings must automatically reload while Quick Setup is visible.");
+ assertTrue(script.body().contains("nodeCapabilities.get(node)?.includes(quickSetupCapability())"),
+ "Quick approvals must remain valid only for their selected capability version.");
+ assertTrue(script.body().contains("'config.quick-setup.v1', 'config.proxy-method.v2'"),
+ "Secondary v2-only backends must remain selectable for HTTP setup.");
+ assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)"));
+ assertTrue(script.body().contains("!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded'"));
+ assertTrue(script.body().contains("Configuration changed elsewhere; your unsaved guided edits were preserved."),
+ "External configuration changes must not overwrite unsaved guided edits.");
+ assertTrue(script.body().contains("dedicatedSetupDirty.add(preset);"));
+ assertTrue(script.body().contains("if (quickPreset.value !== 'vote-site') quickSetupDirty = true;"),
+ "The shared name field is a selector for vote sites but a dirty editable value for other presets.");
+ assertTrue(script.body().contains("const profileName = profilePicker.value;"));
+ assertTrue(script.body().contains("profilePicker.value !== profileName || !currentProfile || JSON.stringify(currentProfile) !== profileSignature"),
+ "Profile application must verify its selection after waiting for live values.");
assertTrue(script.body().contains("selector: JSON.stringify(quickReadConfigurationOptions())"),
"The retained selector must reflect the method returned by the live backend read.");
assertTrue(web.body().contains("Add a simple vote reward"));
@@ -221,7 +257,7 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("quickPresetNeedsRead() && !quickSetupValuesLoaded()"));
assertFalse(script.body().contains("quickPresetReadable() && !loadedQuickSetup"),
"Quick-setup autoload must re-read when the loaded vote-site selector changes.");
- assertTrue(script.body().contains("quickPresetReadable() && !quickSetupValuesLoaded()"),
+ assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)"),
"Quick-setup autoload must validate the loaded selector before deciding it is current.");
assertTrue(script.body().contains("loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId"));
assertTrue(script.body().contains("previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId"));
@@ -276,7 +312,7 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("serverConfigurationGeneration > observedServerConfigurationGeneration"));
assertTrue(script.body().contains("Math.max(observedServerConfigurationGeneration, serverConfigurationGeneration)"),
"A delayed older response must not move the observed server generation backwards.");
- assertTrue(script.body().contains("if (observedSuccessfulApply) invalidateConfigurationReads();"),
+ assertTrue(script.body().contains("if (observedSuccessfulApply) {\n invalidateConfigurationReads();"),
"Activity refreshes must invalidate cached health after observing an external successful apply.");
assertTrue(script.body().contains("if (applied) {\n invalidateConfigurationReads();"),
"Locally completed applies must use the same cache invalidation path.");
@@ -290,7 +326,7 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("if (previewGeneration !== inputGeneration\n"
+ " || signature !== JSON.stringify"),
"Dedicated previews completed after another apply must not restore stale approvals.");
- assertTrue(script.body().contains("dedicatedSetupApprovals.delete(field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging');\n"
+ assertTrue(script.body().contains("dedicatedSetupApprovals.delete(preset);\n dedicatedSetupDirty.add(preset);\n"
+ " inputGeneration++;\n updateExtendedButtons();"),
"Dedicated setup edits must fence delayed reads before they can overwrite newer input.");
assertTrue(script.body().contains("const submittedOptions = JSON.stringify(dedicatedSetupOptions(preset));"));
From 772a9b67761e5e6b0ee594ac0725c4f62fb480e5 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 14:14:57 -0600
Subject: [PATCH 08/30] Serialize artifact store transactions
---
.../control/artifact/ArtifactStore.java | 70 +++++++++++--
.../control/artifact/ArtifactStoreTest.java | 98 ++++++++++++++++++-
2 files changed, 153 insertions(+), 15 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index ae4777f..ed5699e 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -6,6 +6,7 @@
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
@@ -25,6 +26,9 @@
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.locks.ReentrantLock;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.yaml.snakeyaml.LoaderOptions;
@@ -50,6 +54,7 @@ public final class ArtifactStore {
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
private static final Set FILE_PERMISSIONS = Set.of(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
+ private static final ConcurrentMap DIRECTORY_LOCKS = new ConcurrentHashMap<>();
private final Path directory;
private final long maximumStoredBytes;
@@ -83,7 +88,10 @@ public ArtifactStore(Path directory) throws IOException {
this.afterPublishMove = afterPublishMove;
try {
createPrivateDirectory(this.directory);
- removeIncompleteUploads();
+ withDirectoryLock(() -> {
+ removeIncompleteUploads();
+ return null;
+ });
} catch (ArtifactException failure) {
throw failure;
} catch (IOException | RuntimeException failure) {
@@ -114,11 +122,13 @@ private void recoverEvictionTransactions() throws IOException {
String name = marker.getFileName().toString();
if (!name.matches("evict-[0-9a-f]{32}-[0-9a-f]{64}\\.(?:pending|committed)")) continue;
if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker)
- || Files.size(marker) < 1 || Files.size(marker) > 128) throw rejected();
+ || Files.size(marker) > 128) throw rejected();
String stagedName = Files.readString(marker, StandardCharsets.UTF_8);
- if (!stagedName.matches("upload-[A-Za-z0-9._-]+\\.part") || stagedName.contains("..")) throw rejected();
- Path staged = directory.resolve(stagedName);
- boolean stagedPresent = Files.exists(staged, LinkOption.NOFOLLOW_LINKS);
+ boolean legacyMarker = stagedName.isEmpty();
+ if (!legacyMarker && (!stagedName.matches("upload-[A-Za-z0-9._-]+\\.part")
+ || stagedName.contains(".."))) throw rejected();
+ Path staged = legacyMarker ? null : directory.resolve(stagedName);
+ boolean stagedPresent = staged != null && Files.exists(staged, LinkOption.NOFOLLOW_LINKS);
if (stagedPresent && (!Files.isRegularFile(staged, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(staged))) throw rejected();
String transaction = name.substring("evict-".length(), "evict-".length() + 32);
@@ -140,7 +150,8 @@ private void recoverEvictionTransactions() throws IOException {
} else {
if (Files.exists(incoming, LinkOption.NOFOLLOW_LINKS)) {
verifyExistingArtifact(incoming, incomingId);
- if (!stagedPresent) Files.delete(incoming);
+ boolean legacyCollision = legacyMarker && hasMatchingStagedUpload(files, incomingId);
+ if (!stagedPresent && !legacyCollision) Files.delete(incoming);
}
restoreQuarantined(quarantined);
}
@@ -150,6 +161,17 @@ private void recoverEvictionTransactions() throws IOException {
}
}
+ private boolean hasMatchingStagedUpload(List files, String incomingId) throws IOException {
+ for (Path candidate : files) {
+ String name = candidate.getFileName().toString();
+ if (!name.startsWith("upload-") || !name.endsWith(".part")
+ || !Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS)
+ || Files.isSymbolicLink(candidate) || Files.size(candidate) > MAX_UPLOAD_BYTES) continue;
+ if (hash(candidate).equals(incomingId)) return true;
+ }
+ return false;
+ }
+
/**
* Streams, verifies and publishes one JAR. When supplied, the claimed digest must
* match; otherwise the store establishes it. The returned identifier is the
@@ -168,6 +190,18 @@ public synchronized Artifact upload(InputStream source, String displayFilename,
|| protectedArtifactIds == null || protectedArtifactIds.stream().anyMatch(id -> !isSha256(id))) {
throw rejected();
}
+ try {
+ return withDirectoryLock(() -> uploadLocked(source, displayFilename, claimedSha256,
+ protectedArtifactIds));
+ } catch (ArtifactException failure) {
+ throw failure;
+ } catch (IOException | RuntimeException failure) {
+ throw rejected();
+ }
+ }
+
+ private Artifact uploadLocked(InputStream source, String displayFilename, String claimedSha256,
+ Set protectedArtifactIds) throws IOException {
Path temporary = null;
boolean published = false;
try {
@@ -188,15 +222,30 @@ public synchronized Artifact upload(InputStream source, String displayFilename,
List evictionPlan = planCapacity(digest.size(), protectedArtifactIds);
published = publishWithRollback(temporary, artifact, evictionPlan);
return new Artifact(actual, displayFilename, digest.size());
- } catch (ArtifactException failure) {
- throw failure;
- } catch (IOException | RuntimeException failure) {
- throw rejected();
} finally {
if (!published && temporary != null) deleteTemporary(temporary);
}
}
+ private T withDirectoryLock(IoSupplier operation) throws IOException {
+ ReentrantLock processLock = DIRECTORY_LOCKS.computeIfAbsent(directory, ignored -> new ReentrantLock());
+ processLock.lock();
+ try {
+ verifyDirectory();
+ Path lockPath = directory.resolve(".artifact-store.lock");
+ try (FileChannel channel = FileChannel.open(lockPath,
+ Set.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS));
+ FileLock ignored = channel.lock()) {
+ if (!Files.isRegularFile(lockPath, LinkOption.NOFOLLOW_LINKS)
+ || Files.isSymbolicLink(lockPath)) throw rejected();
+ setPermissions(lockPath, FILE_PERMISSIONS);
+ return operation.run();
+ }
+ } finally {
+ processLock.unlock();
+ }
+ }
+
private List planCapacity(long incomingBytes, Set protectedArtifactIds) throws IOException {
List stored = new ArrayList<>();
long bytes = 0;
@@ -601,4 +650,5 @@ private record DigestAndSize(String sha256, long size) { }
private record StoredFile(Path path, String artifactId, long size, long modified) { }
private record QuarantinedFile(Path original, Path backup) { }
@FunctionalInterface interface IoAction { void run(Path path) throws IOException; }
+ @FunctionalInterface private interface IoSupplier { T run() throws IOException; }
}
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 02f6017..ecf8ba9 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -17,6 +17,9 @@
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -43,7 +46,7 @@ class ArtifactStoreTest {
Path published = directory.resolve("artifacts").resolve(sha256 + ".jar");
assertTrue(Files.isRegularFile(published));
try (var entries = Files.list(directory.resolve("artifacts"))) {
- assertEquals(1, entries.count());
+ assertEquals(1, entries.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
}
}
@@ -55,7 +58,7 @@ class ArtifactStoreTest {
assertRejected(() -> store.upload(new ByteArrayInputStream(jar), "VotingPlugin.jar", "0".repeat(64)));
assertRejected(() -> store.upload(new ByteArrayInputStream(jar), "VotingPlugin.jar", actual.toUpperCase()));
try (var entries = Files.list(directory.resolve("artifacts"))) {
- assertEquals(0, entries.count());
+ assertEquals(0, entries.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
}
}
@@ -132,7 +135,7 @@ class ArtifactStoreTest {
};
assertRejected(() -> store.upload(oversized, "VotingPlugin.jar", "0".repeat(64)));
try (var entries = Files.list(directory.resolve("artifacts"))) {
- assertEquals(0, entries.count());
+ assertEquals(0, entries.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
}
}
@@ -254,6 +257,92 @@ class ArtifactStoreTest {
assertFalse(Files.exists(quarantine));
}
+ @Test void startupRecoversALegacyInterruptedEvictionMarker() throws Exception {
+ Path artifacts = directory.resolve("legacy-pending-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/New.class", new byte[] {2});
+ ArtifactStore store = new ArtifactStore(artifacts);
+ String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
+ String incomingId = sha256(incoming);
+ String transaction = "5".repeat(32);
+ Path quarantine = artifacts.resolve("evict-" + transaction + "-" + oldId + ".part");
+ Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
+ Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
+ Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending");
+ Files.createFile(marker);
+
+ ArtifactStore recovered = new ArtifactStore(artifacts);
+
+ assertArrayEquals(old, recovered.open(oldId).readAllBytes());
+ assertRejected(() -> recovered.open(incomingId));
+ assertFalse(Files.exists(quarantine));
+ assertFalse(Files.exists(marker));
+ }
+
+ @Test void legacyPendingCollisionRecoveryPreservesTheCompetingArtifact() throws Exception {
+ Path artifacts = directory.resolve("legacy-collision-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/New.class", new byte[] {2});
+ ArtifactStore store = new ArtifactStore(artifacts);
+ String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
+ String incomingId = sha256(incoming);
+ String transaction = "6".repeat(32);
+ Path quarantine = artifacts.resolve("evict-" + transaction + "-" + oldId + ".part");
+ Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
+ Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
+ Path staged = Files.write(artifacts.resolve("upload-legacy-collision.part"), incoming);
+ Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending");
+ Files.createFile(marker);
+
+ ArtifactStore recovered = new ArtifactStore(artifacts);
+
+ assertArrayEquals(old, recovered.open(oldId).readAllBytes());
+ assertArrayEquals(incoming, recovered.open(incomingId).readAllBytes());
+ assertFalse(Files.exists(staged));
+ assertFalse(Files.exists(quarantine));
+ assertFalse(Files.exists(marker));
+ }
+
+ @Test void uploadsSharingADirectorySerializeCapacityPlanningAndPublication() throws Exception {
+ Path artifacts = directory.resolve("shared-artifacts");
+ CountDownLatch firstPublishing = new CountDownLatch(1);
+ CountDownLatch releaseFirst = new CountDownLatch(1);
+ CountDownLatch secondPublishing = new CountDownLatch(1);
+ ArtifactStore first = new ArtifactStore(artifacts, 1_000_000, 1, path -> {
+ firstPublishing.countDown();
+ try {
+ if (!releaseFirst.await(5, TimeUnit.SECONDS)) throw new IOException("Timed out awaiting test release");
+ } catch (InterruptedException failure) {
+ Thread.currentThread().interrupt();
+ throw new IOException(failure);
+ }
+ });
+ ArtifactStore second = new ArtifactStore(artifacts, 1_000_000, 1,
+ path -> secondPublishing.countDown());
+ byte[] firstJar = jar("name: VotingPlugin\n", "plugin/First.class", new byte[] {1});
+ byte[] secondJar = jar("name: VotingPlugin\n", "plugin/Second.class", new byte[] {2});
+
+ var executor = Executors.newFixedThreadPool(2);
+ try {
+ var firstUpload = executor.submit(() -> first.upload(
+ new ByteArrayInputStream(firstJar), "first.jar", sha256(firstJar)));
+ assertTrue(firstPublishing.await(5, TimeUnit.SECONDS));
+ var secondUpload = executor.submit(() -> second.upload(
+ new ByteArrayInputStream(secondJar), "second.jar", sha256(secondJar)));
+ assertFalse(secondPublishing.await(200, TimeUnit.MILLISECONDS));
+ releaseFirst.countDown();
+ firstUpload.get(5, TimeUnit.SECONDS);
+ secondUpload.get(5, TimeUnit.SECONDS);
+ } finally {
+ releaseFirst.countDown();
+ executor.shutdownNow();
+ }
+
+ try (var files = Files.list(artifacts)) {
+ assertEquals(1, files.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
+ }
+ }
+
@Test void startupPreservesACompetingArtifactAfterAnInterruptedPublishCollision() throws Exception {
Path artifacts = directory.resolve("collision-recovery-artifacts");
byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
@@ -289,8 +378,7 @@ class ArtifactStoreTest {
Path quarantine = artifacts.resolve("evict-" + transaction + "-" + oldId + ".part");
Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
- Files.writeString(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".committed"),
- "upload-committed.part");
+ Files.createFile(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".committed"));
ArtifactStore recovered = new ArtifactStore(artifacts);
From ee1af3d726d0ef89864b5abab91f0fe10232c258 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 14:57:23 -0600
Subject: [PATCH 09/30] Harden artifact publication and guided reads
---
README.md | 4 +++-
docs/control-management.md | 6 +++--
.../control/artifact/ArtifactStore.java | 13 ++++++----
.../domain/ConfigurationOperations.java | 1 +
.../control/domain/InMemoryNodeRegistry.java | 4 +++-
.../protocol/ManagedConfiguration.java | 1 +
src/main/resources/web/app.js | 24 +++++++++++++++----
.../domain/ConfigurationOperationsTest.java | 8 +++++++
.../domain/InMemoryNodeRegistryTest.java | 3 ++-
.../control/http/ControlHttpServerTest.java | 17 +++++++++----
10 files changed, 62 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 945f11c..6661279 100644
--- a/README.md
+++ b/README.md
@@ -174,8 +174,10 @@ Configuration is split into independently negotiated capabilities. `config.proxy
manages only an enrolled proxy's `bungeeconfig.yml`; it is not general proxy file access. The proxy file is strict,
bounded, redacted YAML with safe preview/approval, atomic publication, and a local backup. General proxy-file settings
are saved without a proxy reload, so the operation reports that a restart is needed to activate them. `config.quick-setup.v1` supplies standalone,
-proxy-backend, vote-site, easy-reward, common-settings, auto-create-vote-sites, vote-logging, vote-party, and typed
+proxy-backend, vote-site, easy-reward, common-settings, auto-create-vote-sites, vote-logging, and typed
reward-builder presets.
+Vote Party editing that includes the Enabled state requires `config.quick-setup.v2`; v1 nodes are never sent that
+extended proposal.
The auto-create preset owns only `AutoCreateVoteSites`; the logging preset owns only enabled state, purge retention
(`-1` disables purging or `1`–`3650` days), and main-MySQL reuse, never connection credentials. Readable presets load their
installed values before editing.
diff --git a/docs/control-management.md b/docs/control-management.md
index dbad334..755c3a8 100644
--- a/docs/control-management.md
+++ b/docs/control-management.md
@@ -42,6 +42,7 @@ Control accepts only the intersection with its own allow-list.
| `config.files.v1` | Bounded reads/previews/applies for managed Bukkit YAML files |
| `config.file-comments.v1` | Preserves Control-managed comment metadata where supported |
| `config.quick-setup.v1` | Typed guided settings and reward/site presets |
+| `config.quick-setup.v2` | Vote Party guided settings including revision-safe Enabled round trips |
| `config.vote-sites-sync.v1` | Reward-safe VoteSites merge from one backend to selected targets |
| `config.transport-test.v1` | Typed, bounded proxy-to-backend communication check |
| `config.proxy-method.v1` | Coordinated preview/apply and acknowledged runtime replacement for a supported network proxy method |
@@ -129,8 +130,9 @@ general setting activation still requires a proxy restart.
4. Nodes stage and atomically replace managed YAML, reload VotingPlugin, and restore the local `.control-backup` if reload
fails. The result distinguishes reload and rollback from a successful save.
-The proxy method preset validates and persists the requested `MYSQL`, `PLUGINMESSAGING`, `REDIS`, `MQTT`, or `SOCKETS`
-method on the proxy and its reported backends. The proxy acknowledges its durable result before replacing its runtime, so
+The proxy method preset validates and persists the requested `MYSQL`, `PLUGINMESSAGING`, `REDIS`, `MQTT`, `SOCKETS`, or
+`HTTP` method on the proxy and its reported backends. `HTTP` requires every target to negotiate
+`config.proxy-method.v2`; the other methods use `config.proxy-method.v1`. The proxy acknowledges its durable result before replacing its runtime, so
the operation result cannot be lost during teardown. Backends reload only their proxy communication handler. If a durable
write or backend handler reload fails, its local backup is restored and the operation reports the failed/rolled-back state
rather than a false network-wide success.
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index ed5699e..79bbbae 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -519,11 +519,18 @@ private static boolean declaresVotingPlugin(byte[] pluginYml) throws IOException
private boolean publish(Path temporary, Path artifact) throws IOException {
try {
beforePublishMove.run(artifact);
- moveWithoutReplacing(temporary, artifact);
+ Files.createLink(artifact, temporary);
} catch (java.nio.file.FileAlreadyExistsException collision) {
verifyExistingArtifact(artifact, artifact.getFileName().toString().substring(0, 64));
return false;
}
+ try {
+ Files.delete(temporary);
+ } catch (IOException failure) {
+ try { Files.deleteIfExists(artifact); }
+ catch (IOException rollbackFailure) { failure.addSuppressed(rollbackFailure); }
+ throw failure;
+ }
return true;
}
@@ -546,10 +553,6 @@ private static void move(Path source, Path target, boolean replace) throws IOExc
}
}
- private static void moveWithoutReplacing(Path source, Path target) throws IOException {
- Files.move(source, target);
- }
-
private void verifyExistingArtifact(Path artifact, String expectedHash) throws IOException {
if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)
|| Files.size(artifact) > MAX_UPLOAD_BYTES || !hash(artifact).equals(expectedHash)) throw rejected();
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index ee96058..b4c1d03 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -30,6 +30,7 @@ public final class ConfigurationOperations implements AutoCloseable {
public static final String FILE_CAPABILITY = "config.files.v1";
public static final String PROXY_FILE_CAPABILITY = "config.proxy-files.v1";
public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1";
+ public static final String QUICK_SETUP_VOTE_PARTY_CAPABILITY = "config.quick-setup.v2";
public static final String VOTE_SITES_SYNC_CAPABILITY = "config.vote-sites-sync.v1";
public static final String TRANSPORT_TEST_CAPABILITY = "config.transport-test.v1";
public static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1";
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java
index 441e45f..3608737 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java
@@ -28,7 +28,9 @@ public final class InMemoryNodeRegistry implements NodeRegistry {
public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot",
ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY,
ConfigurationOperations.PROXY_FILE_CAPABILITY,
- ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY,
+ ConfigurationOperations.QUICK_SETUP_CAPABILITY,
+ ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY,
+ ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY,
ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY,
ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY,
"config.file-comments.v1", InspectionQuery.CAPABILITY);
diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
index 29889d1..5159db0 100644
--- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
+++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
@@ -137,6 +137,7 @@ public String capability() {
? "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" : "config.proxy-method.v1"
: "proxy-backend".equals(preset) && "HTTP".equals(options.get("method"))
? "config.proxy-method.v2"
+ : "vote-party".equals(preset) ? "config.quick-setup.v2"
: "config.quick-setup.v1";
default -> throw new IllegalStateException("unsupported configuration domain");
};
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index 00a5182..e8d8abc 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -1334,6 +1334,7 @@ function friendlyCapability(capability) {
'config.proxy-method.v1': 'Proxy method',
'config.proxy-method.v2': 'Proxy method · HTTP',
'config.quick-setup.v1': 'Setup assistant',
+ 'config.quick-setup.v2': 'Vote Party setup',
'config.proxy-routing.v1': 'Proxy routing',
'data.inspect.v1': 'Read-only data inspection'
})[capability];
@@ -1412,7 +1413,7 @@ function nodeCard(node) {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1',
- 'config.quick-setup.v1', 'config.proxy-method.v2']
+ 'config.quick-setup.v1', 'config.quick-setup.v2', 'config.proxy-method.v2']
.some(capability => node.acceptedCapabilities.includes(capability));
checkbox.disabled = !node.online || !controllable || node.nodeId === selectedServerId;
checkbox.checked = selectedNodes.has(node.nodeId);
@@ -1491,7 +1492,8 @@ function updateHeaderAction(tab) {
: tab === 'network' ? runNetworkDoctor.disabled
: tab === 'configurations' ? runDriftCheck.disabled
: tab === 'data' ? refreshDataOverview.disabled
- : tab === 'quick-setup' ? !nodeCapabilities.get(selectedServerId)?.includes('config.quick-setup.v1')
+ : tab === 'quick-setup' ? !nodeCapabilities.get(selectedServerId)?.some(capability =>
+ capability === 'config.quick-setup.v1' || capability === 'config.quick-setup.v2')
: false;
headerAction.disabled = !authenticated || unavailable;
}
@@ -2933,7 +2935,8 @@ function backendQuickTargets() {
function quickSetupCapability() {
return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP'
- ? 'config.proxy-method.v2' : 'config.quick-setup.v1';
+ ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party'
+ ? 'config.quick-setup.v2' : 'config.quick-setup.v1';
}
function quickSetupTargets() {
@@ -3314,6 +3317,8 @@ async function waitForOperation(operation, statusElement = operationStatus, cont
&& Object.values(operation.results || {}).some(result => result?.success);
if (applied) {
invalidateConfigurationReads();
+ invalidateGuidedSetupReads();
+ if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0);
if (tabFromHash() === 'overview') {
text(dataOverview, 'Configuration changed; refreshing server overview…');
window.setTimeout(() => void autoLoadTab('overview'), 0);
@@ -3492,7 +3497,7 @@ async function loadNodesOnce() {
? node.detectedPlugins : []]));
const selectedCapabilitiesChanged = [...selectedNodes].some(node =>
['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1',
- 'data.inspect.v1'].some(capability =>
+ 'config.quick-setup.v2', 'data.inspect.v1'].some(capability =>
Boolean(previousCapabilities.get(node)?.includes(capability)) !==
Boolean(nodeCapabilities.get(node)?.includes(capability))));
if (selectedCapabilitiesChanged) {
@@ -4064,7 +4069,7 @@ async function loadQuickSetupValues(automatic = false, preserveDirty = false) {
}
const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId
&& pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : null;
- const selectedProxyMethod = preset === 'proxy-backend' ? quickMethod.value : null;
+ const selectedProxyMethod = preserveDirty && preset === 'proxy-backend' ? quickMethod.value : null;
const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null;
populateQuickState(result.configuration.options);
if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod;
@@ -5070,6 +5075,7 @@ clearOperationHistory.addEventListener('click', loadOperationHistory);
quickPartyAll, quickPartyOnline, quickAutoSitesOnly, quickVoteLoggingEnabled, quickVoteLoggingDays,
quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', () => {
quickSetupDirty = true;
+ exposeDirtyVoteSiteReload();
clearApprovals();
}));
quickMethod.addEventListener('input', clearApprovals);
@@ -5079,6 +5085,7 @@ quickName.addEventListener('input', () => {
});
quickMethod.addEventListener('input', () => {
if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) {
+ quickSetupDirty = true;
if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration;
void autoLoadTab('quick-setup');
}
@@ -5086,6 +5093,7 @@ quickMethod.addEventListener('input', () => {
quickName.addEventListener('input', () => {
if (pendingDetectedVoteSite && pendingDetectedVoteSite.key !== quickName.value.trim()) pendingDetectedVoteSite = null;
updateQuickFields();
+ exposeDirtyVoteSiteReload();
if (voteSiteReadTimer != null) window.clearTimeout(voteSiteReadTimer);
if (quickPreset.value === 'vote-site' && quickPresetReadable()) {
voteSiteReadTimer = window.setTimeout(() => {
@@ -5094,6 +5102,12 @@ quickName.addEventListener('input', () => {
}, 300);
}
});
+
+function exposeDirtyVoteSiteReload() {
+ if (quickPreset.value !== 'vote-site' || !quickSetupDirty || quickSetupValuesLoaded()) return;
+ readQuickSetup.hidden = false;
+ text(quickOperationStatus, 'The vote-site key changed; load its current values to discard your unsaved edits.');
+}
configurationContent.addEventListener('input', () => {
if (!configurationDirty) {
configurationDraftNodeId = selectedServerId;
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index 4552a1c..66568a1 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -823,6 +823,14 @@ class ConfigurationOperationsTest {
null, List.of(), null, null, "standalone", Map.of("proposal", "x".repeat(501))));
}
+ @Test void votePartyEnabledRoundTripsRequireTheVersionedCapability() {
+ ManagedConfiguration voteParty = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "vote-party", Map.of("enabled", "false", "votesRequired", "20",
+ "broadcast", "", "giveAllPlayers", "false", "onlineOnly", "true", "command", ""));
+
+ assertEquals(ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY, voteParty.capability());
+ }
+
@Test void rewardBuilderResultDoesNotRetainOrEchoItsProposal() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java
index a9cc6e2..f00e0f9 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java
@@ -37,7 +37,8 @@ class InMemoryNodeRegistryTest {
@Test void registrationNegotiatesOptionalCommentPreservingFileSupport() {
Set capabilities = Set.of("config.files.v1", "config.file-comments.v1",
- ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY);
+ ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY,
+ ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY);
var result = registry.register(registration("backend-lobby", session, capabilities, Set.of("config.files.v1")));
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 9ea5cb8..f9b5f99 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -133,6 +133,8 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"),
"Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field.");
assertTrue(script.body().contains("config.proxy-method.v2"));
+ assertTrue(script.body().contains("quickPreset.value === 'vote-party'\n ? 'config.quick-setup.v2'"),
+ "Vote Party Enabled must never be sent under the incompatible v1 quick-setup contract.");
assertTrue(script.body().contains("function quickSetupTargets()"));
assertTrue(script.body().contains("nodeIds = quickSetupTargets()"));
assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()"));
@@ -145,9 +147,9 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("if (inputGeneration !== autoLoadGeneration) {\n autoLoadPending.add(tab);\n return;\n }"),
"A stale dedicated read must fence the remainder of the automatic quick-setup sequence.");
assertTrue(script.body().contains("quickMethod.addEventListener('input', () => {\n if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) {"));
- assertTrue(script.body().contains("if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration;\n void autoLoadTab('quick-setup');"),
+ assertTrue(script.body().contains("quickSetupDirty = true;\n if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration;\n void autoLoadTab('quick-setup');"),
"Changing a proxy-backend method must schedule a capability-correct reread.");
- assertTrue(script.body().contains("const selectedProxyMethod = preset === 'proxy-backend' ? quickMethod.value : null;"));
+ assertTrue(script.body().contains("const selectedProxyMethod = preserveDirty && preset === 'proxy-backend' ? quickMethod.value : null;"));
assertTrue(script.body().contains("if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod;"),
"The capability read must not overwrite the proxy method the operator selected for preview.");
assertTrue(script.body().contains("const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null;"));
@@ -159,12 +161,14 @@ class ControlHttpServerTest {
"Discarded automatic dedicated reads must request a fresh read rather than leave defaults previewable.");
assertTrue(script.body().contains("if (observedSuccessfulApply) {\n invalidateConfigurationReads();\n invalidateGuidedSetupReads();"),
"Observed external applies must invalidate the dedicated setup cards as well as the main editor.");
+ assertTrue(script.body().contains("if (applied) {\n invalidateConfigurationReads();\n invalidateGuidedSetupReads();"),
+ "Locally completed applies must invalidate guided reads as well as the main editor.");
assertTrue(script.body().contains("if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0);"),
"Invalidated dedicated settings must automatically reload while Quick Setup is visible.");
assertTrue(script.body().contains("nodeCapabilities.get(node)?.includes(quickSetupCapability())"),
"Quick approvals must remain valid only for their selected capability version.");
- assertTrue(script.body().contains("'config.quick-setup.v1', 'config.proxy-method.v2'"),
- "Secondary v2-only backends must remain selectable for HTTP setup.");
+ assertTrue(script.body().contains("'config.quick-setup.v1', 'config.quick-setup.v2', 'config.proxy-method.v2'"),
+ "Secondary versioned backends must remain selectable for HTTP and Vote Party setup.");
assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)"));
assertTrue(script.body().contains("!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded'"));
assertTrue(script.body().contains("Configuration changed elsewhere; your unsaved guided edits were preserved."),
@@ -172,6 +176,11 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("dedicatedSetupDirty.add(preset);"));
assertTrue(script.body().contains("if (quickPreset.value !== 'vote-site') quickSetupDirty = true;"),
"The shared name field is a selector for vote sites but a dirty editable value for other presets.");
+ assertTrue(script.body().contains("function exposeDirtyVoteSiteReload()"));
+ assertTrue(script.body().contains("quickSetupDirty = true;\n exposeDirtyVoteSiteReload();"),
+ "Becoming dirty during the selector debounce must also expose reload.");
+ assertTrue(script.body().contains("The vote-site key changed; load its current values to discard your unsaved edits."),
+ "A dirty vote-site selector transition must expose an explicit discard and reload action.");
assertTrue(script.body().contains("const profileName = profilePicker.value;"));
assertTrue(script.body().contains("profilePicker.value !== profileName || !currentProfile || JSON.stringify(currentProfile) !== profileSignature"),
"Profile application must verify its selection after waiting for live values.");
From 5d0fa7c845cc2ce325fc4e406277ae3835881f8d Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:33:20 -0600
Subject: [PATCH 10/30] Harden artifact recovery and configuration state
---
AGENTS.md | 15 +++++--
.../control/artifact/ArtifactStore.java | 3 +-
.../protocol/ManagedConfiguration.java | 2 +-
src/main/resources/web/app.js | 39 ++++++++++++++-----
.../control/artifact/ArtifactStoreTest.java | 25 ++++++++++++
.../domain/ConfigurationOperationsTest.java | 3 ++
.../control/http/ControlHttpServerTest.java | 22 ++++++++---
7 files changed, 89 insertions(+), 20 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index be530aa..293b3a1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -134,10 +134,17 @@ when the pieces are independently deployable or need materially different review
Protocol version `1` describes the registration/heartbeat resource protocol. Feature evolution normally uses a new
capability such as `data.inspect.v1`; do not bump the whole protocol for an optional additive feature.
-`config.proxy-method.v1` persists a validated method on the proxy and reported backends. Backends reload only their proxy
-communication handler. The proxy acknowledges the durable result before its existing guarded runtime replacement, so
-teardown cannot lose the completion. A failed backend reload must restore its backup. Control availability or a method
-switch must never delay or prevent normal proxy or backend startup.
+`config.proxy-method.v1` persists the legacy plugin-messaging or Redis method on the proxy and reported backends;
+`config.proxy-method.v2` adds HTTP. Control must select the exact capability required by the requested method. Backends
+reload only their proxy communication handler. The proxy acknowledges the durable result before its existing guarded
+runtime replacement, so teardown cannot lose the completion. A failed backend reload must restore its backup. Control
+availability or a method switch must never delay or prevent normal proxy or backend startup.
+
+`config.quick-setup.v2` adds `VoteParty.Enabled` to the versioned quick-setup contract. Control must keep legacy
+Vote Party payloads on `config.quick-setup.v1` and require v2 before sending or accepting the `enabled` field. The
+VotingPlugin connector change may deploy first and remain unused until Control negotiates v2; deploying Control first
+must leave v2 actions unavailable on older nodes. Merge the VotingPlugin capability implementation before relying on the
+new Control behavior in production.
## Safe change checklist
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 79bbbae..f3dcdf6 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -151,7 +151,8 @@ private void recoverEvictionTransactions() throws IOException {
if (Files.exists(incoming, LinkOption.NOFOLLOW_LINKS)) {
verifyExistingArtifact(incoming, incomingId);
boolean legacyCollision = legacyMarker && hasMatchingStagedUpload(files, incomingId);
- if (!stagedPresent && !legacyCollision) Files.delete(incoming);
+ boolean interruptedPublication = stagedPresent && Files.isSameFile(staged, incoming);
+ if (interruptedPublication || (!stagedPresent && !legacyCollision)) Files.delete(incoming);
}
restoreQuarantined(quarantined);
}
diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
index 5159db0..92fee5a 100644
--- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
+++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java
@@ -137,7 +137,7 @@ public String capability() {
? "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" : "config.proxy-method.v1"
: "proxy-backend".equals(preset) && "HTTP".equals(options.get("method"))
? "config.proxy-method.v2"
- : "vote-party".equals(preset) ? "config.quick-setup.v2"
+ : "vote-party".equals(preset) && options.containsKey("enabled") ? "config.quick-setup.v2"
: "config.quick-setup.v1";
default -> throw new IllegalStateException("unsupported configuration domain");
};
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index e8d8abc..54bc0fd 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -1512,9 +1512,12 @@ function setActiveTab(tab, updateHash = false) {
}
function openWorkspace(tab, scrollTarget = '', preset = '', navigationButton = null) {
- if (preset) {
+ if (preset && quickPreset.value !== preset) {
quickPreset.value = preset;
loadedQuickSetup = null;
+ quickSetupDirty = false;
+ quickSetupPreserveReadGeneration = -1;
+ pendingDetectedVoteSite = null;
updateQuickFields();
clearApprovals();
}
@@ -2935,10 +2938,19 @@ function backendQuickTargets() {
function quickSetupCapability() {
return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP'
- ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party'
+ ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party' && votePartyUsesV2()
? 'config.quick-setup.v2' : 'config.quick-setup.v1';
}
+function votePartyUsesV2() {
+ const selectedBackends = [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId)
+ && isBackend(nodeIndex.get(nodeId))
+ && (nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1')
+ || nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2')));
+ return selectedBackends.length > 0
+ && selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2'));
+}
+
function quickSetupTargets() {
return targets(quickSetupCapability())
.filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId)));
@@ -3937,15 +3949,15 @@ applyFileConfiguration.addEventListener('click', async () => {
|| !window.confirm(`Apply this exact ${configurationFile.value} preview to ${fileTargetDescription()}?`)) return;
const approval = approvedFilePreview;
approvedFilePreview = null;
- // Applying claims one generation; the successful-read invalidation claims the next.
- const expectedApplyGeneration = inputGeneration + 2;
+ const submittedContent = configurationContent.value;
try {
const operation = await startConfigurationOperation('/api/v1/configuration/apply', {
previewOperationId: approval.operationId, approvalToken: approval.approvalToken
}, fileOperationStatus);
const currentFileTargets = fileTargetsForSelection(configurationFile.value);
- const submittedContextStillCurrent = inputGeneration === expectedApplyGeneration
- && approval.fileName === configurationFile.value
+ const submittedContextStillCurrent = approval.fileName === configurationFile.value
+ && configurationContent.value === submittedContent
+ && fileDraftMatchesCurrentContext()
&& currentFileTargets.length === approval.nodeIds.length
&& approval.nodeIds.every(nodeId => currentFileTargets.includes(nodeId)
&& approval.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId);
@@ -3988,10 +4000,12 @@ function quickOptions() {
disableNoServiceSiteMessage: String(quickHideSiteWarning.checked),
disableUpdateChecking: String(quickDisableUpdates.checked)
};
- return {enabled: String(quickPartyEnabled.checked), votesRequired: quickPartyVotes.value,
+ const voteParty = {votesRequired: quickPartyVotes.value,
command: quickPartyCommand.value.trim(),
broadcast: quickPartyBroadcast.value.trim(), giveAllPlayers: String(quickPartyAll.checked),
onlineOnly: String(quickPartyOnline.checked)};
+ if (quickSetupCapability() === 'config.quick-setup.v2') voteParty.enabled = String(quickPartyEnabled.checked);
+ return voteParty;
}
function quickReadOptions() {
@@ -3999,7 +4013,11 @@ function quickReadOptions() {
}
function quickReadConfigurationOptions() {
- return quickPreset.value === 'proxy-backend' ? {method: quickMethod.value} : quickReadOptions();
+ if (quickPreset.value === 'proxy-backend') return {method: quickMethod.value};
+ if (quickPreset.value === 'vote-party' && quickSetupCapability() === 'config.quick-setup.v2') {
+ return {enabled: String(quickPartyEnabled.checked)};
+ }
+ return quickReadOptions();
}
function populateQuickState(options) {
@@ -4029,7 +4047,10 @@ function populateQuickState(options) {
quickVoteLoggingDays.value = options.purgeDays || '30';
quickVoteLoggingMainMysql.checked = options.useMainMySQL !== 'false';
} else if (quickPreset.value === 'vote-party') {
- quickPartyEnabled.checked = options.enabled === 'true';
+ const enabledAvailable = Object.hasOwn(options, 'enabled');
+ quickPartyEnabled.checked = enabledAvailable && options.enabled === 'true';
+ quickPartyEnabled.indeterminate = !enabledAvailable;
+ quickPartyEnabled.disabled = !enabledAvailable;
quickPartyVotes.value = options.votesRequired || '20';
quickPartyBroadcast.value = options.broadcast || '';
quickPartyAll.checked = options.giveAllPlayers === 'true';
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index ecf8ba9..e853622 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -367,6 +367,31 @@ class ArtifactStoreTest {
assertFalse(Files.exists(marker));
}
+ @Test void startupRollsBackAHardLinkedPublicationBeforeCommit() throws Exception {
+ Path artifacts = directory.resolve("hard-link-recovery-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {2});
+ ArtifactStore store = new ArtifactStore(artifacts);
+ String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
+ String incomingId = sha256(incoming);
+ String transaction = "7".repeat(32);
+ Path quarantine = artifacts.resolve("evict-" + transaction + "-" + oldId + ".part");
+ Files.move(artifacts.resolve(oldId + ".jar"), quarantine);
+ Path staged = Files.write(artifacts.resolve("upload-linked.part"), incoming);
+ Path published = artifacts.resolve(incomingId + ".jar");
+ Files.createLink(published, staged);
+ Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending");
+ Files.writeString(marker, staged.getFileName().toString());
+
+ ArtifactStore recovered = new ArtifactStore(artifacts);
+
+ assertArrayEquals(old, recovered.open(oldId).readAllBytes());
+ assertRejected(() -> recovered.open(incomingId));
+ assertFalse(Files.exists(staged));
+ assertFalse(Files.exists(quarantine));
+ assertFalse(Files.exists(marker));
+ }
+
@Test void startupFinishesACommittedEvictionWithoutRestoringOldArtifacts() throws Exception {
Path artifacts = directory.resolve("committed-artifacts");
byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index 66568a1..e421f2d 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -829,6 +829,9 @@ class ConfigurationOperationsTest {
"broadcast", "", "giveAllPlayers", "false", "onlineOnly", "true", "command", ""));
assertEquals(ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY, voteParty.capability());
+ ManagedConfiguration legacyVoteParty = new ManagedConfiguration("quick-setup", null, null,
+ null, null, "vote-party", Map.of("votesRequired", "20"));
+ assertEquals(ConfigurationOperations.QUICK_SETUP_CAPABILITY, legacyVoteParty.capability());
}
@Test void rewardBuilderResultDoesNotRetainOrEchoItsProposal() throws Exception {
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index f9b5f99..316417c 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -128,13 +128,19 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("readFileConfiguration.hidden = false;"));
assertTrue(script.body().contains("readQuickSetup.hidden = false;"));
assertTrue(script.body().contains("if (!quickSetupValuesLoaded()) {\n text(quickOperationStatus, 'The server or setup changed while reading."));
- assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)"));
- assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'"));
+ assertTrue(script.body().contains("voteParty.enabled = String(quickPartyEnabled.checked)"));
+ assertTrue(script.body().contains("quickPartyEnabled.checked = enabledAvailable && options.enabled === 'true'"));
assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"),
"Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field.");
assertTrue(script.body().contains("config.proxy-method.v2"));
- assertTrue(script.body().contains("quickPreset.value === 'vote-party'\n ? 'config.quick-setup.v2'"),
+ assertTrue(script.body().contains("quickPreset.value === 'vote-party' && votePartyUsesV2()\n"
+ + " ? 'config.quick-setup.v2'"),
+ "Vote Party must use v2 only when every selected backend supports it.");
+ assertTrue(script.body().contains("if (quickSetupCapability() === 'config.quick-setup.v2') voteParty.enabled"),
"Vote Party Enabled must never be sent under the incompatible v1 quick-setup contract.");
+ assertTrue(script.body().contains("quickPartyEnabled.indeterminate = !enabledAvailable;\n"
+ + " quickPartyEnabled.disabled = !enabledAvailable;"),
+ "A legacy read must represent Enabled as unavailable instead of leaking another server's value.");
assertTrue(script.body().contains("function quickSetupTargets()"));
assertTrue(script.body().contains("nodeIds = quickSetupTargets()"));
assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()"));
@@ -325,8 +331,9 @@ class ControlHttpServerTest {
"Activity refreshes must invalidate cached health after observing an external successful apply.");
assertTrue(script.body().contains("if (applied) {\n invalidateConfigurationReads();"),
"Locally completed applies must use the same cache invalidation path.");
- assertTrue(script.body().contains("const expectedApplyGeneration = inputGeneration + 2;"));
- assertTrue(script.body().contains("const submittedContextStillCurrent = inputGeneration === expectedApplyGeneration"));
+ assertTrue(script.body().contains("const submittedContent = configurationContent.value;"));
+ assertTrue(script.body().contains("const submittedContextStillCurrent = approval.fileName === configurationFile.value\n"
+ + " && configurationContent.value === submittedContent\n && fileDraftMatchesCurrentContext()"));
assertTrue(script.body().contains("approval.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId"),
"File apply completion must compare input generation, target scope, file, and node sessions.");
assertTrue(script.body().contains("The apply completed, but newer unsaved file edits remain. Preview again before applying them."),
@@ -730,6 +737,11 @@ class ControlHttpServerTest {
int activateAfterPreset = script.body().indexOf("setActiveTab(tab, true);", openWorkspace);
assertTrue(openWorkspace >= 0 && presetBeforeTab > openWorkspace && activateAfterPreset > presetBeforeTab,
"Nested shortcuts must establish their preset before tab autoload starts.");
+ assertTrue(script.body().contains("if (preset && quickPreset.value !== preset) {\n"
+ + " quickPreset.value = preset;\n loadedQuickSetup = null;\n"
+ + " quickSetupDirty = false;\n quickSetupPreserveReadGeneration = -1;\n"
+ + " pendingDetectedVoteSite = null;"),
+ "A shortcut replacing the preset must discard dirty state from the previous form before autoloading.");
assertTrue(script.body().contains("if (autoLoadInFlight.has(tab)) {\n autoLoadPending.add(tab);"));
assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);"),
"A preset change during an older read must queue a fresh autoload.");
From 12fc70838db9b0b52fba570c9e525137ad6b7109 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 17:18:12 -0600
Subject: [PATCH 11/30] Harden artifact upload and capability refresh
---
.../control/artifact/ArtifactStore.java | 27 ++++++++++++++++---
src/main/resources/web/app.js | 16 ++++++++---
.../control/artifact/ArtifactStoreTest.java | 10 +++++++
.../control/http/ControlHttpServerTest.java | 8 ++++++
4 files changed, 55 insertions(+), 6 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index f3dcdf6..3348eb9 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -321,16 +321,37 @@ private boolean publishWithRollback(Path temporary, Path artifact, List quarantined,
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index 54bc0fd..be68ee7 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -264,6 +264,7 @@ let transportTestBackendId = '';
let proxyMethodProxyId = '';
let proxyMethodCurrentFor = '';
let proxyMethodCurrentSessionId = '';
+let proxyMethodCurrentReadCapability = '';
let proxyMethodCurrentValue = '';
let nodeCapabilities = new Map();
let nodePlugins = new Map();
@@ -1257,6 +1258,7 @@ function applyAuthenticatedSession(body) {
proxyMethodProxyId = '';
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
fileReadCache.clear();
lastFileReadOperation = null;
@@ -2634,10 +2636,13 @@ function renderProxyMethod() {
}));
proxyMethodProxy.value = proxyMethodProxyId;
const network = proxyMethodReadNetwork();
+ const readCapability = proxyMethodReadCapability();
if (proxyMethodCurrentFor !== proxyMethodProxyId
- || proxyMethodCurrentSessionId !== (network.proxy?.sessionId || '')) {
+ || proxyMethodCurrentSessionId !== (network.proxy?.sessionId || '')
+ || proxyMethodCurrentReadCapability !== readCapability) {
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
}
const ready = network.proxyReady && network.topologyComplete && network.reported.length > 0 &&
@@ -2849,6 +2854,7 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) {
proxyMethodProxyId = '';
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
fileReadCache.clear();
text(networkDoctorResults, reason);
@@ -3108,6 +3114,7 @@ function discardAuthenticationState(reason) {
proxyMethodProxyId = '';
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
fileReadCache.clear();
lastFileReadOperation = null;
@@ -3509,7 +3516,7 @@ async function loadNodesOnce() {
? node.detectedPlugins : []]));
const selectedCapabilitiesChanged = [...selectedNodes].some(node =>
['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1',
- 'config.quick-setup.v2', 'data.inspect.v1'].some(capability =>
+ 'config.quick-setup.v2', 'config.proxy-method.v2', 'data.inspect.v1'].some(capability =>
Boolean(previousCapabilities.get(node)?.includes(capability)) !==
Boolean(nodeCapabilities.get(node)?.includes(capability))));
if (selectedCapabilitiesChanged) {
@@ -4015,7 +4022,7 @@ function quickReadOptions() {
function quickReadConfigurationOptions() {
if (quickPreset.value === 'proxy-backend') return {method: quickMethod.value};
if (quickPreset.value === 'vote-party' && quickSetupCapability() === 'config.quick-setup.v2') {
- return {enabled: String(quickPartyEnabled.checked)};
+ return {enabled: 'true'};
}
return quickReadOptions();
}
@@ -4269,6 +4276,7 @@ async function loadProxyMethod(automatic = false) {
|| sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId || result?.sessionId !== sessionId) return;
proxyMethodCurrentFor = proxyId;
proxyMethodCurrentSessionId = sessionId;
+ proxyMethodCurrentReadCapability = readCapability;
proxyMethodCurrentValue = method;
renderProxyMethod();
text(proxyMethodStatus, `Active method on ${proxyId}: ${method}`);
@@ -4283,6 +4291,7 @@ proxyMethodProxy.addEventListener('change', () => {
proxyMethodProxyId = proxyMethodProxy.value;
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
renderProxyMethod();
const network = proxyMethodReadNetwork();
@@ -4323,6 +4332,7 @@ proxyMethodButtons.forEach(button => button.addEventListener('click', async () =
if (applied.state === 'SUCCEEDED') {
proxyMethodCurrentFor = network.proxy.nodeId;
proxyMethodCurrentSessionId = network.proxy.sessionId;
+ proxyMethodCurrentReadCapability = proxyMethodReadCapability();
proxyMethodCurrentValue = method;
renderProxyMethod();
}
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index e853622..16f4ebd 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -99,6 +99,16 @@ class ArtifactStoreTest {
assertRejected(() -> store.upload(new ByteArrayInputStream(duplicate), "VotingPlugin.jar", sha256(duplicate)));
}
+ @Test void removesAnUnpublishedTemporaryTransactionMarkerOnStartup() throws Exception {
+ Path artifacts = directory.resolve("artifacts");
+ Files.createDirectories(artifacts);
+ Path incomplete = Files.writeString(artifacts.resolve("upload-marker-crashed.part"), "truncated");
+
+ new ArtifactStore(artifacts);
+
+ assertFalse(Files.exists(incomplete));
+ }
+
@Test void rejectsSymlinkedStorageAndExistingArtifactTargets() throws Exception {
Path real = directory.resolve("real");
Files.createDirectory(real);
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 316417c..4cd4559 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -175,6 +175,14 @@ class ControlHttpServerTest {
"Quick approvals must remain valid only for their selected capability version.");
assertTrue(script.body().contains("'config.quick-setup.v1', 'config.quick-setup.v2', 'config.proxy-method.v2'"),
"Secondary versioned backends must remain selectable for HTTP and Vote Party setup.");
+ assertTrue(script.body().contains("'config.quick-setup.v2', 'config.proxy-method.v2', 'data.inspect.v1'"),
+ "HTTP capability transitions must invalidate cached guided configuration reads.");
+ assertTrue(script.body().contains("return {enabled: 'true'};"),
+ "The v2 read selector must be a constant capability hint, not editable Vote Party state.");
+ assertTrue(script.body().contains("proxyMethodCurrentReadCapability !== readCapability"),
+ "The active proxy method must be invalidated when its v1/v2 read capability changes.");
+ assertTrue(script.body().contains("proxyMethodCurrentReadCapability = readCapability;"),
+ "Successful proxy reads must remember the exact capability used.");
assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)"));
assertTrue(script.body().contains("!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded'"));
assertTrue(script.body().contains("Configuration changed elsewhere; your unsaved guided edits were preserved."),
From 6fda5bbeecd597bba0372be2fe3370a7c267acad Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 17:43:45 -0600
Subject: [PATCH 12/30] Fix capability-aware guided settings state
---
src/main/resources/web/app.js | 5 ++++-
.../votingplugin/control/http/ControlHttpServerTest.java | 6 +++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index be68ee7..b7ce1b0 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -3520,6 +3520,7 @@ async function loadNodesOnce() {
Boolean(previousCapabilities.get(node)?.includes(capability)) !==
Boolean(nodeCapabilities.get(node)?.includes(capability))));
if (selectedCapabilitiesChanged) {
+ invalidateGuidedSetupReads();
approvedPreview = null;
approvedFilePreview = null;
approvedQuickPreview = null;
@@ -4054,7 +4055,9 @@ function populateQuickState(options) {
quickVoteLoggingDays.value = options.purgeDays || '30';
quickVoteLoggingMainMysql.checked = options.useMainMySQL !== 'false';
} else if (quickPreset.value === 'vote-party') {
- const enabledAvailable = Object.hasOwn(options, 'enabled');
+ const enabledAvailable = quickSetupCapability() === 'config.quick-setup.v2'
+ && nodeCapabilities.get(selectedServerId)?.includes('config.quick-setup.v2')
+ && Object.hasOwn(options, 'enabled');
quickPartyEnabled.checked = enabledAvailable && options.enabled === 'true';
quickPartyEnabled.indeterminate = !enabledAvailable;
quickPartyEnabled.disabled = !enabledAvailable;
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 4cd4559..7cc215e 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -183,6 +183,10 @@ class ControlHttpServerTest {
"The active proxy method must be invalidated when its v1/v2 read capability changes.");
assertTrue(script.body().contains("proxyMethodCurrentReadCapability = readCapability;"),
"Successful proxy reads must remember the exact capability used.");
+ assertTrue(script.body().contains("const enabledAvailable = quickSetupCapability() === 'config.quick-setup.v2'"),
+ "Vote Party Enabled availability must come from v2 negotiation, not a legacy response field.");
+ assertTrue(script.body().contains("if (selectedCapabilitiesChanged) {\n invalidateGuidedSetupReads();"),
+ "Capability transitions must invalidate cached guided reads.");
assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)"));
assertTrue(script.body().contains("!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded'"));
assertTrue(script.body().contains("Configuration changed elsewhere; your unsaved guided edits were preserved."),
@@ -378,7 +382,7 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("window.addEventListener('beforeunload'"));
assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset,"));
assertTrue(script.body().contains("configurationOperationsInFlight"));
- assertTrue(script.body().contains("if (selectedCapabilitiesChanged) {\n approvedPreview = null;"));
+ assertTrue(script.body().contains("if (selectedCapabilitiesChanged) {\n invalidateGuidedSetupReads();\n approvedPreview = null;"));
assertTrue(script.body().contains("approvedPreview.nodeIds.every"));
assertTrue(script.body().contains("selectedCapabilitiesChanged"));
assertTrue(script.body().contains("proxyFile ? !isProxy(restoreNode) : !isBackend(restoreNode)"));
From e6f685d2abf05c2f1554b130fdd534aa1afebf00 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 18:21:58 -0600
Subject: [PATCH 13/30] Enforce configuration and artifact transaction
contracts
---
.../control/artifact/ArtifactStore.java | 13 +++++++++++--
.../domain/ConfigurationOperations.java | 6 +++++-
.../domain/ConfigurationOperationsTest.java | 19 +++++++++++++++++++
3 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 3348eb9..acd0e1d 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -302,7 +302,7 @@ private boolean publishWithRollback(Path temporary, Path artifact, List qua
IOException failure = null;
if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
try {
- move(committed, pending, false);
+ moveAtomically(committed, pending);
DurableFiles.forceDirectory(directory);
} catch (IOException problem) {
return problem;
@@ -575,6 +575,15 @@ private static void move(Path source, Path target, boolean replace) throws IOExc
}
}
+ /** Transaction state changes must never silently degrade to a non-atomic move. */
+ private static void moveAtomically(Path source, Path target) throws IOException {
+ try {
+ Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
+ } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) {
+ throw new IOException("Artifact transaction marker transition is not atomic", unsupported);
+ }
+ }
+
private void verifyExistingArtifact(Path artifact, String expectedHash) throws IOException {
if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)
|| Files.size(artifact) > MAX_UPLOAD_BYTES || !hash(artifact).equals(expectedHash)) throw rejected();
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index b4c1d03..13d79b8 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -716,7 +716,11 @@ private static void validateResultConfiguration(StoredOperation operation, Confi
ManagedConfiguration expected = operation.configuration;
boolean mismatch = expected == null || !expected.domain().equals(actual.domain())
|| (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName()))
- || (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset()));
+ || (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset()))
+ // A legacy selector may omit fields which are only available in a newer
+ // capability. Never accept those fields as a v1 result: the stored
+ // operation capability is part of the selector contract.
+ || !expected.capability().equals(actual.capability());
if (mismatch) throw invalid("result configuration does not match the operation selector");
}
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index e421f2d..7e048bb 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -116,6 +116,25 @@ class ConfigurationOperationsTest {
assertEquals("false", read.results().get("lobby").configuration().options().get("processRewards"));
}
+ @Test void quickSetupReadRejectsAResultFromANewerCapability() throws Exception {
+ Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
+ InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
+ UUID session = UUID.randomUUID();
+ registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1,
+ Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY), Set.of()));
+ ConfigurationOperations operations = new ConfigurationOperations(registry,
+ new ConfigurationAuditLog(directory, clock), clock);
+ ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "vote-party", Map.of());
+ ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector);
+ ConfigurationTask task = operations.claim("lobby", session);
+ ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "vote-party", Map.of("enabled", "true"));
+ assertThrows(ValidationException.class, () -> operations.complete(read.operationId(), "lobby",
+ new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), v2,
+ List.of(), false, false, task.attemptId())));
+ }
+
@Test void voteLoggingAppliesAndRetriesAreSerializedPerTarget() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
From ad12ecdfc522ee46a525c03ca818512df16fa634 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 18:51:03 -0600
Subject: [PATCH 14/30] Allow reads to report active capability state
---
.../domain/ConfigurationOperations.java | 8 +++---
.../domain/ConfigurationOperationsTest.java | 25 +++++++++++++++++--
2 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index 13d79b8..f8a7de4 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -717,10 +717,10 @@ private static void validateResultConfiguration(StoredOperation operation, Confi
boolean mismatch = expected == null || !expected.domain().equals(actual.domain())
|| (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName()))
|| (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset()))
- // A legacy selector may omit fields which are only available in a newer
- // capability. Never accept those fields as a v1 result: the stored
- // operation capability is part of the selector contract.
- || !expected.capability().equals(actual.capability());
+ // READ reports installed state, whose active method can imply a newer
+ // capability than the deliberately minimal selector sent to the node.
+ // PREVIEW/APPLY still require exact capability agreement.
+ || (!"READ".equals(operation.type) && !expected.capability().equals(actual.capability()));
if (mismatch) throw invalid("result configuration does not match the operation selector");
}
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index 7e048bb..af5649f 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -116,7 +116,7 @@ class ConfigurationOperationsTest {
assertEquals("false", read.results().get("lobby").configuration().options().get("processRewards"));
}
- @Test void quickSetupReadRejectsAResultFromANewerCapability() throws Exception {
+ @Test void quickSetupReadAcceptsInstalledStateFromANewerCapability() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
UUID session = UUID.randomUUID();
@@ -130,8 +130,29 @@ class ConfigurationOperationsTest {
ConfigurationTask task = operations.claim("lobby", session);
ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
null, null, "vote-party", Map.of("enabled", "true"));
- assertThrows(ValidationException.class, () -> operations.complete(read.operationId(), "lobby",
+ read = operations.complete(read.operationId(), "lobby",
new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), v2,
+ List.of(), false, false, task.attemptId()));
+ assertEquals("SUCCEEDED", read.state());
+ assertEquals("true", read.results().get("lobby").configuration().options().get("enabled"));
+ }
+
+ @Test void quickSetupPreviewRejectsAResultFromANewerCapability() throws Exception {
+ Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
+ InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
+ UUID session = UUID.randomUUID();
+ registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1,
+ Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY), Set.of()));
+ ConfigurationOperations operations = new ConfigurationOperations(registry,
+ new ConfigurationAuditLog(directory, clock), clock);
+ ManagedConfiguration v1 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "vote-party", Map.of("votesRequired", "100"));
+ ConfigurationOperations.OperationView preview = operations.createPreview(List.of("lobby"), v1);
+ ConfigurationTask task = operations.claim("lobby", session);
+ ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "vote-party", Map.of("enabled", "true", "votesRequired", "100"));
+ assertThrows(ValidationException.class, () -> operations.complete(preview.operationId(), "lobby",
+ new ConfigurationTaskResult(session, true, "OK", "previewed", "a".repeat(64), v2,
List.of(), false, false, task.attemptId())));
}
From a38e68e4368c6355118b53c8c5bf126158fbad2b Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 19:11:01 -0600
Subject: [PATCH 15/30] Keep guided reads capability-safe
---
.../domain/ConfigurationOperations.java | 11 +++++---
src/main/resources/web/app.js | 4 +++
.../domain/ConfigurationOperationsTest.java | 27 ++++++++++++++++---
.../control/http/ControlHttpServerTest.java | 5 ++++
4 files changed, 39 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index f8a7de4..5ca87a6 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -717,13 +717,16 @@ private static void validateResultConfiguration(StoredOperation operation, Confi
boolean mismatch = expected == null || !expected.domain().equals(actual.domain())
|| (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName()))
|| (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset()))
- // READ reports installed state, whose active method can imply a newer
- // capability than the deliberately minimal selector sent to the node.
- // PREVIEW/APPLY still require exact capability agreement.
- || (!"READ".equals(operation.type) && !expected.capability().equals(actual.capability()));
+ || (!activeMethodRead(operation, expected) && !expected.capability().equals(actual.capability()));
if (mismatch) throw invalid("result configuration does not match the operation selector");
}
+ private static boolean activeMethodRead(StoredOperation operation, ManagedConfiguration expected) {
+ return "READ".equals(operation.type) && ManagedConfiguration.QUICK_SETUP.equals(expected.domain())
+ && (ManagedConfiguration.PROXY_METHOD.equals(expected.preset())
+ || "proxy-backend".equals(expected.preset()));
+ }
+
private String retainMessage(String message) {
int remaining = (int) Math.max(0, MAX_RETAINED_MESSAGE_BYTES - retainedMessageBytes);
String retained = truncateUtf8(message, remaining);
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index b7ce1b0..d928ea7 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -3310,6 +3310,7 @@ function invalidateConfigurationReads() {
configurationContent.value = '';
configurationContentPresent = false;
text(fileOperationStatus, 'Configuration changed; read the current file before previewing changes.');
+ if (tabFromHash() === 'configurations') window.setTimeout(() => void autoLoadTab('configurations'), 0);
}
lastOverview = null;
lastDiagnostics = null;
@@ -5084,6 +5085,9 @@ loadProfile.addEventListener('click', async () => {
applyProfileValues(profile);
quickSetupDirty = true;
inputGeneration++;
+ if (quickPreset.value === 'proxy-backend' && loadedQuickSetup) {
+ loadedQuickSetup = {...loadedQuickSetup, selector: JSON.stringify(quickReadConfigurationOptions())};
+ }
updateQuickFields();
clearApprovals();
text(profileStatus, `Loaded “${profileName}” over the confirmed live values. Preview before applying.`);
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index af5649f..df12b72 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -116,7 +116,7 @@ class ConfigurationOperationsTest {
assertEquals("false", read.results().get("lobby").configuration().options().get("processRewards"));
}
- @Test void quickSetupReadAcceptsInstalledStateFromANewerCapability() throws Exception {
+ @Test void proxyBackendReadAcceptsInstalledMethodFromANewerCapability() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
UUID session = UUID.randomUUID();
@@ -125,16 +125,16 @@ class ConfigurationOperationsTest {
ConfigurationOperations operations = new ConfigurationOperations(registry,
new ConfigurationAuditLog(directory, clock), clock);
ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
- null, null, "vote-party", Map.of());
+ null, null, "proxy-backend", Map.of());
ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector);
ConfigurationTask task = operations.claim("lobby", session);
ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
- null, null, "vote-party", Map.of("enabled", "true"));
+ null, null, "proxy-backend", Map.of("method", "HTTP"));
read = operations.complete(read.operationId(), "lobby",
new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), v2,
List.of(), false, false, task.attemptId()));
assertEquals("SUCCEEDED", read.state());
- assertEquals("true", read.results().get("lobby").configuration().options().get("enabled"));
+ assertEquals("HTTP", read.results().get("lobby").configuration().options().get("method"));
}
@Test void quickSetupPreviewRejectsAResultFromANewerCapability() throws Exception {
@@ -156,6 +156,25 @@ class ConfigurationOperationsTest {
List.of(), false, false, task.attemptId())));
}
+ @Test void votePartyReadRejectsAResultFromAnUnnegotiatedCapability() throws Exception {
+ Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
+ InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
+ UUID session = UUID.randomUUID();
+ registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1,
+ Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY), Set.of()));
+ ConfigurationOperations operations = new ConfigurationOperations(registry,
+ new ConfigurationAuditLog(directory, clock), clock);
+ ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "vote-party", Map.of());
+ ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector);
+ ConfigurationTask task = operations.claim("lobby", session);
+ ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "vote-party", Map.of("enabled", "true"));
+ assertThrows(ValidationException.class, () -> operations.complete(read.operationId(), "lobby",
+ new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), v2,
+ List.of(), false, false, task.attemptId())));
+ }
+
@Test void voteLoggingAppliesAndRetriesAreSerializedPerTarget() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 7cc215e..6fe9c28 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -204,6 +204,10 @@ class ControlHttpServerTest {
"Profile application must verify its selection after waiting for live values.");
assertTrue(script.body().contains("selector: JSON.stringify(quickReadConfigurationOptions())"),
"The retained selector must reflect the method returned by the live backend read.");
+ assertTrue(script.body().contains("loadedQuickSetup = {...loadedQuickSetup, selector: JSON.stringify(quickReadConfigurationOptions())};"),
+ "Applying a proxy profile must rebind the confirmed read cache to its restored method.");
+ assertTrue(script.body().contains("if (tabFromHash() === 'configurations') window.setTimeout(() => void autoLoadTab('configurations'), 0);"),
+ "A clean active YAML editor must automatically reload after apply invalidates its cache.");
assertTrue(web.body().contains("Add a simple vote reward"));
assertTrue(web.body().contains("First-run setup"));
assertTrue(web.body().contains("Node enrollment"));
@@ -328,6 +332,7 @@ class ControlHttpServerTest {
+ " if (!configurationDirty) {\n"
+ " configurationContent.value = '';\n configurationContentPresent = false;\n"
+ " text(fileOperationStatus, 'Configuration changed; read the current file before previewing changes.');\n"
+ + " if (tabFromHash() === 'configurations') window.setTimeout(() => void autoLoadTab('configurations'), 0);\n"
+ " }\n lastOverview = null;\n lastDiagnostics = null;\n"
+ " dashboardConfigurationGeneration++;\n invalidateDashboardInspection();"),
"Every successful apply must invalidate file and dashboard reads even after the view context changes.");
From 1e183c340894f9cbc393a3884921f4b84359b645 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 19:22:33 -0600
Subject: [PATCH 16/30] Require atomic artifact quarantine moves
---
.../control/artifact/ArtifactStore.java | 14 ++------------
1 file changed, 2 insertions(+), 12 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index acd0e1d..b457f38 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -289,7 +289,7 @@ private boolean publishWithRollback(Path temporary, Path artifact, List quarantined) throws IOExce
verifyExistingArtifact(file.original(), file.original().getFileName().toString().substring(0, 64));
Files.delete(file.backup());
} else {
- move(file.backup(), file.original(), false);
+ moveAtomically(file.backup(), file.original());
}
}
}
@@ -565,16 +565,6 @@ private void finishPublishedArtifact(Path artifact) throws IOException {
DurableFiles.forceDirectory(directory);
}
- private static void move(Path source, Path target, boolean replace) throws IOException {
- try {
- if (replace) Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
- else Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
- } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) {
- if (replace) Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
- else Files.move(source, target);
- }
- }
-
/** Transaction state changes must never silently degrade to a non-atomic move. */
private static void moveAtomically(Path source, Path target) throws IOException {
try {
From 6057dbd6d70bc75cbd017a9047c009675c747a75 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 20:08:43 -0600
Subject: [PATCH 17/30] Harden artifact recovery and settings refresh
---
.../control/artifact/ArtifactStore.java | 40 +++++++++++++------
src/main/resources/web/app.js | 23 +++++++++++
.../control/artifact/ArtifactStoreTest.java | 15 ++++---
.../control/http/ControlHttpServerTest.java | 15 +++++++
4 files changed, 74 insertions(+), 19 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index b457f38..8f47db8 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -144,16 +144,29 @@ private void recoverEvictionTransactions() throws IOException {
quarantined.add(new QuarantinedFile(artifactPath(artifactId), candidate));
}
Path incoming = artifactPath(incomingId);
- if (committed) {
+ boolean pendingCollision = false;
+ if (!committed && Files.exists(incoming, LinkOption.NOFOLLOW_LINKS)) {
verifyExistingArtifact(incoming, incomingId);
- for (QuarantinedFile file : quarantined) Files.delete(file.backup());
- } else {
- if (Files.exists(incoming, LinkOption.NOFOLLOW_LINKS)) {
- verifyExistingArtifact(incoming, incomingId);
+ if (stagedPresent) {
+ // A pending marker normally rolls back a partially published hard link.
+ // If the verified staged upload is a distinct file, however, publish()
+ // lost a content-address collision to another verified publisher. That
+ // publication is durable and its planned evictions must be committed.
+ verifyExistingArtifact(staged, incomingId);
+ pendingCollision = !Files.isSameFile(staged, incoming);
+ if (!pendingCollision) Files.delete(incoming);
+ } else {
boolean legacyCollision = legacyMarker && hasMatchingStagedUpload(files, incomingId);
- boolean interruptedPublication = stagedPresent && Files.isSameFile(staged, incoming);
- if (interruptedPublication || (!stagedPresent && !legacyCollision)) Files.delete(incoming);
+ if (!legacyCollision) Files.delete(incoming);
}
+ }
+ if (committed || pendingCollision) {
+ if (committed) verifyExistingArtifact(incoming, incomingId);
+ // A verified competing publication is equivalent to the commit point: keeping
+ // the quarantined artifacts would violate the configured capacity bound.
+ // The staged upload is removed by removeIncompleteUploads after this marker.
+ for (QuarantinedFile file : quarantined) Files.delete(file.backup());
+ } else {
restoreQuarantined(quarantined);
}
DurableFiles.forceDirectory(directory);
@@ -295,15 +308,16 @@ private boolean publishWithRollback(Path temporary, Path artifact, List {
+ const previousQuickCapability = quickSetupCapability();
if (checkbox.checked && selectedNodes.size >= MAX_CONFIGURATION_TARGETS) {
checkbox.checked = false;
text(operationStatus, `At most ${MAX_CONFIGURATION_TARGETS} servers can be configured at once.`);
@@ -1432,6 +1433,7 @@ function nodeCard(node) {
approvedQuickPreview = null;
dedicatedSetupApprovals.clear();
inputGeneration++;
+ reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability);
updatePluginSuggestions();
renderSelectedServer();
updateConfigurationButtons();
@@ -2957,6 +2959,20 @@ function votePartyUsesV2() {
&& selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2'));
}
+function reloadVotePartyWhenTargetCapabilityChanges(previousCapability, scheduleReload = true) {
+ if (quickPreset.value !== 'vote-party' || previousCapability === quickSetupCapability()) return;
+ loadedQuickSetup = null;
+ quickSetupDirty = false;
+ approvedQuickPreview = null;
+ populateQuickState({});
+ text(quickOperationStatus, 'Selected backend capabilities changed. Loading confirmed Vote Party settings…');
+ updateConfigurationButtons();
+ // Funnel capability transitions through the tab's single-flight autoloader.
+ // A rapid v2/v1/v2 change therefore marks one follow-up read instead of
+ // starting overlapping READ operations that can race to populate the form.
+ if (scheduleReload && tabFromHash() === 'quick-setup') void autoLoadTab('quick-setup');
+}
+
function quickSetupTargets() {
return targets(quickSetupCapability())
.filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId)));
@@ -3496,6 +3512,7 @@ async function loadNodesOnce() {
text(message, 'Loading…');
try {
const registry = await loadAllNodes();
+ const previousQuickCapability = quickSetupCapability();
const previousNodeIndex = nodeIndex;
allNodeItems = registry.items;
nodePageMetadata = registry.pageMetadata;
@@ -3579,6 +3596,10 @@ async function loadNodesOnce() {
text(operationStatus, routingDraftStatus('The selected nodes changed during refresh. Preview again before apply.'));
}
selectedNodes = filteredSelection;
+ // A registry refresh can change the effective Vote Party contract without a
+ // user selection event. Clear the old v2/v1 form before the normal tab
+ // auto-load runs so a delayed or failed READ cannot expose stale values.
+ reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability, false);
renderNodeViews();
updatePluginSuggestions();
updateConfigurationButtons();
@@ -4473,6 +4494,7 @@ loadAutoSites.addEventListener('click', () => loadDedicatedSetup('auto-create-vo
previewAutoSites.addEventListener('click', () => previewDedicatedSetup('auto-create-vote-sites'));
applyAutoSites.addEventListener('click', () => applyDedicatedSetup('auto-create-vote-sites'));
selectAllAutoSitesTargets.addEventListener('click', () => {
+ const previousQuickCapability = quickSetupCapability();
const available = allNodeItems.filter(node => isBackend(node) && node.online
&& node.acceptedCapabilities.includes('config.quick-setup.v1'));
const candidates = available
@@ -4484,6 +4506,7 @@ selectAllAutoSitesTargets.addEventListener('click', () => {
approvedFilePreview = null;
approvedQuickPreview = null;
inputGeneration++;
+ reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability);
renderNodeViews();
updatePluginSuggestions();
updateConfigurationButtons();
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 16f4ebd..74f3c6d 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -199,7 +199,7 @@ class ArtifactStoreTest {
assertRejected(() -> store.open(secondId));
}
- @Test void publicationCollisionRestoresPlannedEvictionsAndRemovesTheStagedUpload() throws Exception {
+ @Test void publicationCollisionCommitsPlannedEvictionsAndRemovesTheStagedUpload() throws Exception {
Path artifacts = directory.resolve("collision-artifacts");
byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {2});
@@ -215,7 +215,7 @@ class ArtifactStoreTest {
ArtifactStore.Artifact duplicate = store.upload(new ByteArrayInputStream(incoming), "incoming.jar", incomingId);
assertEquals(incomingId, duplicate.artifactId());
- assertArrayEquals(old, store.open(oldId).readAllBytes());
+ assertRejected(() -> store.open(oldId));
assertArrayEquals(incoming, store.open(incomingId).readAllBytes());
try (var entries = Files.list(artifacts)) {
assertFalse(entries.anyMatch(path -> path.getFileName().toString().startsWith("upload-")
@@ -353,11 +353,11 @@ class ArtifactStoreTest {
}
}
- @Test void startupPreservesACompetingArtifactAfterAnInterruptedPublishCollision() throws Exception {
+ @Test void startupCommitsEvictionAfterCrashFollowingACompetingPublishCollision() throws Exception {
Path artifacts = directory.resolve("collision-recovery-artifacts");
byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {2});
- ArtifactStore store = new ArtifactStore(artifacts);
+ ArtifactStore store = new ArtifactStore(artifacts, 1_000_000, 1);
String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
String incomingId = sha256(incoming);
String transaction = "4".repeat(32);
@@ -368,13 +368,16 @@ class ArtifactStoreTest {
Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending");
Files.writeString(marker, staged.getFileName().toString());
- ArtifactStore recovered = new ArtifactStore(artifacts);
+ ArtifactStore recovered = new ArtifactStore(artifacts, 1_000_000, 1);
- assertArrayEquals(old, recovered.open(oldId).readAllBytes());
+ assertRejected(() -> recovered.open(oldId));
assertArrayEquals(incoming, recovered.open(incomingId).readAllBytes());
assertFalse(Files.exists(staged));
assertFalse(Files.exists(quarantine));
assertFalse(Files.exists(marker));
+ try (var files = Files.list(artifacts)) {
+ assertEquals(1, files.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
+ }
}
@Test void startupRollsBackAHardLinkedPublicationBeforeCommit() throws Exception {
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 6fe9c28..952f16f 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -149,6 +149,21 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("function quickReadConfigurationOptions()"));
assertTrue(script.body().contains("options: quickReadConfigurationOptions()"));
assertTrue(script.body().contains("loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions())"));
+ assertTrue(script.body().contains("reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability)"),
+ "Changing selected backend capability must discard and reload Vote Party state.");
+ assertTrue(script.body().contains("if (scheduleReload && tabFromHash() === 'quick-setup') void autoLoadTab('quick-setup');"),
+ "Vote Party capability transitions must use the quick-setup single-flight autoloader.");
+ assertFalse(script.body().contains("if (scheduleReload && tabFromHash() === 'quick-setup') void loadQuickSetupValues(true);"),
+ "Vote Party capability transitions must not start an overlapping direct READ.");
+ assertTrue(script.body().contains("const registry = await loadAllNodes();\n"
+ + " const previousQuickCapability = quickSetupCapability();\n"
+ + " const previousNodeIndex = nodeIndex;"));
+ assertTrue(script.body().contains("selectedNodes = filteredSelection;\n"
+ + " // A registry refresh can change the effective Vote Party contract without a\n"
+ + " // user selection event. Clear the old v2/v1 form before the normal tab\n"
+ + " // auto-load runs so a delayed or failed READ cannot expose stale values.\n"
+ + " reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability, false);"),
+ "Refresh-driven v2/v1 capability changes must clear stale Vote Party state before rereading.");
assertTrue(script.body().contains("const autoLoadGeneration = inputGeneration;"));
assertTrue(script.body().contains("if (inputGeneration !== autoLoadGeneration) {\n autoLoadPending.add(tab);\n return;\n }"),
"A stale dedicated read must fence the remainder of the automatic quick-setup sequence.");
From e79dffd8643a4720bca88e9823f2d78493b308ef Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 20:32:09 -0600
Subject: [PATCH 18/30] Preserve claimed configuration capability
---
.../votingplugin/control/domain/ConfigurationOperations.java | 2 +-
.../votingplugin/control/protocol/ConfigurationTask.java | 2 +-
.../control/domain/ConfigurationOperationsTest.java | 1 +
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index 5ca87a6..799207a 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -264,7 +264,7 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) {
throw e;
}
return new ConfigurationTask(operation.id, operation.type, configurationForTask(operation),
- operation.expectedRevisions.get(nodeId), attemptId);
+ operation.expectedRevisions.get(nodeId), attemptId, operation.configuration.capability());
}
}
return null;
diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java
index ba3b8e6..72f4f63 100644
--- a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java
+++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java
@@ -3,4 +3,4 @@
import java.util.UUID;
public record ConfigurationTask(UUID operationId, String type, ManagedConfiguration configuration,
- String expectedRevision, UUID attemptId) { }
+ String expectedRevision, UUID attemptId, String capability) { }
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index df12b72..1ba866f 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -963,6 +963,7 @@ class ConfigurationOperationsTest {
ConfigurationTask httpRead = operations.claim("http-backend", httpBackendSession);
assertEquals("proxy-backend", httpRead.configuration().preset());
assertEquals(Map.of(), httpRead.configuration().options());
+ assertEquals(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, httpRead.capability());
ManagedConfiguration lowercaseHttp = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null,
List.of(), null, null, "proxy-backend", Map.of("server", "lobby", "method", "http"));
assertThrows(IllegalArgumentException.class, lowercaseHttp::validateProposal);
From 6dfdb8611e8ab264bce04455a67a27914084198a Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Sun, 13 Sep 2026 20:46:48 -0600
Subject: [PATCH 19/30] Validate reported configuration capabilities
---
.../domain/ConfigurationOperations.java | 9 ++++---
.../domain/ConfigurationOperationsTest.java | 24 ++++++++++++++++++-
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index 799207a..18b20a3 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -573,7 +573,7 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co
|| cancelLostCapability(operation, node)) {
return view(operation);
}
- validateResultConfiguration(operation, result);
+ validateResultConfiguration(operation, result, node);
String priorState = operation.states.get(nodeId);
ConfigurationTaskResult priorResult = operation.results.get(nodeId);
Instant priorLease = operation.leasedAt.get(nodeId);
@@ -710,14 +710,17 @@ private void reclaimStaleRestartSessions(String incomingNodeId) {
}
}
- private static void validateResultConfiguration(StoredOperation operation, ConfigurationTaskResult result) {
+ private static void validateResultConfiguration(StoredOperation operation, ConfigurationTaskResult result,
+ NodeStatus node) {
ManagedConfiguration actual = result.configuration();
if (actual == null) return;
ManagedConfiguration expected = operation.configuration;
boolean mismatch = expected == null || !expected.domain().equals(actual.domain())
|| (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName()))
|| (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset()))
- || (!activeMethodRead(operation, expected) && !expected.capability().equals(actual.capability()));
+ || (!expected.capability().equals(actual.capability())
+ && (!activeMethodRead(operation, expected)
+ || !node.acceptedCapabilities().contains(actual.capability())));
if (mismatch) throw invalid("result configuration does not match the operation selector");
}
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index 1ba866f..aab0737 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -121,7 +121,8 @@ class ConfigurationOperationsTest {
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
UUID session = UUID.randomUUID();
registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1,
- Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY), Set.of()));
+ Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY,
+ ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY), Set.of()));
ConfigurationOperations operations = new ConfigurationOperations(registry,
new ConfigurationAuditLog(directory, clock), clock);
ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
@@ -137,6 +138,27 @@ class ConfigurationOperationsTest {
assertEquals("HTTP", read.results().get("lobby").configuration().options().get("method"));
}
+ @Test void proxyBackendReadRejectsInstalledMethodFromAnUnnegotiatedCapability() throws Exception {
+ Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
+ InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
+ UUID session = UUID.randomUUID();
+ registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1,
+ Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY), Set.of()));
+ ConfigurationOperations operations = new ConfigurationOperations(registry,
+ new ConfigurationAuditLog(directory, clock), clock);
+ ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "proxy-backend", Map.of());
+ ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector);
+ ConfigurationTask task = operations.claim("lobby", session);
+ ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "proxy-backend", Map.of("method", "HTTP"));
+
+ assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class,
+ () -> operations.complete(read.operationId(), "lobby",
+ new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), v2,
+ List.of(), false, false, task.attemptId()))).code());
+ }
+
@Test void quickSetupPreviewRejectsAResultFromANewerCapability() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
From 6f21a5cd356d72332493aeb57659b5a06d8b8c14 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Mon, 14 Sep 2026 16:37:47 -0600
Subject: [PATCH 20/30] Harden artifact recovery and capability state
---
.../control/artifact/ArtifactStore.java | 55 +++++++++++++++++--
src/main/resources/web/app.js | 41 +++++++++++++-
.../control/artifact/ArtifactStoreTest.java | 32 +++++++++++
.../control/http/ControlHttpServerTest.java | 14 ++++-
4 files changed, 133 insertions(+), 9 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 8f47db8..9af3902 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -50,6 +50,7 @@ public final class ArtifactStore {
private static final long MAX_EXPANDED_BYTES = 128L * 1024L * 1024L;
private static final long MAX_COMPRESSION_RATIO = 200L;
private static final int MAX_PLUGIN_YML_BYTES = 64 * 1024;
+ private static final int MAX_TRANSACTION_MARKER_BYTES = 4096;
private static final Set DIRECTORY_PERMISSIONS = Set.of(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
private static final Set FILE_PERMISSIONS = Set.of(
@@ -122,17 +123,27 @@ private void recoverEvictionTransactions() throws IOException {
String name = marker.getFileName().toString();
if (!name.matches("evict-[0-9a-f]{32}-[0-9a-f]{64}\\.(?:pending|committed)")) continue;
if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker)
- || Files.size(marker) > 128) throw rejected();
- String stagedName = Files.readString(marker, StandardCharsets.UTF_8);
+ || Files.size(marker) > MAX_TRANSACTION_MARKER_BYTES) throw rejected();
+ String markerContents = Files.readString(marker, StandardCharsets.UTF_8);
+ String[] markerLines = markerContents.split("\n", -1);
+ String stagedName = markerLines[0];
boolean legacyMarker = stagedName.isEmpty();
if (!legacyMarker && (!stagedName.matches("upload-[A-Za-z0-9._-]+\\.part")
|| stagedName.contains(".."))) throw rejected();
+ List plannedArtifactIds = new ArrayList<>();
+ Set uniquePlannedArtifactIds = new HashSet<>();
+ for (int index = 1; index < markerLines.length; index++) {
+ String artifactId = markerLines[index];
+ if (!isSha256(artifactId) || !uniquePlannedArtifactIds.add(artifactId)) throw rejected();
+ plannedArtifactIds.add(artifactId);
+ }
Path staged = legacyMarker ? null : directory.resolve(stagedName);
boolean stagedPresent = staged != null && Files.exists(staged, LinkOption.NOFOLLOW_LINKS);
if (stagedPresent && (!Files.isRegularFile(staged, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(staged))) throw rejected();
String transaction = name.substring("evict-".length(), "evict-".length() + 32);
String incomingId = name.substring("evict-".length() + 33, "evict-".length() + 33 + 64);
+ if (uniquePlannedArtifactIds.contains(incomingId)) throw rejected();
boolean committed = name.endsWith(".committed");
List quarantined = new ArrayList<>();
for (Path candidate : files) {
@@ -165,6 +176,7 @@ private void recoverEvictionTransactions() throws IOException {
// A verified competing publication is equivalent to the commit point: keeping
// the quarantined artifacts would violate the configured capacity bound.
// The staged upload is removed by removeIncompleteUploads after this marker.
+ completePlannedEvictions(transaction, plannedArtifactIds, quarantined);
for (QuarantinedFile file : quarantined) Files.delete(file.backup());
} else {
restoreQuarantined(quarantined);
@@ -175,6 +187,34 @@ private void recoverEvictionTransactions() throws IOException {
}
}
+ /** Completes a durable eviction plan after the incoming artifact reached its commit point. */
+ private void completePlannedEvictions(String transaction, List plannedArtifactIds,
+ List quarantined) throws IOException {
+ Set alreadyQuarantined = new HashSet<>();
+ for (QuarantinedFile file : quarantined) {
+ alreadyQuarantined.add(file.original().getFileName().toString().substring(0, 64));
+ }
+ boolean moved = false;
+ for (String artifactId : plannedArtifactIds) {
+ if (alreadyQuarantined.contains(artifactId)) continue;
+ Path original = artifactPath(artifactId);
+ Path backup = directory.resolve("evict-" + transaction + "-" + artifactId + ".part");
+ if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS)) {
+ verifyExistingArtifact(backup, artifactId);
+ quarantined.add(new QuarantinedFile(original, backup));
+ continue;
+ }
+ // A missing original and backup means cleanup already completed before
+ // the marker itself could be removed. Otherwise finish the planned move.
+ if (!Files.exists(original, LinkOption.NOFOLLOW_LINKS)) continue;
+ verifyExistingArtifact(original, artifactId);
+ moveAtomically(original, backup);
+ quarantined.add(new QuarantinedFile(original, backup));
+ moved = true;
+ }
+ if (moved) DurableFiles.forceDirectory(directory);
+ }
+
private boolean hasMatchingStagedUpload(List files, String incomingId) throws IOException {
for (Path candidate : files) {
String name = candidate.getFileName().toString();
@@ -298,7 +338,7 @@ private boolean publishWithRollback(Path temporary, Path artifact, List evictionPlan)
+ throws IOException {
Path temporaryMarker = directory.resolve("upload-marker-" + UUID.randomUUID() + ".part");
boolean published = false;
+ StringBuilder serialized = new StringBuilder(stagedName);
+ for (StoredFile candidate : evictionPlan) serialized.append('\n').append(candidate.artifactId());
+ byte[] serializedBytes = serialized.toString().getBytes(StandardCharsets.UTF_8);
+ if (serializedBytes.length > MAX_TRANSACTION_MARKER_BYTES) throw rejected();
try (FileChannel channel = FileChannel.open(temporaryMarker,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS)) {
setPermissions(temporaryMarker, FILE_PERMISSIONS);
- ByteBuffer contents = ByteBuffer.wrap(stagedName.getBytes(StandardCharsets.UTF_8));
+ ByteBuffer contents = ByteBuffer.wrap(serializedBytes);
while (contents.hasRemaining()) {
if (channel.write(contents) <= 0) throw new IOException("Artifact transaction marker could not be written");
}
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index dedc5a3..d90b961 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -2593,6 +2593,15 @@ function proxyMethodCapabilityFor(method) {
function proxyMethodReadCapability() {
const capabilities = nodeCapabilities.get(proxyMethodProxyId) || [];
+ // v2 can represent every method, including HTTP; v1 cannot. Prefer the
+ // richer common contract and fall back to v1 for older mixed networks.
+ for (const capability of ['config.proxy-method.v2', 'config.proxy-method.v1']) {
+ const network = proxyMethodNetworkFor(allNodeItems, backendTopologyTruncatedNodeIds,
+ proxyMethodProxyId, capability);
+ if (network.proxyReady && network.topologyComplete && network.unavailable.length === 0) return capability;
+ }
+ // Retain a proxy-supported fallback so the disabled-state explanation can
+ // identify the missing backend capability or incomplete topology.
return capabilities.includes('config.proxy-method.v1')
? 'config.proxy-method.v1' : 'config.proxy-method.v2';
}
@@ -2962,10 +2971,18 @@ function votePartyUsesV2() {
function reloadVotePartyWhenTargetCapabilityChanges(previousCapability, scheduleReload = true) {
if (quickPreset.value !== 'vote-party' || previousCapability === quickSetupCapability()) return;
loadedQuickSetup = null;
- quickSetupDirty = false;
approvedQuickPreview = null;
- populateQuickState({});
- text(quickOperationStatus, 'Selected backend capabilities changed. Loading confirmed Vote Party settings…');
+ if (quickSetupDirty) {
+ // The capability-dependent Enabled field must be refreshed, but the
+ // operator's unsaved common Vote Party edits remain authoritative locally.
+ quickSetupPreserveReadGeneration = inputGeneration;
+ text(quickOperationStatus,
+ 'Selected backend capabilities changed. Preserving unsaved Vote Party edits while loading confirmed state…');
+ } else {
+ quickSetupPreserveReadGeneration = -1;
+ populateQuickState({});
+ text(quickOperationStatus, 'Selected backend capabilities changed. Loading confirmed Vote Party settings…');
+ }
updateConfigurationButtons();
// Funnel capability transitions through the tab's single-flight autoloader.
// A rapid v2/v1/v2 change therefore marks one follow-up read instead of
@@ -4124,9 +4141,27 @@ async function loadQuickSetupValues(automatic = false, preserveDirty = false) {
&& pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : null;
const selectedProxyMethod = preserveDirty && preset === 'proxy-backend' ? quickMethod.value : null;
const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null;
+ const editedVoteParty = preserveDirty && preset === 'vote-party' ? {
+ enabled: !quickPartyEnabled.disabled && !quickPartyEnabled.indeterminate ? quickPartyEnabled.checked : null,
+ votes: quickPartyVotes.value,
+ broadcast: quickPartyBroadcast.value,
+ giveAllPlayers: quickPartyAll.checked,
+ onlineOnly: quickPartyOnline.checked,
+ command: quickPartyCommand.value
+ } : null;
populateQuickState(result.configuration.options);
if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod;
if (editedProxyServer != null) quickName.value = editedProxyServer;
+ if (editedVoteParty != null) {
+ if (editedVoteParty.enabled != null && !quickPartyEnabled.disabled) {
+ quickPartyEnabled.checked = editedVoteParty.enabled;
+ }
+ quickPartyVotes.value = editedVoteParty.votes;
+ quickPartyBroadcast.value = editedVoteParty.broadcast;
+ quickPartyAll.checked = editedVoteParty.giveAllPlayers;
+ quickPartyOnline.checked = editedVoteParty.onlineOnly;
+ quickPartyCommand.value = editedVoteParty.command;
+ }
quickSetupDirty = preserveDirty;
if (detected && result.configuration.options.exists === 'false') {
quickSiteDisplayName.value = detected.service;
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 74f3c6d..e6a3c91 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -380,6 +380,38 @@ class ArtifactStoreTest {
}
}
+ @Test void collisionRecoveryCompletesEveryEvictionRecordedBeforeTheFirstMove() throws Exception {
+ Path artifacts = directory.resolve("partial-collision-recovery-artifacts");
+ Files.createDirectories(artifacts);
+ byte[] first = jar("name: VotingPlugin\n", "plugin/First.class", new byte[] {1});
+ byte[] second = jar("name: VotingPlugin\n", "plugin/Second.class", new byte[] {2});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {3});
+ String firstId = sha256(first);
+ String secondId = sha256(second);
+ String incomingId = sha256(incoming);
+ Files.write(artifacts.resolve(firstId + ".jar"), first);
+ Files.write(artifacts.resolve(secondId + ".jar"), second);
+ Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
+ Path staged = Files.write(artifacts.resolve("upload-partial-collision.part"), incoming);
+ String transaction = "7".repeat(32);
+ Path firstBackup = artifacts.resolve("evict-" + transaction + "-" + firstId + ".part");
+ Files.move(artifacts.resolve(firstId + ".jar"), firstBackup);
+ Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending");
+ Files.writeString(marker, staged.getFileName() + "\n" + firstId + "\n" + secondId);
+
+ ArtifactStore recovered = new ArtifactStore(artifacts, 1_000_000, 1);
+
+ assertRejected(() -> recovered.open(firstId));
+ assertRejected(() -> recovered.open(secondId));
+ assertArrayEquals(incoming, recovered.open(incomingId).readAllBytes());
+ assertFalse(Files.exists(staged));
+ assertFalse(Files.exists(firstBackup));
+ assertFalse(Files.exists(marker));
+ try (var files = Files.list(artifacts)) {
+ assertEquals(1, files.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
+ }
+ }
+
@Test void startupRollsBackAHardLinkedPublicationBeforeCommit() throws Exception {
Path artifacts = directory.resolve("hard-link-recovery-artifacts");
byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 952f16f..a109824 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -150,7 +150,11 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("options: quickReadConfigurationOptions()"));
assertTrue(script.body().contains("loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions())"));
assertTrue(script.body().contains("reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability)"),
- "Changing selected backend capability must discard and reload Vote Party state.");
+ "Changing selected backend capability must reload capability-dependent Vote Party state.");
+ assertTrue(script.body().contains("quickSetupPreserveReadGeneration = inputGeneration;\n"
+ + " text(quickOperationStatus,\n"
+ + " 'Selected backend capabilities changed. Preserving unsaved Vote Party edits"),
+ "Capability changes must preserve unsaved common Vote Party fields during the confirmed read.");
assertTrue(script.body().contains("if (scheduleReload && tabFromHash() === 'quick-setup') void autoLoadTab('quick-setup');"),
"Vote Party capability transitions must use the quick-setup single-flight autoloader.");
assertFalse(script.body().contains("if (scheduleReload && tabFromHash() === 'quick-setup') void loadQuickSetupValues(true);"),
@@ -176,6 +180,14 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null;"));
assertTrue(script.body().contains("if (editedProxyServer != null) quickName.value = editedProxyServer;"),
"A capability read must preserve an edited proxy destination.");
+ assertTrue(script.body().contains("const editedVoteParty = preserveDirty && preset === 'vote-party'"));
+ assertTrue(script.body().contains("if (editedVoteParty.enabled != null && !quickPartyEnabled.disabled)"));
+ assertTrue(script.body().contains("quickPartyCommand.value = editedVoteParty.command;"),
+ "A capability refresh must restore every unsaved common Vote Party value.");
+ assertTrue(script.body().contains("for (const capability of ['config.proxy-method.v2', 'config.proxy-method.v1'])"),
+ "A fully v2-capable network must retain HTTP current-state visibility instead of downgrading to v1.");
+ assertTrue(script.body().contains("network.proxyReady && network.topologyComplete && network.unavailable.length === 0"),
+ "Proxy-method reads must negotiate one capability shared by the full reported network.");
assertTrue(script.body().contains("previewAutoSites.disabled = !quickReady || autoSitesState.textContent === 'Not loaded';"));
assertTrue(script.body().contains("previewVoteLogging.disabled = !quickReady || voteLoggingState.textContent === 'Not loaded';"));
assertTrue(script.body().contains("if (automatic && requestGeneration !== inputGeneration) void autoLoadTab('quick-setup');"),
From f67def2563da20bb151aefb078f37408cc65c6a1 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Mon, 14 Sep 2026 16:56:32 -0600
Subject: [PATCH 21/30] Preserve unavailable Vote Party profile state
---
src/main/resources/web/app.js | 11 +++++++++--
.../control/http/ControlHttpServerTest.java | 4 ++++
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index d90b961..89a8c3b 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -1171,7 +1171,7 @@ function writeProfiles(profiles) {
}
function currentProfileValues() {
- return {
+ const values = {
version: 1, preset: quickPreset.value, name: quickName.value, method: quickMethod.value,
siteDisplayName: quickSiteDisplayName.value, service: quickService.value, url: quickUrl.value,
delay: quickDelay.value, priority: quickSitePriority.value, material: quickSiteMaterial.value,
@@ -1180,7 +1180,7 @@ function currentProfileValues() {
processRewards: quickProcessRewards.checked, autoSites: quickAutoSites.checked,
extraCheck: quickExtraCheck.checked, countFake: quickCountFake.checked,
hideWarning: quickHideSiteWarning.checked, disableUpdates: quickDisableUpdates.checked,
- partyEnabled: quickPartyEnabled.checked, partyVotes: quickPartyVotes.value, partyCommand: quickPartyCommand.value,
+ partyVotes: quickPartyVotes.value, partyCommand: quickPartyCommand.value,
partyBroadcast: quickPartyBroadcast.value, partyAll: quickPartyAll.checked, partyOnline: quickPartyOnline.checked,
autoSitesOnly: quickAutoSitesOnly.checked, voteLogging: quickVoteLoggingEnabled.checked,
voteLoggingDays: quickVoteLoggingDays.value, voteLoggingMainMysql: quickVoteLoggingMainMysql.checked,
@@ -1189,6 +1189,13 @@ function currentProfileValues() {
broadcasts: rewardBroadcasts.value, permissions: rewardPermissions.value, items: rewardItems.value,
onlineOnly: rewardOnlineOnly.checked}
};
+ // A v1 target cannot report Enabled. Omitting it preserves the confirmed live
+ // value if this profile is later loaded against a v2-capable backend.
+ if (quickSetupCapability() === 'config.quick-setup.v2'
+ && !quickPartyEnabled.disabled && !quickPartyEnabled.indeterminate) {
+ values.partyEnabled = quickPartyEnabled.checked;
+ }
+ return values;
}
function populateProfilePicker() {
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index a109824..9c9d0e9 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -132,6 +132,10 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("quickPartyEnabled.checked = enabledAvailable && options.enabled === 'true'"));
assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"),
"Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field.");
+ assertTrue(script.body().contains("if (quickSetupCapability() === 'config.quick-setup.v2'\n"
+ + " && !quickPartyEnabled.disabled && !quickPartyEnabled.indeterminate) {\n"
+ + " values.partyEnabled = quickPartyEnabled.checked;"),
+ "Profiles must omit an unavailable Vote Party Enabled value instead of fabricating false.");
assertTrue(script.body().contains("config.proxy-method.v2"));
assertTrue(script.body().contains("quickPreset.value === 'vote-party' && votePartyUsesV2()\n"
+ " ? 'config.quick-setup.v2'"),
From 9a0a341c813a86288a90589207496a5e1ca68bec Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:05:24 -0600
Subject: [PATCH 22/30] Harden artifact admission and target selection
---
.../control/artifact/ArtifactStore.java | 6 +++-
src/main/resources/web/app.js | 33 ++++++++++++++-----
.../control/artifact/ArtifactStoreTest.java | 13 ++++++++
.../control/http/ControlHttpServerTest.java | 7 ++--
4 files changed, 48 insertions(+), 11 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 9af3902..0b29b44 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -260,7 +260,11 @@ private Artifact uploadLocked(InputStream source, String displayFilename, String
boolean published = false;
try {
verifyDirectory();
- recoverEvictionTransactions();
+ // A prior rejected upload may have failed its best-effort finally
+ // cleanup. Reconcile every owned temporary before capacity planning;
+ // removeIncompleteUploads fails closed when a remnant cannot be
+ // safely verified or removed.
+ removeIncompleteUploads();
temporary = Files.createTempFile(directory, "upload-", ".part");
setPermissions(temporary, FILE_PERMISSIONS);
DigestAndSize digest = copyBounded(source, temporary);
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index 89a8c3b..cc52a72 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -2930,6 +2930,8 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 |
const fileDraftReady = fileReady && fileDraftMatchesCurrentContext();
const syncSelected = quickPreset.value === 'sync-vote-sites';
const quickCapability = quickSetupCapability();
+ const votePartyCapabilityMismatch = quickPreset.value === 'vote-party'
+ && selectedVotePartyBackends().length > 0 && !votePartyCommonCapability();
const quickReady = authenticated && !busy && (syncSelected
? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0)
: primaryCapabilities.includes(quickCapability) && quickSetupTargets().length > 0);
@@ -2942,6 +2944,10 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 |
readQuickSetup.disabled = !quickReady || !quickPresetReadable();
previewQuickSetup.disabled = !quickReady || (quickPresetNeedsRead() && !quickSetupValuesLoaded());
applyQuickSetup.disabled = !quickReady || !approvedQuickPreview;
+ if (votePartyCapabilityMismatch && !busy) {
+ text(quickOperationStatus,
+ 'The selected backends do not share a Vote Party configuration capability. Update their VotingPlugin versions or select compatible backends.');
+ }
runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy;
const methodNetwork = proxyMethodReadNetwork();
proxyMethodButtons.forEach(button => {
@@ -2962,17 +2968,28 @@ function backendQuickTargets() {
function quickSetupCapability() {
return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP'
- ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party' && votePartyUsesV2()
- ? 'config.quick-setup.v2' : 'config.quick-setup.v1';
+ ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party'
+ ? votePartyCommonCapability() || 'config.quick-setup.unavailable' : 'config.quick-setup.v1';
}
function votePartyUsesV2() {
- const selectedBackends = [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId)
- && isBackend(nodeIndex.get(nodeId))
- && (nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1')
- || nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2')));
- return selectedBackends.length > 0
- && selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2'));
+ return votePartyCommonCapability() === 'config.quick-setup.v2';
+}
+
+function selectedVotePartyBackends() {
+ return [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId)));
+}
+
+function votePartyCommonCapability() {
+ const selectedBackends = selectedVotePartyBackends();
+ if (!selectedBackends.length) return null;
+ if (selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2'))) {
+ return 'config.quick-setup.v2';
+ }
+ if (selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1'))) {
+ return 'config.quick-setup.v1';
+ }
+ return null;
}
function reloadVotePartyWhenTargetCapabilityChanges(previousCapability, scheduleReload = true) {
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index e6a3c91..62afabe 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -99,6 +99,19 @@ class ArtifactStoreTest {
assertRejected(() -> store.upload(new ByteArrayInputStream(duplicate), "VotingPlugin.jar", sha256(duplicate)));
}
+ @Test void removesIncompleteUploadsBeforeEveryNewUpload() throws Exception {
+ Path artifacts = directory.resolve("artifacts-retry-cleanup");
+ ArtifactStore store = new ArtifactStore(artifacts);
+ Path incomplete = Files.writeString(artifacts.resolve("upload-rejected.part"), "partial");
+ byte[] valid = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
+
+ ArtifactStore.Artifact uploaded = store.upload(
+ new ByteArrayInputStream(valid), "VotingPlugin.jar", sha256(valid));
+
+ assertFalse(Files.exists(incomplete));
+ assertArrayEquals(valid, store.open(uploaded.artifactId()).readAllBytes());
+ }
+
@Test void removesAnUnpublishedTemporaryTransactionMarkerOnStartup() throws Exception {
Path artifacts = directory.resolve("artifacts");
Files.createDirectories(artifacts);
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 9c9d0e9..9ba563f 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -137,9 +137,12 @@ class ControlHttpServerTest {
+ " values.partyEnabled = quickPartyEnabled.checked;"),
"Profiles must omit an unavailable Vote Party Enabled value instead of fabricating false.");
assertTrue(script.body().contains("config.proxy-method.v2"));
- assertTrue(script.body().contains("quickPreset.value === 'vote-party' && votePartyUsesV2()\n"
- + " ? 'config.quick-setup.v2'"),
+ assertTrue(script.body().contains("quickPreset.value === 'vote-party'\n"
+ + " ? votePartyCommonCapability() || 'config.quick-setup.unavailable'"),
"Vote Party must use v2 only when every selected backend supports it.");
+ assertTrue(script.body().contains("selectedVotePartyBackends().length > 0 && !votePartyCommonCapability()"));
+ assertTrue(script.body().contains("The selected backends do not share a Vote Party configuration capability."),
+ "Mixed v1-only/v2-only targets must be rejected explicitly instead of silently omitting a backend.");
assertTrue(script.body().contains("if (quickSetupCapability() === 'config.quick-setup.v2') voteParty.enabled"),
"Vote Party Enabled must never be sent under the incompatible v1 quick-setup contract.");
assertTrue(script.body().contains("quickPartyEnabled.indeterminate = !enabledAvailable;\n"
From f6a1adf20195991474117d20e548edd85557002a Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Mon, 14 Sep 2026 21:10:58 -0600
Subject: [PATCH 23/30] Harden artifact access and setup reads
---
.../control/artifact/ArtifactStore.java | 20 +++++-----
src/main/resources/web/app.js | 4 +-
.../control/artifact/ArtifactStoreTest.java | 37 +++++++++++++++++++
.../control/http/ControlHttpServerTest.java | 5 +++
4 files changed, 56 insertions(+), 10 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 0b29b44..fa5277f 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -473,11 +473,12 @@ private void restoreQuarantined(List quarantined) throws IOExce
public InputStream open(String artifactId) throws IOException {
if (!isSha256(artifactId)) throw rejected();
try {
- verifyDirectory();
- Path artifact = artifactPath(artifactId);
- verifyExistingArtifact(artifact, artifactId);
- return Channels.newInputStream(FileChannel.open(artifact,
- Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)));
+ return withDirectoryLock(() -> {
+ Path artifact = artifactPath(artifactId);
+ verifyExistingArtifact(artifact, artifactId);
+ return Channels.newInputStream(FileChannel.open(artifact,
+ Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)));
+ });
} catch (IOException failure) {
throw rejected();
}
@@ -487,10 +488,11 @@ public InputStream open(String artifactId) throws IOException {
public Artifact describe(String artifactId) throws IOException {
if (!isSha256(artifactId)) throw rejected();
try {
- verifyDirectory();
- Path artifact = artifactPath(artifactId);
- verifyExistingArtifact(artifact, artifactId);
- return new Artifact(artifactId, "VotingPlugin.jar", Files.size(artifact));
+ return withDirectoryLock(() -> {
+ Path artifact = artifactPath(artifactId);
+ verifyExistingArtifact(artifact, artifactId);
+ return new Artifact(artifactId, "VotingPlugin.jar", Files.size(artifact));
+ });
} catch (IOException failure) {
throw rejected();
}
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index cc52a72..0272fd3 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -644,12 +644,14 @@ function renderSiteHealthResult(value) {
pendingDetectedVoteSite = {nodeId: selectedServerId, key, service: String(service).slice(0, 200)};
selectedNodes = new Set(selectedServerId ? [selectedServerId] : []);
loadedQuickSetup = null;
+ quickSetupDirty = false;
+ quickSetupPreserveReadGeneration = -1;
updateQuickFields();
clearApprovals();
renderNodeViews();
updatePluginSuggestions();
setActiveTab('quick-setup', true);
- text(quickOperationStatus, 'Detected service copied into the VoteSite setup. Load the generated key to confirm it is unused, complete the URL and delay, then preview before creating it.');
+ text(quickOperationStatus, 'Detected service copied into the VoteSite setup. Control is checking that the generated key is unused; complete the URL and delay, then preview before creating it.');
scrollToAnchor(document.querySelector('#quick-setup-card'));
});
actions.append(button);
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 62afabe..0531b3f 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -10,6 +10,7 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
@@ -18,9 +19,12 @@
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
@@ -50,6 +54,39 @@ class ArtifactStoreTest {
}
}
+ @Test void artifactAccessWaitsForDirectoryMutationLock() throws Exception {
+ Path artifacts = directory.resolve("locked-artifacts");
+ byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1, 2, 3});
+ ArtifactStore store = new ArtifactStore(artifacts);
+ String artifactId = store.upload(new ByteArrayInputStream(jar), "VotingPlugin.jar", sha256(jar)).artifactId();
+ Field locksField = ArtifactStore.class.getDeclaredField("DIRECTORY_LOCKS");
+ locksField.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ConcurrentMap locks = (ConcurrentMap) locksField.get(null);
+ ReentrantLock lock = locks.get(artifacts.toAbsolutePath().normalize());
+ CountDownLatch started = new CountDownLatch(1);
+ var executor = Executors.newSingleThreadExecutor();
+ lock.lock();
+ try {
+ var access = executor.submit(() -> {
+ started.countDown();
+ ArtifactStore.Artifact described = store.describe(artifactId);
+ try (InputStream input = store.open(artifactId)) {
+ return new Object[] {described, input.readAllBytes()};
+ }
+ });
+ assertTrue(started.await(5, TimeUnit.SECONDS));
+ assertThrows(TimeoutException.class, () -> access.get(200, TimeUnit.MILLISECONDS));
+ lock.unlock();
+ Object[] result = access.get(5, TimeUnit.SECONDS);
+ assertEquals(jar.length, ((ArtifactStore.Artifact) result[0]).size());
+ assertArrayEquals(jar, (byte[]) result[1]);
+ } finally {
+ if (lock.isHeldByCurrentThread()) lock.unlock();
+ executor.shutdownNow();
+ }
+ }
+
@Test void rejectsWrongOrNonLowercaseClaimsAndDoesNotPublishPartialArtifacts() throws Exception {
byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
ArtifactStore store = new ArtifactStore(directory.resolve("artifacts"));
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 9ba563f..7fe0486 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -793,6 +793,11 @@ class ControlHttpServerTest {
+ " quickSetupDirty = false;\n quickSetupPreserveReadGeneration = -1;\n"
+ " pendingDetectedVoteSite = null;"),
"A shortcut replacing the preset must discard dirty state from the previous form before autoloading.");
+ assertTrue(script.body().contains("pendingDetectedVoteSite = {nodeId: selectedServerId, key, service: String(service).slice(0, 200)};\n"
+ + " selectedNodes = new Set(selectedServerId ? [selectedServerId] : []);\n"
+ + " loadedQuickSetup = null;\n quickSetupDirty = false;\n"
+ + " quickSetupPreserveReadGeneration = -1;"),
+ "Detected-site navigation must discard dirty state from the previous preset before autoloading.");
assertTrue(script.body().contains("if (autoLoadInFlight.has(tab)) {\n autoLoadPending.add(tab);"));
assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);"),
"A preset change during an older read must queue a fresh autoload.");
From 22b48056570b7d18d1c6f578204750267abc437b Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Mon, 14 Sep 2026 21:35:52 -0600
Subject: [PATCH 24/30] Require complete proxy capability sets
---
.../control/artifact/ArtifactStore.java | 23 +++++++++++++--
src/main/resources/web/app.js | 20 +++++++++++--
.../control/artifact/ArtifactStoreTest.java | 28 +++++++++++++++++++
.../control/http/ControlHttpServerTest.java | 6 ++--
4 files changed, 70 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index fa5277f..982dbc2 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -62,6 +62,7 @@ public final class ArtifactStore {
private final int maximumStoredArtifacts;
private final IoAction beforePublishMove;
private final IoAction afterPublishMove;
+ private final IoAction afterCollision;
/** Creates or opens an empty private directory owned by Control. */
public ArtifactStore(Path directory) throws IOException {
@@ -79,14 +80,20 @@ public ArtifactStore(Path directory) throws IOException {
ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts,
IoAction beforePublishMove, IoAction afterPublishMove) throws IOException {
+ this(directory, maximumStoredBytes, maximumStoredArtifacts, beforePublishMove, afterPublishMove, path -> { });
+ }
+
+ ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts,
+ IoAction beforePublishMove, IoAction afterPublishMove, IoAction afterCollision) throws IOException {
if (directory == null) throw rejected();
if (maximumStoredBytes < 1 || maximumStoredArtifacts < 1
- || beforePublishMove == null || afterPublishMove == null) throw rejected();
+ || beforePublishMove == null || afterPublishMove == null || afterCollision == null) throw rejected();
this.directory = directory.toAbsolutePath().normalize();
this.maximumStoredBytes = maximumStoredBytes;
this.maximumStoredArtifacts = maximumStoredArtifacts;
this.beforePublishMove = beforePublishMove;
this.afterPublishMove = afterPublishMove;
+ this.afterCollision = afterCollision;
try {
createPrivateDirectory(this.directory);
withDirectoryLock(() -> {
@@ -258,6 +265,7 @@ private Artifact uploadLocked(InputStream source, String displayFilename, String
Set protectedArtifactIds) throws IOException {
Path temporary = null;
boolean published = false;
+ boolean recoveryPending = false;
try {
verifyDirectory();
// A prior rejected upload may have failed its best-effort finally
@@ -280,8 +288,11 @@ private Artifact uploadLocked(InputStream source, String displayFilename, String
List evictionPlan = planCapacity(digest.size(), protectedArtifactIds);
published = publishWithRollback(temporary, artifact, evictionPlan);
return new Artifact(actual, displayFilename, digest.size());
+ } catch (RecoveryRequiredException failure) {
+ recoveryPending = true;
+ throw failure;
} finally {
- if (!published && temporary != null) deleteTemporary(temporary);
+ if (!published && !recoveryPending && temporary != null) deleteTemporary(temporary);
}
}
@@ -341,6 +352,7 @@ private boolean publishWithRollback(Path temporary, Path artifact, List 0 |
const quickCapability = quickSetupCapability();
const votePartyCapabilityMismatch = quickPreset.value === 'vote-party'
&& selectedVotePartyBackends().length > 0 && !votePartyCommonCapability();
+ const proxyBackendCapabilityMismatch = quickPreset.value === 'proxy-backend'
+ && selectedVotePartyBackends().length > 0 && !proxyBackendCommonCapability();
const quickReady = authenticated && !busy && (syncSelected
? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0)
- : primaryCapabilities.includes(quickCapability) && quickSetupTargets().length > 0);
+ : !proxyBackendCapabilityMismatch && primaryCapabilities.includes(quickCapability)
+ && quickSetupTargets().length > 0);
readConfiguration.disabled = !routingReadReady;
previewConfiguration.disabled = !routingDraftReady;
applyConfiguration.disabled = !routingDraftReady || !approvedPreview;
@@ -2950,6 +2953,10 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 |
text(quickOperationStatus,
'The selected backends do not share a Vote Party configuration capability. Update their VotingPlugin versions or select compatible backends.');
}
+ if (proxyBackendCapabilityMismatch && !busy) {
+ text(quickOperationStatus,
+ 'Every selected backend must support this proxy method. Update incompatible VotingPlugin versions or select compatible backends.');
+ }
runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy;
const methodNetwork = proxyMethodReadNetwork();
proxyMethodButtons.forEach(button => {
@@ -2969,11 +2976,18 @@ function backendQuickTargets() {
}
function quickSetupCapability() {
- return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP'
- ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party'
+ return quickPreset.value === 'proxy-backend'
+ ? proxyBackendCommonCapability() || 'config.proxy-method.unavailable' : quickPreset.value === 'vote-party'
? votePartyCommonCapability() || 'config.quick-setup.unavailable' : 'config.quick-setup.v1';
}
+function proxyBackendCommonCapability() {
+ const selectedBackends = selectedVotePartyBackends();
+ if (!selectedBackends.length) return null;
+ const required = quickMethod.value === 'HTTP' ? 'config.proxy-method.v2' : 'config.quick-setup.v1';
+ return selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes(required)) ? required : null;
+}
+
function votePartyUsesV2() {
return votePartyCommonCapability() === 'config.quick-setup.v2';
}
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 0531b3f..bf06fd3 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -273,6 +273,34 @@ class ArtifactStoreTest {
}
}
+ @Test void collisionFinalizationFailureKeepsItsEvictionPlanForRecovery() throws Exception {
+ Path artifacts = directory.resolve("collision-finalization-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {2});
+ String oldId = sha256(old);
+ String incomingId = sha256(incoming);
+ AtomicBoolean createCollision = new AtomicBoolean();
+ AtomicBoolean failFinalization = new AtomicBoolean();
+ ArtifactStore store = new ArtifactStore(artifacts, 1_000_000, 1, path -> {
+ if (createCollision.get()) Files.write(path, incoming);
+ }, path -> { }, path -> {
+ if (failFinalization.get()) throw new IOException("simulated collision finalization failure");
+ });
+ store.upload(new ByteArrayInputStream(old), "old.jar", oldId);
+
+ createCollision.set(true);
+ failFinalization.set(true);
+ assertRejected(() -> store.upload(new ByteArrayInputStream(incoming), "incoming.jar", incomingId));
+
+ ArtifactStore recovered = new ArtifactStore(artifacts, 1_000_000, 1);
+ assertRejected(() -> recovered.open(oldId));
+ assertArrayEquals(incoming, recovered.open(incomingId).readAllBytes());
+ try (var entries = Files.list(artifacts)) {
+ assertFalse(entries.anyMatch(path -> path.getFileName().toString().startsWith("upload-")
+ || path.getFileName().toString().startsWith("evict-")));
+ }
+ }
+
@Test void incompleteRollbackRetainsItsPendingRecoveryMarker() throws Exception {
Path artifacts = directory.resolve("rollback-artifacts");
AtomicBoolean failAfterMove = new AtomicBoolean();
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index 7fe0486..fc506c9 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -117,6 +117,9 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("proxyMethodNetworkSignature(refreshedNetwork)"));
assertTrue(script.body().contains("proxyMethodCurrentSessionId !== (network.proxy?.sessionId || '')"));
assertTrue(script.body().contains("sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId"));
+ assertTrue(script.body().contains("function proxyBackendCommonCapability()"));
+ assertTrue(script.body().contains("selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes(required))"));
+ assertTrue(script.body().contains("!proxyBackendCapabilityMismatch && primaryCapabilities.includes(quickCapability)"));
assertTrue(script.body().contains("refreshedNetwork.proxy?.sessionId !== network.proxy.sessionId"));
assertTrue(script.body().contains("if (approvedQuickPreview?.workflow === 'sync-vote-sites') approvedQuickPreview = null;"));
assertTrue(script.body().contains("if (quickPreset.value !== 'sync-vote-sites') return;"));
@@ -137,8 +140,7 @@ class ControlHttpServerTest {
+ " values.partyEnabled = quickPartyEnabled.checked;"),
"Profiles must omit an unavailable Vote Party Enabled value instead of fabricating false.");
assertTrue(script.body().contains("config.proxy-method.v2"));
- assertTrue(script.body().contains("quickPreset.value === 'vote-party'\n"
- + " ? votePartyCommonCapability() || 'config.quick-setup.unavailable'"),
+ assertTrue(script.body().contains("? votePartyCommonCapability() || 'config.quick-setup.unavailable' : 'config.quick-setup.v1';"),
"Vote Party must use v2 only when every selected backend supports it.");
assertTrue(script.body().contains("selectedVotePartyBackends().length > 0 && !votePartyCommonCapability()"));
assertTrue(script.body().contains("The selected backends do not share a Vote Party configuration capability."),
From d936c32a1297a5d967c88ed024944262d328a31e Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Mon, 14 Sep 2026 21:50:18 -0600
Subject: [PATCH 25/30] Reject stale proxy capability reads
---
src/main/resources/web/app.js | 3 ++-
.../votingplugin/control/http/ControlHttpServerTest.java | 2 ++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index 462d73b..f24932e 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -4373,7 +4373,8 @@ async function loadProxyMethod(automatic = false) {
const method = result?.success ? result.configuration?.options?.method : '';
if (!method) throw new Error('The proxy did not return its active communication method.');
if (requestAuthenticationGeneration !== authenticationGeneration || proxyId !== proxyMethodProxyId
- || sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId || result?.sessionId !== sessionId) return;
+ || sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId
+ || readCapability !== proxyMethodReadCapability() || result?.sessionId !== sessionId) return;
proxyMethodCurrentFor = proxyId;
proxyMethodCurrentSessionId = sessionId;
proxyMethodCurrentReadCapability = readCapability;
diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
index fc506c9..e2e63c9 100644
--- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java
@@ -814,6 +814,8 @@ class ControlHttpServerTest {
assertTrue(script.body().contains("const readCapability = proxyMethodReadCapability();"));
assertTrue(script.body().contains("readCapability === 'config.proxy-method.v2' ? 'HTTP' : 'PLUGINMESSAGING'"),
"A v2-only proxy must read its current method through the capability it advertises.");
+ assertTrue(script.body().contains("readCapability !== proxyMethodReadCapability()"),
+ "A proxy-method read must be discarded when the negotiated capability changes while it is in flight.");
assertTrue(script.body().contains("autoLoadPending.clear();"));
int globalShortcut = script.body().indexOf("function openGlobalShortcut(destination)");
int selectConfigView = script.body().indexOf("setConfigView(destination.configView);", globalShortcut);
From 547b64783c3b72bb3b19fd04da6ff8133ed8636f Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Mon, 14 Sep 2026 22:01:22 -0600
Subject: [PATCH 26/30] Document configuration task capability contract
---
docs/control-management.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/docs/control-management.md b/docs/control-management.md
index 755c3a8..766c0c3 100644
--- a/docs/control-management.md
+++ b/docs/control-management.md
@@ -141,6 +141,13 @@ Each target state is `QUEUED`, `IN_PROGRESS`, or `COMPLETE`; the aggregate state
`COMPLETED_WITH_ERRORS`. A claim has a two-minute lease and new `attemptId`. The result must echo that attempt and the
current node session, preventing a stale execution from completing reissued work.
+The claimed `ConfigurationTask` also contains an exact `capability` field. Connectors must dispatch and validate the task
+against that field rather than deriving a contract from `configuration`. In particular, an HTTP `proxy-backend` `READ`
+uses `config.proxy-method.v2` while its task configuration deliberately omits the requested method so the current value is
+not supplied as an answer. Legacy proxy-method reads carry `config.proxy-method.v1`. A connector must implement this
+field and the matching v2 task/result contract before advertising `config.proxy-method.v2`; older connectors remain on
+v1 and reject unsupported capability work through normal negotiation.
+
### Retry behavior
`POST /api/v1/operations/{operationId}/retry` creates a new operation; it never mutates the historical view.
From f17f5c6cecc3e829d9c97c131266da772bf634fd Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Tue, 15 Sep 2026 04:53:26 -0600
Subject: [PATCH 27/30] Fix artifact recovery and legacy method reads
---
.../control/artifact/ArtifactStore.java | 26 +++++++++++----
.../domain/ConfigurationOperations.java | 12 +++++--
.../control/artifact/ArtifactStoreTest.java | 32 +++++++++++++++++++
.../domain/ConfigurationOperationsTest.java | 23 +++++++++++++
4 files changed, 85 insertions(+), 8 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 982dbc2..294639e 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -63,6 +63,7 @@ public final class ArtifactStore {
private final IoAction beforePublishMove;
private final IoAction afterPublishMove;
private final IoAction afterCollision;
+ private final IoAction publicationDelete;
/** Creates or opens an empty private directory owned by Control. */
public ArtifactStore(Path directory) throws IOException {
@@ -85,15 +86,24 @@ public ArtifactStore(Path directory) throws IOException {
ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts,
IoAction beforePublishMove, IoAction afterPublishMove, IoAction afterCollision) throws IOException {
+ this(directory, maximumStoredBytes, maximumStoredArtifacts, beforePublishMove, afterPublishMove,
+ afterCollision, Files::delete);
+ }
+
+ ArtifactStore(Path directory, long maximumStoredBytes, int maximumStoredArtifacts,
+ IoAction beforePublishMove, IoAction afterPublishMove, IoAction afterCollision,
+ IoAction publicationDelete) throws IOException {
if (directory == null) throw rejected();
if (maximumStoredBytes < 1 || maximumStoredArtifacts < 1
- || beforePublishMove == null || afterPublishMove == null || afterCollision == null) throw rejected();
+ || beforePublishMove == null || afterPublishMove == null || afterCollision == null
+ || publicationDelete == null) throw rejected();
this.directory = directory.toAbsolutePath().normalize();
this.maximumStoredBytes = maximumStoredBytes;
this.maximumStoredArtifacts = maximumStoredArtifacts;
this.beforePublishMove = beforePublishMove;
this.afterPublishMove = afterPublishMove;
this.afterCollision = afterCollision;
+ this.publicationDelete = publicationDelete;
try {
createPrivateDirectory(this.directory);
withDirectoryLock(() -> {
@@ -353,6 +363,7 @@ private boolean publishWithRollback(Path temporary, Path artifact, List qua
if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)) {
throw rejected();
}
- Files.delete(artifact);
+ publicationDelete.run(artifact);
}
} catch (IOException problem) {
failure = problem;
@@ -618,18 +630,19 @@ private static boolean declaresVotingPlugin(byte[] pluginYml) throws IOException
}
}
- private boolean publish(Path temporary, Path artifact) throws IOException {
+ private boolean publish(Path temporary, Path artifact, PublicationState publication) throws IOException {
try {
beforePublishMove.run(artifact);
Files.createLink(artifact, temporary);
+ publication.linked = true;
} catch (java.nio.file.FileAlreadyExistsException collision) {
verifyExistingArtifact(artifact, artifact.getFileName().toString().substring(0, 64));
return false;
}
try {
- Files.delete(temporary);
+ publicationDelete.run(temporary);
} catch (IOException failure) {
- try { Files.deleteIfExists(artifact); }
+ try { publicationDelete.run(artifact); }
catch (IOException rollbackFailure) { failure.addSuppressed(rollbackFailure); }
throw failure;
}
@@ -757,6 +770,7 @@ private static final class RecoveryRequiredException extends IOException {
private record DigestAndSize(String sha256, long size) { }
private record StoredFile(Path path, String artifactId, long size, long modified) { }
private record QuarantinedFile(Path original, Path backup) { }
+ private static final class PublicationState { private boolean linked; }
@FunctionalInterface interface IoAction { void run(Path path) throws IOException; }
@FunctionalInterface private interface IoSupplier { T run() throws IOException; }
}
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index 18b20a3..7f45795 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -719,11 +719,19 @@ private static void validateResultConfiguration(StoredOperation operation, Confi
|| (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName()))
|| (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset()))
|| (!expected.capability().equals(actual.capability())
- && (!activeMethodRead(operation, expected)
- || !node.acceptedCapabilities().contains(actual.capability())));
+ && !compatibleActiveMethodRead(operation, expected, actual, node));
if (mismatch) throw invalid("result configuration does not match the operation selector");
}
+ private static boolean compatibleActiveMethodRead(StoredOperation operation, ManagedConfiguration expected,
+ ManagedConfiguration actual, NodeStatus node) {
+ if (!activeMethodRead(operation, expected)) return false;
+ if (PROXY_METHOD_HTTP_CAPABILITY.equals(expected.capability())
+ && actual.options().containsKey("method")
+ && !"HTTP".equals(actual.options().get("method"))) return true;
+ return node.acceptedCapabilities().contains(actual.capability());
+ }
+
private static boolean activeMethodRead(StoredOperation operation, ManagedConfiguration expected) {
return "READ".equals(operation.type) && ManagedConfiguration.QUICK_SETUP.equals(expected.domain())
&& (ManagedConfiguration.PROXY_METHOD.equals(expected.preset())
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index bf06fd3..87f3304 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -323,6 +323,38 @@ class ArtifactStoreTest {
}
}
+ @Test void hardLinkCleanupFailureRetainsRecoveryStateUntilStartupCanReconcile() throws Exception {
+ Path artifacts = directory.resolve("hard-link-cleanup-artifacts");
+ AtomicBoolean failPublicationDeletes = new AtomicBoolean();
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/Incoming.class", new byte[] {2});
+ String oldId = sha256(old);
+ String incomingId = sha256(incoming);
+ ArtifactStore store = new ArtifactStore(artifacts, 1_000_000, 1,
+ path -> { }, path -> { }, path -> { }, path -> {
+ if (failPublicationDeletes.get()) throw new IOException("simulated delete failure");
+ Files.delete(path);
+ });
+ store.upload(new ByteArrayInputStream(old), "old.jar", oldId);
+
+ failPublicationDeletes.set(true);
+ assertRejected(() -> store.upload(new ByteArrayInputStream(incoming), "incoming.jar", incomingId));
+
+ assertArrayEquals(old, store.open(oldId).readAllBytes());
+ assertArrayEquals(incoming, store.open(incomingId).readAllBytes());
+ try (var entries = Files.list(artifacts)) {
+ assertTrue(entries.anyMatch(path -> path.getFileName().toString().endsWith(".pending")));
+ }
+
+ ArtifactStore recovered = new ArtifactStore(artifacts, 1_000_000, 1);
+ assertArrayEquals(old, recovered.open(oldId).readAllBytes());
+ assertRejected(() -> recovered.open(incomingId));
+ try (var entries = Files.list(artifacts)) {
+ assertFalse(entries.anyMatch(path -> path.getFileName().toString().startsWith("evict-")
+ || path.getFileName().toString().startsWith("upload-")));
+ }
+ }
+
@Test void startupRestoresAnInterruptedEvictionQuarantine() throws Exception {
Path artifacts = directory.resolve("artifacts");
byte[] jar = jar("name: VotingPlugin\n", "plugin/Main.class", new byte[] {1});
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index aab0737..e086098 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -138,6 +138,29 @@ class ConfigurationOperationsTest {
assertEquals("HTTP", read.results().get("lobby").configuration().options().get("method"));
}
+ @Test void proxyBackendV2ReadAcceptsInstalledLegacyMethodWithoutV1Negotiation() throws Exception {
+ Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
+ InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
+ UUID session = UUID.randomUUID();
+ registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1,
+ Set.of(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY), Set.of()));
+ ConfigurationOperations operations = new ConfigurationOperations(registry,
+ new ConfigurationAuditLog(directory, clock), clock);
+ ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "proxy-backend", Map.of("method", "HTTP"));
+ ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector);
+ ConfigurationTask task = operations.claim("lobby", session);
+ ManagedConfiguration installed = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "proxy-backend", Map.of("method", "PLUGINMESSAGING"));
+
+ read = operations.complete(read.operationId(), "lobby",
+ new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), installed,
+ List.of(), false, false, task.attemptId()));
+
+ assertEquals("SUCCEEDED", read.state());
+ assertEquals("PLUGINMESSAGING", read.results().get("lobby").configuration().options().get("method"));
+ }
+
@Test void proxyBackendReadRejectsInstalledMethodFromAnUnnegotiatedCapability() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
From 979de2eb935f515df15077004d447ee8054c26fc Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Tue, 15 Sep 2026 05:11:43 -0600
Subject: [PATCH 28/30] Recover artifacts before access
---
.../control/artifact/ArtifactStore.java | 2 ++
.../domain/ConfigurationOperations.java | 5 +++++
.../control/artifact/ArtifactStoreTest.java | 5 +++--
.../domain/ConfigurationOperationsTest.java | 21 +++++++++++++++++++
4 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 294639e..43bf6f7 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -501,6 +501,7 @@ public InputStream open(String artifactId) throws IOException {
if (!isSha256(artifactId)) throw rejected();
try {
return withDirectoryLock(() -> {
+ removeIncompleteUploads();
Path artifact = artifactPath(artifactId);
verifyExistingArtifact(artifact, artifactId);
return Channels.newInputStream(FileChannel.open(artifact,
@@ -516,6 +517,7 @@ public Artifact describe(String artifactId) throws IOException {
if (!isSha256(artifactId)) throw rejected();
try {
return withDirectoryLock(() -> {
+ removeIncompleteUploads();
Path artifact = artifactPath(artifactId);
verifyExistingArtifact(artifact, artifactId);
return new Artifact(artifactId, "VotingPlugin.jar", Files.size(artifact));
diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
index 7f45795..d061c0e 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -726,6 +726,11 @@ private static void validateResultConfiguration(StoredOperation operation, Confi
private static boolean compatibleActiveMethodRead(StoredOperation operation, ManagedConfiguration expected,
ManagedConfiguration actual, NodeStatus node) {
if (!activeMethodRead(operation, expected)) return false;
+ try {
+ actual.validateProposal();
+ } catch (IllegalArgumentException invalidMethod) {
+ return false;
+ }
if (PROXY_METHOD_HTTP_CAPABILITY.equals(expected.capability())
&& actual.options().containsKey("method")
&& !"HTTP".equals(actual.options().get("method"))) return true;
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 87f3304..b64d858 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -341,9 +341,10 @@ class ArtifactStoreTest {
assertRejected(() -> store.upload(new ByteArrayInputStream(incoming), "incoming.jar", incomingId));
assertArrayEquals(old, store.open(oldId).readAllBytes());
- assertArrayEquals(incoming, store.open(incomingId).readAllBytes());
+ assertRejected(() -> store.open(incomingId));
+ assertRejected(() -> store.describe(incomingId));
try (var entries = Files.list(artifacts)) {
- assertTrue(entries.anyMatch(path -> path.getFileName().toString().endsWith(".pending")));
+ assertFalse(entries.anyMatch(path -> path.getFileName().toString().endsWith(".pending")));
}
ArtifactStore recovered = new ArtifactStore(artifacts, 1_000_000, 1);
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
index e086098..1aef434 100644
--- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java
@@ -161,6 +161,27 @@ class ConfigurationOperationsTest {
assertEquals("PLUGINMESSAGING", read.results().get("lobby").configuration().options().get("method"));
}
+ @Test void proxyBackendV2ReadRejectsAnUnknownInstalledMethod() throws Exception {
+ Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
+ InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
+ UUID session = UUID.randomUUID();
+ registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1,
+ Set.of(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY), Set.of()));
+ ConfigurationOperations operations = new ConfigurationOperations(registry,
+ new ConfigurationAuditLog(directory, clock), clock);
+ ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "proxy-backend", Map.of("method", "HTTP"));
+ ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector);
+ ConfigurationTask task = operations.claim("lobby", session);
+ ManagedConfiguration invalid = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(),
+ null, null, "proxy-backend", Map.of("method", "NOT_A_METHOD"));
+
+ assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class,
+ () -> operations.complete(read.operationId(), "lobby",
+ new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), invalid,
+ List.of(), false, false, task.attemptId()))).code());
+ }
+
@Test void proxyBackendReadRejectsInstalledMethodFromAnUnnegotiatedCapability() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC);
InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2));
From f6f90ab8efbbe9f2c976b4f8c510c1955b85f8fc Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Tue, 15 Sep 2026 05:26:29 -0600
Subject: [PATCH 29/30] Keep artifact recovery within capacity
---
.../votingplugin/control/artifact/ArtifactStore.java | 4 ++--
.../votingplugin/control/artifact/ArtifactStoreTest.java | 9 ++++++---
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 43bf6f7..089aa89 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -184,8 +184,8 @@ private void recoverEvictionTransactions() throws IOException {
pendingCollision = !Files.isSameFile(staged, incoming);
if (!pendingCollision) Files.delete(incoming);
} else {
- boolean legacyCollision = legacyMarker && hasMatchingStagedUpload(files, incomingId);
- if (!legacyCollision) Files.delete(incoming);
+ pendingCollision = legacyMarker && hasMatchingStagedUpload(files, incomingId);
+ if (!pendingCollision) Files.delete(incoming);
}
}
if (committed || pendingCollision) {
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index b64d858..59a07c9 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -404,7 +404,7 @@ class ArtifactStoreTest {
Path artifacts = directory.resolve("legacy-collision-artifacts");
byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
byte[] incoming = jar("name: VotingPlugin\n", "plugin/New.class", new byte[] {2});
- ArtifactStore store = new ArtifactStore(artifacts);
+ ArtifactStore store = new ArtifactStore(artifacts, 1_000_000, 1);
String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
String incomingId = sha256(incoming);
String transaction = "6".repeat(32);
@@ -415,13 +415,16 @@ class ArtifactStoreTest {
Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending");
Files.createFile(marker);
- ArtifactStore recovered = new ArtifactStore(artifacts);
+ ArtifactStore recovered = new ArtifactStore(artifacts, 1_000_000, 1);
- assertArrayEquals(old, recovered.open(oldId).readAllBytes());
+ assertRejected(() -> recovered.open(oldId));
assertArrayEquals(incoming, recovered.open(incomingId).readAllBytes());
assertFalse(Files.exists(staged));
assertFalse(Files.exists(quarantine));
assertFalse(Files.exists(marker));
+ try (var files = Files.list(artifacts)) {
+ assertEquals(1, files.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
+ }
}
@Test void uploadsSharingADirectorySerializeCapacityPlanningAndPublication() throws Exception {
From 6f0cb1de6d9b65036b383743e95ceb92c1b9cf60 Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Tue, 15 Sep 2026 15:56:02 -0600
Subject: [PATCH 30/30] Fail closed on legacy artifact recovery overflow
---
.../control/artifact/ArtifactStore.java | 27 +++++++++++++++++++
.../control/artifact/ArtifactStoreTest.java | 26 ++++++++++++++++++
2 files changed, 53 insertions(+)
diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
index 089aa89..622afc9 100644
--- a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -193,6 +193,7 @@ private void recoverEvictionTransactions() throws IOException {
// A verified competing publication is equivalent to the commit point: keeping
// the quarantined artifacts would violate the configured capacity bound.
// The staged upload is removed by removeIncompleteUploads after this marker.
+ requireRecoveredCapacity(plannedArtifactIds);
completePlannedEvictions(transaction, plannedArtifactIds, quarantined);
for (QuarantinedFile file : quarantined) Files.delete(file.backup());
} else {
@@ -204,6 +205,32 @@ private void recoverEvictionTransactions() throws IOException {
}
}
+ /**
+ * Ensures that a recovered commit remains within the capacity that was configured for
+ * this store. Only evictions recorded in the durable marker may be projected away.
+ * In particular, an old-format empty pending marker has no authority to delete a
+ * canonical artifact merely because a matching competing publication exists.
+ */
+ private void requireRecoveredCapacity(List plannedArtifactIds) throws IOException {
+ Set planned = new HashSet<>(plannedArtifactIds);
+ long bytes = 0;
+ int count = 0;
+ try (var files = Files.list(directory)) {
+ for (Path file : files.toList()) {
+ String name = file.getFileName().toString();
+ if (!name.matches("[0-9a-f]{64}\\.jar")) continue;
+ String artifactId = name.substring(0, 64);
+ if (planned.contains(artifactId)) continue;
+ verifyExistingArtifact(file, artifactId);
+ bytes = Math.addExact(bytes, Files.size(file));
+ count = Math.addExact(count, 1);
+ }
+ } catch (ArithmeticException failure) {
+ throw rejected();
+ }
+ if (count > maximumStoredArtifacts || bytes > maximumStoredBytes) throw rejected();
+ }
+
/** Completes a durable eviction plan after the incoming artifact reached its commit point. */
private void completePlannedEvictions(String transaction, List plannedArtifactIds,
List quarantined) throws IOException {
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
index 59a07c9..ddf52ee 100644
--- a/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -427,6 +427,32 @@ class ArtifactStoreTest {
}
}
+ @Test void legacyPendingCollisionBeforeAnyEvictionFailsClosedAtCapacity() throws Exception {
+ Path artifacts = directory.resolve("legacy-pre-eviction-collision-artifacts");
+ byte[] old = jar("name: VotingPlugin\n", "plugin/Old.class", new byte[] {1});
+ byte[] incoming = jar("name: VotingPlugin\n", "plugin/New.class", new byte[] {2});
+ ArtifactStore store = new ArtifactStore(artifacts, 1_000_000, 1);
+ String oldId = store.upload(new ByteArrayInputStream(old), "old.jar", sha256(old)).artifactId();
+ String incomingId = sha256(incoming);
+ String transaction = "8".repeat(32);
+ Path oldArtifact = artifacts.resolve(oldId + ".jar");
+ Path incomingArtifact = artifacts.resolve(incomingId + ".jar");
+ Files.write(incomingArtifact, incoming);
+ Path staged = Files.write(artifacts.resolve("upload-legacy-pre-eviction.part"), incoming);
+ Path marker = artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending");
+ Files.createFile(marker);
+
+ assertRejected(() -> new ArtifactStore(artifacts, 1_000_000, 1));
+
+ assertArrayEquals(old, Files.readAllBytes(oldArtifact));
+ assertArrayEquals(incoming, Files.readAllBytes(incomingArtifact));
+ assertArrayEquals(incoming, Files.readAllBytes(staged));
+ assertTrue(Files.exists(marker));
+ try (var files = Files.list(artifacts)) {
+ assertEquals(2, files.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
+ }
+ }
+
@Test void uploadsSharingADirectorySerializeCapacityPlanningAndPublication() throws Exception {
Path artifacts = directory.resolve("shared-artifacts");
CountDownLatch firstPublishing = new CountDownLatch(1);