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 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);