diff --git a/docs/control-management.md b/docs/control-management.md index 755c3a8..766c0c3 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -141,6 +141,13 @@ Each target state is `QUEUED`, `IN_PROGRESS`, or `COMPLETE`; the aggregate state `COMPLETED_WITH_ERRORS`. A claim has a two-minute lease and new `attemptId`. The result must echo that attempt and the current node session, preventing a stale execution from completing reissued work. +The claimed `ConfigurationTask` also contains an exact `capability` field. Connectors must dispatch and validate the task +against that field rather than deriving a contract from `configuration`. In particular, an HTTP `proxy-backend` `READ` +uses `config.proxy-method.v2` while its task configuration deliberately omits the requested method so the current value is +not supplied as an answer. Legacy proxy-method reads carry `config.proxy-method.v1`. A connector must implement this +field and the matching v2 task/result contract before advertising `config.proxy-method.v2`; older connectors remain on +v1 and reject unsupported capability work through normal negotiation. + ### Retry behavior `POST /api/v1/operations/{operationId}/retry` creates a new operation; it never mutates the historical view. diff --git a/pom.xml b/pom.xml index 09b8c35..187a722 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ UTF-8 5.11.4 2.18.2 + 2.6 @@ -19,6 +20,9 @@ com.fasterxml.jackson.datatypejackson-datatype-jsr310${jackson.version} + + org.yamlsnakeyaml${snakeyaml.version} + org.junit.jupiterjunit-jupiter${junit.version}test diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java b/src/main/java/com/bencodez/votingplugin/control/artifact/ArtifactStore.java 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 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);