@@ -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
new file mode 100644
index 0000000..622afc9
--- /dev/null
+++ b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java
@@ -0,0 +1,805 @@
+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.channels.FileLock;
+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.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;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+/**
+ * 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 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(
+ PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
+ private static final ConcurrentMap DIRECTORY_LOCKS = new ConcurrentHashMap<>();
+
+ private final Path directory;
+ private final long maximumStoredBytes;
+ private final int maximumStoredArtifacts;
+ 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 {
+ this(directory, MAX_STORED_BYTES, MAX_STORED_ARTIFACTS);
+ }
+
+ 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 {
+ this(directory, maximumStoredBytes, maximumStoredArtifacts, path -> { }, afterPublishMove);
+ }
+
+ 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 {
+ 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
+ || 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(() -> {
+ removeIncompleteUploads();
+ return null;
+ });
+ } catch (ArtifactException failure) {
+ throw failure;
+ } catch (IOException | RuntimeException failure) {
+ throw rejected();
+ }
+ }
+
+ private void removeIncompleteUploads() throws IOException {
+ recoverEvictionTransactions();
+ try (var files = Files.list(directory)) {
+ for (Path file : files.toList()) {
+ String name = file.getFileName().toString();
+ 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();
+ }
+ Files.delete(file);
+ }
+ }
+ 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) > 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) {
+ 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);
+ boolean pendingCollision = false;
+ if (!committed && 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 {
+ pendingCollision = legacyMarker && hasMatchingStagedUpload(files, incomingId);
+ if (!pendingCollision) 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.
+ requireRecoveredCapacity(plannedArtifactIds);
+ completePlannedEvictions(transaction, plannedArtifactIds, quarantined);
+ for (QuarantinedFile file : quarantined) Files.delete(file.backup());
+ } else {
+ restoreQuarantined(quarantined);
+ }
+ DurableFiles.forceDirectory(directory);
+ Files.delete(marker);
+ DurableFiles.forceDirectory(directory);
+ }
+ }
+
+ /**
+ * 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 {
+ 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();
+ 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
+ * 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();
+ }
+ 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;
+ boolean recoveryPending = false;
+ try {
+ verifyDirectory();
+ // 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);
+ 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());
+ }
+ 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 && !recoveryPending && 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;
+ 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();
+ List evictionPlan = new ArrayList<>();
+ for (StoredFile candidate : stored) {
+ if (count < maximumStoredArtifacts && bytes <= maximumStoredBytes - incomingBytes) break;
+ if (protectedArtifactIds.contains(candidate.artifactId())) continue;
+ evictionPlan.add(candidate);
+ bytes -= candidate.size();
+ count--;
+ }
+ if (count >= maximumStoredArtifacts || bytes > maximumStoredBytes - incomingBytes) throw rejected();
+ return List.copyOf(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;
+ boolean collision = false;
+ PublicationState publication = new PublicationState();
+ try {
+ createTransactionMarker(pending, temporary.getFileName().toString(), evictionPlan);
+ for (StoredFile candidate : evictionPlan) {
+ Path backup = directory.resolve("evict-" + transaction + "-" + candidate.artifactId() + ".part");
+ if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS)) throw rejected();
+ moveAtomically(candidate.path(), backup);
+ quarantined.add(new QuarantinedFile(candidate.path(), backup));
+ }
+ if (!quarantined.isEmpty()) DurableFiles.forceDirectory(directory);
+ moved = publish(temporary, artifact, publication);
+ if (!moved) {
+ // A verified content-address collision is a successful publication
+ // by another process. Commit this capacity transition instead of
+ // restoring evictions and leaving the store over its configured bounds.
+ collision = true;
+ afterCollision.run(artifact);
+ moveAtomically(pending, committed);
+ DurableFiles.forceDirectory(directory);
+ } else {
+ finishPublishedArtifact(artifact);
+ moveAtomically(pending, committed);
+ DurableFiles.forceDirectory(directory);
+ }
+ } catch (IOException | RuntimeException failure) {
+ moved |= publication.linked;
+ if (collision) throw new RecoveryRequiredException(failure);
+ IOException rollbackFailure = rollbackPublication(artifact, quarantined, pending, committed, moved);
+ if (rollbackFailure != null) failure.addSuppressed(rollbackFailure);
+ throw failure;
+ }
+ 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. */
+ }
+ return moved;
+ }
+
+ private void createTransactionMarker(Path marker, String stagedName, 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(serializedBytes);
+ while (contents.hasRemaining()) {
+ if (channel.write(contents) <= 0) throw new IOException("Artifact transaction marker could not be written");
+ }
+ channel.force(true);
+ } catch (IOException | RuntimeException failure) {
+ cleanupTemporaryMarker(temporaryMarker, failure);
+ throw failure;
+ }
+ try {
+ Files.move(temporaryMarker, marker, StandardCopyOption.ATOMIC_MOVE);
+ published = true;
+ DurableFiles.forceDirectory(directory);
+ } catch (IOException | RuntimeException failure) {
+ if (!published) cleanupTemporaryMarker(temporaryMarker, failure);
+ throw failure;
+ }
+ }
+
+ private void cleanupTemporaryMarker(Path temporaryMarker, Throwable original) {
+ try {
+ if (Files.deleteIfExists(temporaryMarker)) DurableFiles.forceDirectory(directory);
+ } catch (IOException cleanupFailure) {
+ original.addSuppressed(cleanupFailure);
+ }
+ }
+
+ private IOException rollbackPublication(Path artifact, List quarantined,
+ Path pending, Path committed, boolean removeArtifact) {
+ IOException failure = null;
+ if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
+ try {
+ moveAtomically(committed, pending);
+ DurableFiles.forceDirectory(directory);
+ } catch (IOException problem) {
+ return problem;
+ }
+ }
+ try {
+ if (removeArtifact && Files.exists(artifact, LinkOption.NOFOLLOW_LINKS)) {
+ if (!Files.isRegularFile(artifact, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(artifact)) {
+ throw rejected();
+ }
+ publicationDelete.run(artifact);
+ }
+ } catch (IOException problem) {
+ failure = problem;
+ }
+ try { restoreQuarantined(quarantined); }
+ 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;
+ }
+
+ 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 {
+ moveAtomically(file.backup(), file.original());
+ }
+ }
+ }
+
+ /** Opens a verified immutable artifact by its opaque identifier. */
+ 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,
+ 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 {
+ return withDirectoryLock(() -> {
+ removeIncompleteUploads();
+ Path artifact = artifactPath(artifactId);
+ verifyExistingArtifact(artifact, artifactId);
+ return new Artifact(artifactId, "VotingPlugin.jar", Files.size(artifact));
+ });
+ } catch (IOException failure) {
+ throw rejected();
+ }
+ }
+
+ private static DigestAndSize copyBounded(InputStream source, Path target) throws IOException {
+ MessageDigest digest = sha256();
+ long total = 0;
+ byte[] bytes = new byte[16 * 1024];
+ try (FileChannel output = FileChannel.open(target, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ for (int read; (read = source.read(bytes)) != -1;) {
+ if (read == 0) continue;
+ total = Math.addExact(total, read);
+ if (total > MAX_UPLOAD_BYTES) throw rejected();
+ digest.update(bytes, 0, read);
+ ByteBuffer buffer = ByteBuffer.wrap(bytes, 0, read);
+ while (buffer.hasRemaining()) output.write(buffer);
+ }
+ output.force(true);
+ } catch (ArithmeticException failure) {
+ throw rejected();
+ }
+ return new DigestAndSize(hex(digest.digest()), total);
+ }
+
+ private static void inspectJar(Path file) throws IOException {
+ int entries = 0;
+ long expanded = 0;
+ int pluginYmlEntries = 0;
+ byte[] pluginYml = null;
+ Set entryNames = new HashSet<>();
+ try (ZipFile zip = new ZipFile(file.toFile(), StandardCharsets.UTF_8)) {
+ Enumeration extends ZipEntry> enumeration = zip.entries();
+ while (enumeration.hasMoreElements()) {
+ ZipEntry entry = enumeration.nextElement();
+ if (++entries > MAX_ENTRY_COUNT || !isSafeZipEntryName(entry.getName())
+ || !entryNames.add(entry.getName())) throw rejected();
+ long declaredSize = entry.getSize();
+ long compressedSize = entry.getCompressedSize();
+ if (declaredSize < 0 || declaredSize > MAX_ENTRY_BYTES || compressedSize < 0
+ || (compressedSize > 0 && declaredSize > compressedSize * MAX_COMPRESSION_RATIO)) {
+ throw rejected();
+ }
+ expanded = addBounded(expanded, declaredSize, MAX_EXPANDED_BYTES);
+ if ("plugin.yml".equals(entry.getName())) {
+ if (++pluginYmlEntries != 1 || entry.isDirectory() || declaredSize > MAX_PLUGIN_YML_BYTES) {
+ throw rejected();
+ }
+ pluginYml = readExactly(zip.getInputStream(entry), declaredSize);
+ } else if (!entry.isDirectory()) {
+ consumeBounded(zip.getInputStream(entry), declaredSize);
+ }
+ }
+ } catch (ArtifactException failure) {
+ throw failure;
+ } catch (IOException | RuntimeException failure) {
+ throw rejected();
+ }
+ if (entries == 0 || pluginYmlEntries != 1 || pluginYml == null || !declaresVotingPlugin(pluginYml)) throw rejected();
+ }
+
+ private static void consumeBounded(InputStream input, long expected) throws IOException {
+ long actual = 0;
+ byte[] buffer = new byte[8192];
+ try (input) {
+ for (int read; (read = input.read(buffer)) != -1;) {
+ actual = addBounded(actual, read, expected);
+ }
+ }
+ if (actual != expected) throw rejected();
+ }
+
+ private static byte[] readExactly(InputStream input, long expected) throws IOException {
+ byte[] result = new byte[(int) expected];
+ int offset = 0;
+ try (input) {
+ while (offset < result.length) {
+ int read = input.read(result, offset, result.length - offset);
+ if (read == -1) break;
+ if (read == 0) continue;
+ offset = Math.toIntExact(addBounded(offset, read, expected));
+ }
+ if (offset == result.length && input.read() != -1) throw rejected();
+ }
+ if (offset != expected) throw rejected();
+ return result;
+ }
+
+ private static boolean declaresVotingPlugin(byte[] pluginYml) throws IOException {
+ final String text;
+ try {
+ text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(pluginYml)).toString();
+ } catch (CharacterCodingException failure) {
+ throw rejected();
+ }
+ 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();
+ }
+ }
+
+ 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 {
+ publicationDelete.run(temporary);
+ } catch (IOException failure) {
+ try { publicationDelete.run(artifact); }
+ catch (IOException rollbackFailure) { failure.addSuppressed(rollbackFailure); }
+ throw failure;
+ }
+ 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)) {
+ channel.force(true);
+ }
+ DurableFiles.forceDirectory(directory);
+ }
+
+ /** 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();
+ }
+
+ 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 static final class RecoveryRequiredException extends IOException {
+ private RecoveryRequiredException(Throwable cause) { super("Artifact transaction requires recovery", cause); }
+ }
+
+ 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 ee3e0be..d061c0e 100644
--- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
+++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java
@@ -241,6 +241,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;
@@ -263,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;
@@ -378,6 +379,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",
@@ -557,11 +568,12 @@ 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);
}
- 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);
@@ -698,17 +710,33 @@ 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())
+ && !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;
+ 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;
+ 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/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/main/resources/web/app.js b/src/main/resources/web/app.js
index e5f071a..f24932e 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -264,8 +264,8 @@ let transportTestBackendId = '';
let proxyMethodProxyId = '';
let proxyMethodCurrentFor = '';
let proxyMethodCurrentSessionId = '';
+let proxyMethodCurrentReadCapability = '';
let proxyMethodCurrentValue = '';
-let proxyMethodReadGeneration = 0;
let nodeCapabilities = new Map();
let nodePlugins = new Map();
let inputGeneration = 0;
@@ -637,8 +637,6 @@ function renderSiteHealthResult(value) {
button.className = 'secondary compact';
button.addEventListener('click', () => {
const key = String(service).replace(/[^A-Za-z0-9_-]/g, '-').replace(/-+/g, '-').slice(0, 64) || 'vote-site';
- quickSetupDirty = false;
- quickSetupPreserveReadGeneration = -1;
quickPreset.value = 'vote-site';
quickName.value = key;
quickSiteDisplayName.value = String(service).slice(0, 200);
@@ -646,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. Its generated key is loading automatically; 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);
@@ -1173,7 +1173,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,
@@ -1182,7 +1182,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,
@@ -1191,6 +1191,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() {
@@ -1260,6 +1267,7 @@ function applyAuthenticatedSession(body) {
proxyMethodProxyId = '';
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
fileReadCache.clear();
lastFileReadOperation = null;
@@ -1434,7 +1442,7 @@ function nodeCard(node) {
approvedQuickPreview = null;
dedicatedSetupApprovals.clear();
inputGeneration++;
- handleQuickTargetCapabilityChange(previousQuickCapability);
+ reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability);
updatePluginSuggestions();
renderSelectedServer();
updateConfigurationButtons();
@@ -2594,6 +2602,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';
}
@@ -2639,10 +2656,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 &&
@@ -2854,6 +2874,7 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) {
proxyMethodProxyId = '';
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
fileReadCache.clear();
text(networkDoctorResults, reason);
@@ -2911,9 +2932,14 @@ 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 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;
@@ -2923,6 +2949,14 @@ 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.');
+ }
+ 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 => {
@@ -2942,24 +2976,29 @@ function backendQuickTargets() {
}
function quickSetupCapability() {
- if (quickPreset.value === 'proxy-backend') {
- const capability = quickMethod.value === 'HTTP' ? 'config.proxy-method.v2' : 'config.quick-setup.v1';
- return selectedBackendsSupport(capability) ? capability : null;
- }
- return quickPreset.value === 'vote-party' ? votePartyCapability() : 'config.quick-setup.v1';
+ 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 selectedBackendsSupport(capability) {
- const selectedBackends = [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId)
- && isBackend(nodeIndex.get(nodeId)));
- return selectedBackends.length > 0
- && selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes(capability));
+function votePartyUsesV2() {
+ return votePartyCommonCapability() === 'config.quick-setup.v2';
}
-function votePartyCapability() {
- const selectedBackends = [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId)
- && isBackend(nodeIndex.get(nodeId)));
- if (selectedBackends.length === 0) return null;
+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';
}
@@ -2969,17 +3008,26 @@ function votePartyCapability() {
return null;
}
-function handleQuickTargetCapabilityChange(previousCapability) {
+function reloadVotePartyWhenTargetCapabilityChanges(previousCapability, scheduleReload = true) {
if (quickPreset.value !== 'vote-party' || previousCapability === quickSetupCapability()) return;
loadedQuickSetup = null;
approvedQuickPreview = null;
if (quickSetupDirty) {
- readQuickSetup.hidden = false;
+ // The capability-dependent Enabled field must be refreshed, but the
+ // operator's unsaved common Vote Party edits remain authoritative locally.
+ quickSetupPreserveReadGeneration = inputGeneration;
text(quickOperationStatus,
- 'The selected backends require a different Vote Party capability. Retry loading to discard unsaved edits.');
- } else if (tabFromHash() === 'quick-setup') {
- void autoLoadTab('quick-setup');
+ '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
+ // starting overlapping READ operations that can race to populate the form.
+ if (scheduleReload && tabFromHash() === 'quick-setup') void autoLoadTab('quick-setup');
}
function quickSetupTargets() {
@@ -3139,6 +3187,7 @@ function discardAuthenticationState(reason) {
proxyMethodProxyId = '';
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
fileReadCache.clear();
lastFileReadOperation = null;
@@ -3520,8 +3569,8 @@ async function loadNodesOnce() {
text(message, 'Loading…');
try {
const registry = await loadAllNodes();
- const previousNodeIndex = nodeIndex;
const previousQuickCapability = quickSetupCapability();
+ const previousNodeIndex = nodeIndex;
allNodeItems = registry.items;
nodePageMetadata = registry.pageMetadata;
selectNodePage(pageOffset);
@@ -3540,25 +3589,13 @@ async function loadNodesOnce() {
nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []]));
nodePlugins = new Map(registry.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins)
? node.detectedPlugins : []]));
- const proxyMethodCapabilityNodes = new Set([...selectedNodes, proxyMethodProxyId].filter(Boolean));
- const proxyMethodCapabilitiesChanged = [...proxyMethodCapabilityNodes].some(node =>
- ['config.proxy-method.v1', 'config.proxy-method.v2'].some(capability =>
- Boolean(previousCapabilities.get(node)?.includes(capability)) !==
- Boolean(nodeCapabilities.get(node)?.includes(capability))));
const selectedCapabilitiesChanged = [...selectedNodes].some(node =>
['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1',
- 'config.quick-setup.v2', 'config.proxy-method.v1', 'config.proxy-method.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 || proxyMethodCapabilitiesChanged) {
+ if (selectedCapabilitiesChanged) {
invalidateGuidedSetupReads();
- if (proxyMethodCapabilitiesChanged) {
- proxyMethodReadGeneration++;
- proxyMethodCurrentFor = '';
- proxyMethodCurrentSessionId = '';
- proxyMethodCurrentValue = '';
- }
approvedPreview = null;
approvedFilePreview = null;
approvedQuickPreview = null;
@@ -3616,7 +3653,10 @@ async function loadNodesOnce() {
text(operationStatus, routingDraftStatus('The selected nodes changed during refresh. Preview again before apply.'));
}
selectedNodes = filteredSelection;
- handleQuickTargetCapabilityChange(previousQuickCapability);
+ // 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();
@@ -4094,7 +4134,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;
@@ -4139,9 +4181,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;
@@ -4300,7 +4360,6 @@ runTransportTest.addEventListener('click', async () => {
async function loadProxyMethod(automatic = false) {
const proxyId = proxyMethodProxyId;
const readCapability = proxyMethodReadCapability();
- const readGeneration = proxyMethodReadGeneration;
const sessionId = proxyMethodNetwork(readCapability).proxy?.sessionId;
const requestAuthenticationGeneration = authenticationGeneration;
if (!proxyId) return;
@@ -4314,10 +4373,11 @@ 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
- || readGeneration !== proxyMethodReadGeneration || readCapability !== proxyMethodReadCapability()
- || 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;
proxyMethodCurrentValue = method;
renderProxyMethod();
text(proxyMethodStatus, `Active method on ${proxyId}: ${method}`);
@@ -4332,6 +4392,7 @@ proxyMethodProxy.addEventListener('change', () => {
proxyMethodProxyId = proxyMethodProxy.value;
proxyMethodCurrentFor = '';
proxyMethodCurrentSessionId = '';
+ proxyMethodCurrentReadCapability = '';
proxyMethodCurrentValue = '';
renderProxyMethod();
const network = proxyMethodReadNetwork();
@@ -4372,6 +4433,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();
}
@@ -4508,7 +4570,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 previousQuickCapability = quickSetupCapability();
const available = allNodeItems.filter(node => isBackend(node) && node.online
&& node.acceptedCapabilities.includes('config.quick-setup.v1'));
const candidates = available
@@ -4520,7 +4582,7 @@ selectAllAutoSitesTargets.addEventListener('click', () => {
approvedFilePreview = null;
approvedQuickPreview = null;
inputGeneration++;
- handleQuickTargetCapabilityChange(previousQuickCapability);
+ reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability);
renderNodeViews();
updatePluginSuggestions();
updateConfigurationButtons();
@@ -5110,7 +5172,7 @@ loadProfile.addEventListener('click', async () => {
updateQuickFields();
clearApprovals();
text(profileStatus, `Loading live values before applying “${profileName}”…`);
- if (quickPresetReadable() && !await loadQuickSetupValues(true, true)) {
+ if (quickPresetReadable() && !await loadQuickSetupValues(true)) {
text(profileStatus, `Could not load live values for “${profileName}”. Retry before using this profile.`);
return;
}
@@ -5122,6 +5184,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/artifact/ArtifactStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
new file mode 100644
index 0000000..ddf52ee
--- /dev/null
+++ b/src/test/java/com/bencodez/votingplugin/control/artifact/ArtifactStoreTest.java
@@ -0,0 +1,670 @@
+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.lang.reflect.Field;
+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.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;
+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.filter(path -> path.getFileName().toString().endsWith(".jar")).count());
+ }
+ }
+
+ @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"));
+ 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.filter(path -> path.getFileName().toString().endsWith(".jar")).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[] 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)));
+ }
+
+ @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 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);
+ 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);
+ 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.filter(path -> path.getFileName().toString().endsWith(".jar")).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));
+ }
+
+ @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());
+ }
+
+ @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 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});
+ 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());
+ 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-")
+ || path.getFileName().toString().startsWith("evict-")));
+ }
+ }
+
+ @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();
+ 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 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());
+ assertRejected(() -> store.open(incomingId));
+ assertRejected(() -> store.describe(incomingId));
+ try (var entries = Files.list(artifacts)) {
+ assertFalse(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});
+ 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-" + transaction + "-" + artifactId + ".part");
+ Files.move(canonical, quarantine);
+ Files.write(artifacts.resolve(incomingId + ".jar"), incoming);
+ Files.writeString(artifacts.resolve("evict-" + transaction + "-" + incomingId + ".pending"),
+ "upload-owned.part");
+
+ ArtifactStore recovered = new ArtifactStore(artifacts);
+
+ assertArrayEquals(jar, recovered.open(artifactId).readAllBytes());
+ assertRejected(() -> recovered.open(incomingId));
+ 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, 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);
+ 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, 1_000_000, 1);
+
+ 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 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);
+ 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 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, 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);
+ 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, 1_000_000, 1);
+
+ 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 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});
+ 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});
+ 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.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);
+
+ 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)) {
+ 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; }
+}
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 b2911c7..1aef434 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,71 @@ 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 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));
+ 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));
@@ -963,6 +1029,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);
@@ -1245,6 +1312,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 7514784..e2e63c9 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;"));
@@ -132,27 +135,22 @@ 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("return quickPreset.value === 'vote-party' ? votePartyCapability()"),
- "Vote Party must select only a capability shared by every selected backend.");
+ 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."),
+ "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"
+ " 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("function votePartyCapability()"));
- assertTrue(script.body().contains("return selectedBackendsSupport(capability) ? capability : null;"));
- assertTrue(script.body().contains("function selectedBackendsSupport(capability)"));
- assertTrue(script.body().contains("'config.quick-setup.v2', 'config.proxy-method.v1', 'config.proxy-method.v2'"));
- assertTrue(script.body().contains("if (selectedCapabilitiesChanged || proxyMethodCapabilitiesChanged) {\n invalidateGuidedSetupReads();"));
- assertTrue(script.body().contains("const proxyMethodCapabilityNodes = new Set([...selectedNodes, proxyMethodProxyId].filter(Boolean));"));
- assertTrue(script.body().contains("if (proxyMethodCapabilitiesChanged) {\n proxyMethodReadGeneration++;"));
- assertTrue(script.body().contains("readGeneration !== proxyMethodReadGeneration || readCapability !== proxyMethodReadCapability()"));
- assertTrue(script.body().contains("if (selectedBackends.length === 0) return null;"));
- assertTrue(script.body().contains("selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1'))"));
- assertTrue(script.body().contains("return null;\n}"),
- "Mixed Vote Party capability sets must be rejected instead of silently dropping selected backends.");
assertTrue(script.body().contains("nodeIds = quickSetupTargets()"));
assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()"));
assertTrue(script.body().contains("autoLoadPending.add(tab);"));
@@ -160,11 +158,25 @@ 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("function handleQuickTargetCapabilityChange(previousCapability)"));
- assertTrue(script.body().contains("The selected backends require a different Vote Party capability."),
- "A target-driven v1/v2 change must expose an explicit reload when dirty.");
- assertTrue(script.body().contains("handleQuickTargetCapabilityChange(previousQuickCapability);"),
- "Target selection changes must reload or expose the capability-correct Vote Party read.");
+ assertTrue(script.body().contains("reloadVotePartyWhenTargetCapabilityChanges(previousQuickCapability)"),
+ "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);"),
+ "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.");
@@ -177,6 +189,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');"),
@@ -191,24 +211,25 @@ 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("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("return {enabled: 'true'};"),
- "Vote Party v2 reads must not use the editable Enabled value as their selector.");
- assertTrue(script.body().contains("loadQuickSetupValues(true, true)"),
- "Profile loading must preserve its requested proxy method through the live read.");
- assertTrue(script.body().contains("const previousQuickCapability = quickSetupCapability();")
- && script.body().contains("selectedNodes = filteredSelection;\n handleQuickTargetCapabilityChange(previousQuickCapability);"),
- "Heartbeat capability changes must invalidate or re-expose Vote Party loading.");
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("quickSetupDirty = false;\n quickSetupPreserveReadGeneration = -1;\n quickPreset.value = 'vote-site';"),
- "Opening a detected vote site must clear another preset's dirty guard before automatic READ.");
- 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(script.body().contains("function exposeDirtyVoteSiteReload()"));
assertTrue(script.body().contains("quickSetupDirty = true;\n exposeDirtyVoteSiteReload();"),
"Becoming dirty during the selector debounce must also expose reload.");
@@ -219,6 +240,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"));
@@ -398,7 +423,8 @@ 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("approvedPreview.nodeIds.every"));
+ 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)"));
assertTrue(script.body().contains("discardAuthenticationState"));
@@ -769,10 +795,15 @@ 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.");
- assertTrue(script.body().contains("quickPresetReadable() && !await loadQuickSetupValues(true, true)"),
+ 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.");
@@ -783,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);