From c497c9f4f1eb1392a989d50a9344ce17fe3729a2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:52:14 +0000 Subject: [PATCH 1/7] feat(studio): migrate editable projects to character ownership --- .github/compatibility/tlibs-2.3.xml | 21 + .github/workflows/build.yml | 13 + .github/workflows/maven-release.yml | 13 + pom.xml | 13 + .../musicalinstruments/InstrumentPlugin.java | 1 + .../studio/CharacterStudioBridge.java | 53 ++ .../studio/CharacterStudioEstate.java | 82 ++++ .../studio/DurableFiles.java | 57 +++ .../musicalinstruments/studio/RawYaml.java | 34 ++ .../studio/StudioDialogs.java | 3 +- .../studio/StudioEstate.java | 19 + .../studio/StudioIdentity.java | 33 ++ .../musicalinstruments/studio/StudioMenu.java | 22 +- .../studio/StudioOwnership.java | 351 +++++++++++++ .../studio/StudioService.java | 189 +++++-- .../studio/StudioStore.java | 124 +++-- src/main/resources/plugin.yml | 2 +- .../InstrumentPluginTest.java | 8 + .../studio/StudioBridgeTest.java | 129 +++++ .../studio/StudioCharacterTest.java | 85 ++++ .../studio/StudioDialogWorkflowTest.java | 14 + .../studio/StudioEstateTest.java | 106 ++++ .../studio/StudioMenuTest.java | 21 + .../studio/StudioOwnershipTest.java | 464 ++++++++++++++++++ .../studio/StudioSaveQueueTest.java | 31 +- .../studio/StudioServiceTest.java | 158 ++++-- .../studio/StudioStoreRecoveryTest.java | 8 +- .../studio/StudioStoreTest.java | 28 +- .../studio/TestIdentity.java | 26 + tools/studio-ownership.py | 122 +++++ tools/test_studio_ownership.py | 83 ++++ 31 files changed, 2148 insertions(+), 165 deletions(-) create mode 100644 .github/compatibility/tlibs-2.3.xml create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioBridge.java create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/DurableFiles.java create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/RawYaml.java create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/StudioEstate.java create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/StudioIdentity.java create mode 100644 src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioBridgeTest.java create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioEstateTest.java create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/TestIdentity.java create mode 100755 tools/studio-ownership.py create mode 100644 tools/test_studio_ownership.py diff --git a/.github/compatibility/tlibs-2.3.xml b/.github/compatibility/tlibs-2.3.xml new file mode 100644 index 0000000..2851e42 --- /dev/null +++ b/.github/compatibility/tlibs-2.3.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + net.tfminecraft + studio-compatibility-fixture + main-SNAPSHOT + + 21 + UTF-8 + + + + me.plugins + tlibs + 2.3.0 + provided + + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a6c6a2..af9ffc5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,19 @@ jobs: java-version: '21' cache: maven + - name: Install pinned shared plugins + uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74 + with: + mode: pinned + token: ${{ github.token }} + + - name: Install older API compatibility fixture + uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74 + with: + mode: pinned + pom: .github/compatibility/tlibs-2.3.xml + lock: .build/legacy-plugin-dependencies.json + - name: Set build version id: dev shell: bash diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 159ccf6..00e5ad6 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -36,6 +36,19 @@ jobs: distribution: temurin java-version: ${{ inputs.java-version }} + - name: Install pinned shared plugins + uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74 + with: + mode: pinned + token: ${{ github.token }} + + - name: Install older API compatibility fixture + uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74 + with: + mode: pinned + pom: .github/compatibility/tlibs-2.3.xml + lock: .build/legacy-plugin-dependencies.json + - name: Validate release version env: TAG: ${{ github.ref_name }} diff --git a/pom.xml b/pom.xml index 955e016..2385160 100644 --- a/pom.xml +++ b/pom.xml @@ -14,9 +14,22 @@ 21 UTF-8 + 2.6.0 + + me.plugins + tlibs + ${tlibs.version} + provided + + + * + * + + + org.junit.jupiter junit-jupiter diff --git a/src/main/java/net/tfminecraft/musicalinstruments/InstrumentPlugin.java b/src/main/java/net/tfminecraft/musicalinstruments/InstrumentPlugin.java index e9106ed..bcfe22e 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/InstrumentPlugin.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/InstrumentPlugin.java @@ -65,6 +65,7 @@ public void onEnable() { // ItemsAdder can enable after this plugin despite the softdepend, so check the station on the first tick. getServer().getScheduler().runTask(this, () -> studio.station().warnIfUnavailable(getLogger())); studioMenu = new StudioMenu(this, studio); + studio.startOwnership(studioMenu); StudioCommand musicCommand = new StudioCommand(studio, studioMenu); getCommand("music").setExecutor(musicCommand); getCommand("music").setTabCompleter(musicCommand); diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioBridge.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioBridge.java new file mode 100644 index 0000000..7ecf9c6 --- /dev/null +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioBridge.java @@ -0,0 +1,53 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.Characters; + +/** Loaded only after the optional TLibs character API has been probed. */ +public final class CharacterStudioBridge implements StudioIdentity { + public CharacterStudioBridge() {} + + static Token token(CharacterSession session) { + return new Token(new Owner(session.ref().ownerId(), session.ref().characterId()), session.sessionId()); + } + + public Optional current(Player player) { + if (player == null) return Optional.empty(); + return Characters.currentSession(player) + .filter(session -> session.ref().ownerId().equals(player.getUniqueId())) + .filter(Characters::isCurrent).map(CharacterStudioBridge::token); + } + + public boolean current(Token token) { + if (token == null) return false; + Player player = Bukkit.getPlayer(token.owner().account()); + return player != null && current(player).filter(token::equals).isPresent(); + } + + public boolean heirsReady() { return Characters.legacyHeirsReady(); } + public Optional heir(UUID account) { + if (account == null) return Optional.empty(); + return Characters.legacyHeir(account).filter(ref -> ref.ownerId().equals(account)) + .map(ref -> new Owner(ref.ownerId(), ref.characterId())); + } + + public static void start(Plugin plugin, StudioService studio, StudioMenu menu) { + plugin.getServer().getPluginManager().registerEvents(new Listener() { + @EventHandler(priority = EventPriority.LOWEST) + public void end(CharacterSessionEndEvent event) { + // The event's session is already noncurrent; finish with its captured owner. + studio.end(event.getPlayer(), token(event.getSession())); + menu.end(event.getPlayer()); + } + }, plugin); + } +} diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java new file mode 100644 index 0000000..4cd92ab --- /dev/null +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java @@ -0,0 +1,82 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.EstateParticipant; +import net.tfminecraft.tlibs.character.estate.Estates; +import net.tfminecraft.tlibs.character.estate.Holdings; +import net.tfminecraft.tlibs.character.estate.Outcome; + +/** Published recordings stay immutable; only the editable project's authority moves. */ +final class CharacterStudioEstate implements EstateParticipant { + static final String ID = "studio"; + private final StudioService studio; + private final StudioOwnership ownership; + private final Plugin plugin; + private final Map> reported = new HashMap<>(); + + CharacterStudioEstate(Plugin plugin, StudioService studio, StudioOwnership ownership) { + this.plugin = plugin; this.studio = studio; this.ownership = ownership; + } + + static void start(Plugin plugin, StudioService studio, StudioOwnership ownership) { + CharacterStudioEstate participant = new CharacterStudioEstate(plugin, studio, ownership); + plugin.getServer().getServicesManager().register(EstateParticipant.class, participant, plugin, ServicePriority.Normal); + plugin.getServer().getScheduler().runTaskTimer(plugin, participant::reconcile, 1L, 72_000L); + } + + public String id() { return ID; } + public Outcome settle(EstateDecision decision) { + if (!Estates.guardHolds(decision)) return Outcome.retry("The current estate decision does not hold"); + UUID source = decision.testator().characterId(); + if (!studio.quiescent(source)) return Outcome.retry("The original character's studio saves are still pending"); + try { + ownership.requireEstateReady(owner(decision.testator())); + if (decision.property().character().isEmpty()) { + var previous = ownership.retainedReceipts(decision.deathId(), decision.toString(), owner(decision.testator())); + return Outcome.done(Map.of("projects", previous.size()), previous); + } + CharacterRef recipient = decision.property().character().orElseThrow(); + if (!studio.quiescent(recipient.characterId())) return Outcome.retry("The recipient's studio saves are still pending"); + var moved = ownership.transfer(decision.deathId(), decision.toString(), owner(decision.testator()), owner(recipient), + () -> Estates.guardHolds(decision)); + studio.transferred(source); + studio.transferred(recipient.characterId()); + return Outcome.done(Map.of("projects", moved.size()), moved); + } catch (StudioOwnership.Conflict ex) { + return Outcome.blocked(ex.getMessage()); + } catch (IOException ex) { + return Outcome.retry("Could not save studio ownership: " + ex.getMessage()); + } + } + + public Holdings describe(UUID character) { + try { + var ids = ownership.holdings(character); + return ids.isEmpty() ? Holdings.NONE : new Holdings(Map.of("projects", ids.size()), ids); + } catch (IOException ex) { + return new Holdings(Map.of("heldLibraries", 1), java.util.List.of("Studio library needs recovery")); + } + } + + void reconcile() { + for (EstateDecision decision : Estates.decided()) { + try { + if (!Estates.guardHolds(decision)) continue; + Outcome outcome = settle(decision); + Map.Entry result = Map.entry(decision.revision(), outcome); + if (!result.equals(reported.get(decision.deathId())) + && Estates.report(decision.deathId(), ID, decision.revision(), outcome)) reported.put(decision.deathId(), result); + } catch (RuntimeException ex) { plugin.getLogger().warning("Studio estate failed: " + decision.deathId() + " " + ex); } + } + } + + private static StudioIdentity.Owner owner(CharacterRef ref) { return new StudioIdentity.Owner(ref.ownerId(), ref.characterId()); } +} diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/DurableFiles.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/DurableFiles.java new file mode 100644 index 0000000..98b8a71 --- /dev/null +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/DurableFiles.java @@ -0,0 +1,57 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +/** Ownership publications acknowledge both the file and containing directory. */ +final class DurableFiles { + private DurableFiles() {} + + static void write(Path target, byte[] bytes, boolean replace) throws IOException { + write(target, bytes, replace, true); + } + + static void write(Path target, byte[] bytes, boolean replace, boolean strict) throws IOException { + Path folder = target.toAbsolutePath().getParent(); + Files.createDirectories(folder); + Path temp = Files.createTempFile(folder, "studio-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + if (strict && !replace) { + // Atomic rename may overwrite even without REPLACE_EXISTING. A hard link cannot. + try { Files.createLink(target, temp); } + catch (UnsupportedOperationException ex) { throw new IOException("Durable no-clobber publication unavailable", ex); } + } else { + try { + if (replace) Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + else Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ex) { + if (strict) throw ex; + if (replace) Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + else Files.move(temp, target); + } + } + // Earlier failures may have left an existing but unsynced parent link. + for (Path path = folder; path != null; path = path.getParent()) force(path); + } finally { + Files.deleteIfExists(temp); + } + } + + static void delete(Path target) throws IOException { + if (Files.deleteIfExists(target)) force(target.toAbsolutePath().getParent()); + } + + static void force(Path folder) throws IOException { + try (FileChannel channel = FileChannel.open(folder, StandardOpenOption.READ)) { channel.force(true); } + } +} diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/RawYaml.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/RawYaml.java new file mode 100644 index 0000000..5f90d43 --- /dev/null +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/RawYaml.java @@ -0,0 +1,34 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; + +/** Bukkit's configuration setter removes nulls; raw project extension maps must keep them. */ +final class RawYaml { + private RawYaml() {} + + static Map read(byte[] bytes) throws IOException { + LoaderOptions options = new LoaderOptions(); + options.setCodePointLimit(StudioStore.MAX_FILE_BYTES); + options.setAllowDuplicateKeys(false); + try { + Object value = new Yaml(new SafeConstructor(options)).load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString()); + if (!(value instanceof Map map)) throw new IOException("Project document is not a mapping"); + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) throw new IOException("Project property name is not text"); + result.put(key, entry.getValue()); + } + return result; + } catch (YAMLException ex) { throw new IOException("Unreadable project YAML", ex); } + } + + static byte[] bytes(Map values) { return new Yaml().dump(values).getBytes(StandardCharsets.UTF_8); } +} diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java index f3da5b4..3b46d36 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java @@ -149,11 +149,12 @@ private DialogAction action(Player owner, Location station, AtomicBoolean used, private DialogAction action(Player owner, Location station, AtomicBoolean used, Response response, Reopen destination) { UUID ownerId = owner.getUniqueId(); + StudioIdentity.Token token = studio.token(owner); return DialogAction.customClick((view, audience) -> { if (!(audience instanceof Player target) || !target.getUniqueId().equals(ownerId) || !used.compareAndSet(false, true) || !plugin.isEnabled()) return; plugin.getServer().getScheduler().runTask(plugin, () -> { - if (!target.isOnline()) return; + if (!target.isOnline() || !studio.current(token, target)) return; try { Block block = studio.station().require(target, station); response.apply(target, view); diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioEstate.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioEstate.java new file mode 100644 index 0000000..c2d6be2 --- /dev/null +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioEstate.java @@ -0,0 +1,19 @@ +package net.tfminecraft.musicalinstruments.studio; + +import org.bukkit.plugin.Plugin; + +/** The optional estate implementation is resolved only after all required API classes. */ +final class StudioEstate { + private StudioEstate() {} + static void startIfAvailable(Plugin plugin, StudioService studio, StudioOwnership ownership) { + try { + for (String name : new String[] {"Estates", "EstateParticipant", "EstateDecision", "Outcome"}) + Class.forName("net.tfminecraft.tlibs.character.estate." + name); + Class.forName("net.tfminecraft.musicalinstruments.studio.CharacterStudioEstate") + .getDeclaredMethod("start", Plugin.class, StudioService.class, StudioOwnership.class) + .invoke(null, plugin, studio, ownership); + } catch (ReflectiveOperationException | LinkageError ex) { + plugin.getLogger().warning("Studio estate integration unavailable; projects stay with their owners: " + ex); + } + } +} diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioIdentity.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioIdentity.java new file mode 100644 index 0000000..c2249bc --- /dev/null +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioIdentity.java @@ -0,0 +1,33 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; + +/** Immutable routing tokens keep the optional character API outside the studio's models. */ +public interface StudioIdentity { + record Owner(UUID account, UUID character) {} + record Token(Owner owner, long session) {} + + Optional current(Player player); + boolean current(Token token); + boolean heirsReady(); + Optional heir(UUID account); + + static StudioIdentity load() { + try { + Class api = Class.forName("net.tfminecraft.tlibs.character.Characters"); + api.getMethod("legacyHeir", UUID.class); + api.getMethod("legacyHeirsReady"); + return (StudioIdentity) Class.forName("net.tfminecraft.musicalinstruments.studio.CharacterStudioBridge") + .getConstructor().newInstance(); + } catch (ReflectiveOperationException | LinkageError ex) { + return new StudioIdentity() { + public Optional current(Player player) { return Optional.empty(); } + public boolean current(Token token) { return false; } + public boolean heirsReady() { return false; } + public Optional heir(UUID account) { return Optional.empty(); } + }; + } + } +} diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java index d818d9b..80c7d7d 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java @@ -53,7 +53,7 @@ public StudioMenu(InstrumentPlugin plugin, StudioService studio) { private void open(Player player, Block station, Screen screen, int page) throws IOException { studio.station().require(player, station); UUID projectId = screen == Screen.LIBRARY ? null : studio.requireProject(player).song().id(); - Holder holder = new Holder(player.getUniqueId(), station == null ? null : station.getLocation(), screen, page, projectId); + Holder holder = new Holder(studio.token(player), station == null ? null : station.getLocation(), screen, page, projectId); holder.inventory = plugin.getServer().createInventory(holder, screen == Screen.SETTINGS ? 27 : 54, Component.text(screen == Screen.LIBRARY ? "\u266b Your song projects" : screen == Screen.SETTINGS ? "\u266b Song settings" : "\u266b Recording Studio", NamedTextColor.DARK_GRAY)); @@ -189,7 +189,7 @@ public void click(InventoryClickEvent event) { if (event.getRawSlot() >= holder.inventory.getSize() && (event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT)) return; event.setCancelled(true); - if (!(event.getWhoClicked() instanceof Player player) || !holder.owner.equals(player.getUniqueId()) + if (!(event.getWhoClicked() instanceof Player player) || !studio.current(holder.token, player) || event.getRawSlot() < 0 || event.getRawSlot() >= holder.inventory.getSize() || !(event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_LEFT || event.getClick() == ClickType.SHIFT_RIGHT)) return; @@ -200,7 +200,7 @@ public void click(InventoryClickEvent event) { return; } plugin.getServer().getScheduler().runTask(plugin, () -> { - if (!player.isOnline() || player.getOpenInventory().getTopInventory() != holder.inventory) return; + if (!player.isOnline() || !studio.current(holder.token, player) || player.getOpenInventory().getTopInventory() != holder.inventory) return; try { Block station = studio.station().require(player, holder.station); if (raw == 8 && holder.screen != Screen.LIBRARY) { player.closeInventory(); return; } @@ -269,7 +269,7 @@ private void deposit(Player player, Holder holder, ItemStack cursor) { } ItemStack original = cursor.clone(); plugin.getServer().getScheduler().runTask(plugin, () -> { - if (!player.isOnline() || player.getOpenInventory().getTopInventory() != holder.inventory + if (!player.isOnline() || !studio.current(holder.token, player) || player.getOpenInventory().getTopInventory() != holder.inventory || !original.equals(player.getItemOnCursor())) return; try { studio.station().require(player, holder.station); @@ -298,7 +298,7 @@ private void refresh() { for (Player player : plugin.getServer().getOnlinePlayers()) { if (!(open(player) instanceof Holder holder)) continue; try { - if (!holder.owner.equals(player.getUniqueId())) { player.closeInventory(); continue; } + if (!studio.current(holder.token, player)) { player.closeInventory(); continue; } studio.station().require(player, holder.station); if (holder.screen == Screen.LIBRARY) continue; Project project = studio.requireProject(player); @@ -315,11 +315,15 @@ public void drag(InventoryDragEvent event) { if (event.getRawSlots().stream().noneMatch(slot -> slot < holder.inventory.getSize())) return; event.setCancelled(true); if (holder.screen == Screen.EDITOR && event.getRawSlots().equals(java.util.Set.of(50)) - && event.getWhoClicked() instanceof Player player && holder.owner.equals(player.getUniqueId())) { + && event.getWhoClicked() instanceof Player player && studio.current(holder.token, player)) { deposit(player, holder, event.getOldCursor()); } } + public void end(Player player) { + if (open(player) instanceof Holder) player.closeInventory(); + } + public void close() { refresher.cancel(); for (Player player : plugin.getServer().getOnlinePlayers()) { @@ -333,7 +337,7 @@ private static InventoryHolder open(Player player) { } private static final class Holder implements InventoryHolder { - private final UUID owner; + private final StudioIdentity.Token token; private final Location station; private final Screen screen; private final UUID projectId; @@ -346,8 +350,8 @@ private static final class Holder implements InventoryHolder { private ItemStack overwriteDisc; private Project overwriteProject; private long overwriteDeadline; - private Holder(UUID owner, Location station, Screen screen, int page, UUID projectId) { - this.owner = owner; this.station = station; this.screen = screen; this.page = page; this.projectId = projectId; + private Holder(StudioIdentity.Token token, Location station, Screen screen, int page, UUID projectId) { + this.token = token; this.station = station; this.screen = screen; this.page = page; this.projectId = projectId; } @Override public Inventory getInventory() { return inventory; } } diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java new file mode 100644 index 0000000..89c633a --- /dev/null +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java @@ -0,0 +1,351 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Properties; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +/** Raw project publications with immutable source bytes and checked replay receipts. */ +final class StudioOwnership { + static final class Conflict extends IOException { + Conflict(String message) { super(message); } + } + + private final StudioStore store; + private final Path root; + private record Stamp(long size, java.nio.file.attribute.FileTime modified, Object key) {} + private record Locks(Stamp stamp, java.util.Set characters) {} + private final java.util.Map lockCache = new java.util.HashMap<>(); + StudioOwnership(StudioStore store) { this.store = store; this.root = store.root(); } + + void migrate(StudioIdentity identity) throws IOException { + if (!identity.heirsReady()) return; + Path legacy = root.resolve("projects"); + if (!Files.isDirectory(legacy)) return; + for (Path folder : files(legacy)) { + if (!Files.isDirectory(folder)) { holdUnrecognized(folder); continue; } + UUID account; + try { account = UUID.fromString(folder.getFileName().toString()); } + catch (IllegalArgumentException ex) { holdUnrecognized(folder); continue; } + Path journalFile = root.resolve("ownership/migration").resolve(account + ".xml"); + Properties journal = read(journalFile); + StudioIdentity.Owner to; + if (journal.isEmpty()) { + to = identity.heir(account).orElse(null); + if (to == null) continue; + journal.setProperty("format", "1"); + journal.setProperty("kind", "migration"); + journal.setProperty("from-account", account.toString()); + journal.setProperty("to-account", to.account().toString()); + journal.setProperty("to-character", to.character().toString()); + for (Path source : projects(folder)) { + String id = source.getFileName().toString().replace(".yml", ""); + byte[] bytes = Files.readAllBytes(source); + try { + UUID.fromString(id); + var yaml = yaml(bytes); + if (!Integer.valueOf(StudioStore.FORMAT).equals(yaml.get("format")) || !account.toString().equals(yaml.get("owner")) + || !id.equals(yaml.get("id"))) throw new Conflict("Legacy project identity mismatch"); + plan(journal, id, source, store.projectFile(to.character(), UUID.fromString(id)), bytes, to); + } catch (IOException | IllegalArgumentException ex) { + journal.setProperty("held." + id, ex.getMessage()); + journal.setProperty("held-source." + id, Base64.getEncoder().encodeToString(bytes)); + } + } + Path selected = folder.resolve(StudioStore.SELECTED); + if (Files.exists(selected)) journal.setProperty("selected", Base64.getEncoder().encodeToString(Files.readAllBytes(selected))); + write(journalFile, journal); // Entire inventory/backup precedes the first publication. + } else { + to = owner(journal, "to"); + } + for (String id : ids(journal)) apply(journalFile, journal, id, () -> true, false); + if (!"true".equals(journal.getProperty("complete"))) { + String selected = journal.getProperty("selected"); + Path target = store.directory(to.character()).resolve(StudioStore.SELECTED); + if (selected != null && !Files.exists(target)) { + byte[] pointer = Base64.getDecoder().decode(selected); + try { + UUID id = UUID.fromString(new String(pointer, StandardCharsets.UTF_8).strip()); + if (Files.exists(store.projectFile(to.character(), id))) DurableFiles.write(target, pointer, false); + } catch (IllegalArgumentException ex) { journal.setProperty("held.selected", "Invalid project selection"); } + } + journal.setProperty("complete", "true"); + write(journalFile, journal); + } + } + } + + private void holdUnrecognized(Path source) throws IOException { + Path file = root.resolve("ownership/migration").resolve("unrecognized-" + hash(root.relativize(source).toString().getBytes(StandardCharsets.UTF_8)) + ".xml"); + if (Files.exists(file)) { read(file); return; } + Properties journal = new Properties(); + journal.setProperty("format", "1"); journal.setProperty("kind", "migration"); + List records = Files.isDirectory(source) ? projects(source) : List.of(source); + for (Path record : records) { + String key = root.relativize(record).toString(); + byte[] bytes = Files.readAllBytes(record); + journal.setProperty("held." + key, "Unrecognized legacy account path"); + journal.setProperty("held-source." + key, Base64.getEncoder().encodeToString(bytes)); + } + write(file, journal); + } + + List transfer(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to, + BooleanSupplier guard) throws IOException { + if (from.equals(to)) throw new Conflict("Estate recipient is the testator"); + Path file = root.resolve("ownership/estates").resolve(death + ".xml"); + Properties journal = read(file); + if (journal.isEmpty()) { + journal.setProperty("format", "1"); + journal.setProperty("kind", "estate"); + journal.setProperty("death", death.toString()); + journal.setProperty("from-account", from.account().toString()); + journal.setProperty("from-character", from.character().toString()); + } + if (!death.toString().equals(journal.getProperty("death")) || !from.equals(owner(journal, "from"))) + throw new Conflict("Estate journal identity differs from this death"); + for (String id : ids(journal)) { + if ("moved".equals(journal.getProperty(id + ".state"))) continue; + if (!exactDecision.equals(journal.getProperty(id + ".decision"))) { + // Only a plan with no published output can be superseded by a new decision. + if (!"planned".equals(journal.getProperty(id + ".state")) + || Files.exists(path(journal.getProperty(id + ".target")))) + throw new Conflict("Stale partial transfer needs staff recovery: " + id); + removeEntry(journal, id); + } else if (!to.equals(owner(journal, id + ".to"))) { + throw new Conflict("The planned recipient differs from the current exact decision"); + } + } + if (!guard.getAsBoolean()) throw new IOException("Estate decision no longer holds"); + for (Path source : projects(store.directory(from.character()))) { + String id = source.getFileName().toString().replace(".yml", ""); + if ("moved".equals(journal.getProperty(id + ".state"))) throw new Conflict("Transferred project reappeared at its previous owner: " + id); + if (journal.containsKey(id + ".state")) continue; + UUID projectId; + try { projectId = UUID.fromString(id); } + catch (IllegalArgumentException ex) { throw new Conflict("Unrecognized project file: " + source); } + byte[] before = Files.readAllBytes(source); + var yaml = yaml(before); + if (!id.equals(yaml.get("id")) || !from.account().toString().equals(yaml.get("owner-account")) + || !from.character().toString().equals(yaml.get("owner-character"))) + throw new Conflict("Unresolved project authority: " + id); + plan(journal, id, source, store.projectFile(to.character(), projectId), before, to); + journal.setProperty(id + ".from-account", from.account().toString()); + journal.setProperty(id + ".from-character", from.character().toString()); + journal.setProperty(id + ".decision", exactDecision); + } + write(file, journal); + for (String id : ids(journal)) { + if (!"moved".equals(journal.getProperty(id + ".state"))) apply(file, journal, id, guard, true); + } + return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList(); + } + + List retainedReceipts(UUID death, String exactDecision, StudioIdentity.Owner from) throws IOException { + Path file = root.resolve("ownership/estates").resolve(death + ".xml"); + Properties journal = read(file); + if (!journal.isEmpty() && (!death.toString().equals(journal.getProperty("death")) || !from.equals(owner(journal, "from")))) + throw new Conflict("Estate journal identity differs from this death"); + for (String id : ids(journal)) { + if ("moved".equals(journal.getProperty(id + ".state"))) continue; + if (!exactDecision.equals(journal.getProperty(id + ".decision"))) { + if (!"planned".equals(journal.getProperty(id + ".state")) || Files.exists(path(journal.getProperty(id + ".target")))) + throw new Conflict("Stale partial transfer needs staff recovery: " + id); + removeEntry(journal, id); + } + } + if (!journal.isEmpty()) write(file, journal); + return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList(); + } + + private void plan(Properties journal, String id, Path source, Path target, byte[] bytes, StudioIdentity.Owner to) throws IOException { + Path backup = root.resolve("ownership/backups").resolve(hash(bytes) + ".yml"); + if (!Files.exists(backup)) DurableFiles.write(backup, bytes, false); + else if (!hash(Files.readAllBytes(backup)).equals(hash(bytes))) throw new Conflict("Ownership backup differs from its hash"); + var yaml = yaml(bytes); + yaml.put("owner-account", to.account().toString()); + yaml.put("owner-character", to.character().toString()); + byte[] after = RawYaml.bytes(yaml); + journal.setProperty(id + ".source", root.relativize(source).toString()); + journal.setProperty(id + ".target", root.relativize(target).toString()); + journal.setProperty(id + ".before", Base64.getEncoder().encodeToString(bytes)); + journal.setProperty(id + ".after", Base64.getEncoder().encodeToString(after)); + journal.setProperty(id + ".before-hash", hash(bytes)); + journal.setProperty(id + ".after-hash", hash(after)); + journal.setProperty(id + ".to-account", to.account().toString()); + journal.setProperty(id + ".to-character", to.character().toString()); + journal.setProperty(id + ".state", "planned"); + } + + private void apply(Path file, Properties journal, String id, BooleanSupplier guard, boolean removeSource) throws IOException { + if ("moved".equals(journal.getProperty(id + ".state"))) return; + Path source = path(journal.getProperty(id + ".source")); + Path target = path(journal.getProperty(id + ".target")); + if (source.equals(target)) throw new Conflict("Source and destination refer to the same project"); + StudioIdentity.Owner to = owner(journal, id + ".to"); + if (!target.equals(store.projectFile(to.character(), UUID.fromString(id)).normalize())) + throw new Conflict("Destination path differs from its planned character"); + if (Files.exists(source) && !hash(Files.readAllBytes(source)).equals(journal.getProperty(id + ".before-hash"))) + throw new Conflict("Project changed after ownership planning: " + source); + if (!Files.exists(source) && "planned".equals(journal.getProperty(id + ".state"))) + throw new Conflict("Planned source project disappeared: " + source); + if (!Files.exists(source) && !Files.exists(target)) throw new Conflict("Both published project copies disappeared"); + if (Files.exists(target)) { + if (!hash(Files.readAllBytes(target)).equals(journal.getProperty(id + ".after-hash"))) + throw new Conflict("Recipient project conflicts: " + target); + } else { + if (!guard.getAsBoolean()) throw new IOException("Estate decision changed before publication"); + DurableFiles.write(target, Base64.getDecoder().decode(journal.getProperty(id + ".after")), false); + } + journal.setProperty(id + ".state", "published"); + write(file, journal); + if (removeSource) { + if (!guard.getAsBoolean()) throw new IOException("Estate decision changed before source removal"); + DurableFiles.delete(source); + } + journal.setProperty(id + ".state", "moved"); + write(file, journal); + } + + void requireWritable(UUID character) throws IOException { + for (String kind : List.of("migration", "estates")) for (Path file : files(root.resolve("ownership").resolve(kind))) { + var attributes = Files.readAttributes(file, java.nio.file.attribute.BasicFileAttributes.class); + Stamp stamp = new Stamp(attributes.size(), attributes.lastModifiedTime(), attributes.fileKey()); + Locks cached = lockCache.get(file); + if (cached == null || !stamp.equals(cached.stamp())) { + Properties journal = read(file); + java.util.Set pending = new java.util.HashSet<>(); + for (String id : ids(journal)) if (!"moved".equals(journal.getProperty(id + ".state"))) { + pending.add(owner(journal, id + ".to").character()); + if ("estate".equals(journal.getProperty("kind"))) pending.add(owner(journal, "from").character()); + } + cached = new Locks(stamp, java.util.Set.copyOf(pending)); + lockCache.put(file, cached); + } + if (cached.characters().contains(character)) throw new Conflict("Studio ownership publication needs recovery: " + file); + } + } + + List holdings(UUID character) throws IOException { + return projects(store.directory(character)).stream().map(file -> file.getFileName().toString().replace(".yml", "")).toList(); + } + + void requireEstateReady(StudioIdentity.Owner from) throws IOException { + Path legacy = root.resolve("projects").resolve(from.account().toString()); + if (projects(legacy).isEmpty()) return; + Properties journal = read(root.resolve("ownership/migration").resolve(from.account() + ".xml")); + if (journal.isEmpty() || !"true".equals(journal.getProperty("complete"))) + throw new IOException("The account's reviewed legacy project migration is not complete"); + if (from.character().toString().equals(journal.getProperty("to-character")) + && journal.stringPropertyNames().stream().anyMatch(key -> key.startsWith("held."))) + throw new Conflict("Legacy projects for this character remain held for staff"); + } + + private Path path(String relative) throws Conflict { + Path result = root.resolve(relative).normalize(); + if (!result.startsWith(root.normalize())) throw new Conflict("Invalid ownership journal path"); + return result; + } + + private static StudioIdentity.Owner owner(Properties values, String prefix) throws Conflict { + try { return new StudioIdentity.Owner(UUID.fromString(values.getProperty(prefix + "-account")), + UUID.fromString(values.getProperty(prefix + "-character"))); } + catch (IllegalArgumentException | NullPointerException ex) { throw new Conflict("Invalid journal owner"); } + } + + static Properties read(Path file) throws IOException { + Properties result = new Properties(); + if (Files.exists(file)) try (var input = Files.newInputStream(file)) { + try { result.loadFromXML(input); } + catch (java.util.InvalidPropertiesFormatException ex) { throw new Conflict("Unreadable ownership journal"); } + if (!"1".equals(result.getProperty("format"))) throw new Conflict("Unsupported ownership journal format"); + String kind = result.getProperty("kind"); + if (!"migration".equals(kind) && !"estate".equals(kind)) throw new Conflict("Unknown ownership journal kind"); + try { + for (String id : ids(result)) { + UUID.fromString(id); + if (!List.of("planned", "published", "moved").contains(result.getProperty(id + ".state"))) + throw new Conflict("Unknown ownership publication state"); + for (String part : List.of("before", "after")) { + byte[] bytes = Base64.getDecoder().decode(result.getProperty(id + "." + part)); + if (!hash(bytes).equals(result.getProperty(id + "." + part + "-hash"))) + throw new Conflict("Ownership journal payload hash differs"); + } + StudioIdentity.Owner to = owner(result, id + ".to"); + var after = yaml(Base64.getDecoder().decode(result.getProperty(id + ".after"))); + if (!id.equals(after.get("id")) || !to.account().toString().equals(after.get("owner-account")) + || !to.character().toString().equals(after.get("owner-character"))) + throw new Conflict("Planned authority differs from its payload"); + if (result.getProperty(id + ".source") == null || result.getProperty(id + ".target") == null) + throw new Conflict("Ownership publication path missing"); + var before = yaml(Base64.getDecoder().decode(result.getProperty(id + ".before"))); + if (!id.equals(before.get("id"))) throw new Conflict("Planned source ID differs from its payload"); + Path expectedSource; + if ("estate".equals(kind)) { + StudioIdentity.Owner from = owner(result, "from"); + if (!from.equals(owner(result, id + ".from")) + || !from.account().toString().equals(before.get("owner-account")) + || !from.character().toString().equals(before.get("owner-character"))) + throw new Conflict("Planned source authority differs from its payload"); + expectedSource = Path.of("character-projects", from.character().toString(), id + ".yml"); + } else { + UUID account = UUID.fromString(result.getProperty("from-account")); + if (!account.toString().equals(before.get("owner")) || !to.equals(owner(result, "to"))) + throw new Conflict("Planned legacy authority differs from its payload"); + expectedSource = Path.of("projects", account.toString(), id + ".yml"); + } + if (!expectedSource.equals(Path.of(result.getProperty(id + ".source")))) + throw new Conflict("Source path differs from its planned owner"); + } + } catch (IllegalArgumentException | NullPointerException ex) { throw new Conflict("Malformed ownership publication record"); } + } + return result; + } + + static void write(Path file, Properties journal) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + journal.storeToXML(output, "Studio ownership format 1", StandardCharsets.UTF_8); + DurableFiles.write(file, output.toByteArray(), true); + } + + static String hash(byte[] bytes) { + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (NoSuchAlgorithmException ex) { throw new AssertionError(ex); } + } + + private static java.util.Map yaml(byte[] bytes) throws IOException { + try { return RawYaml.read(bytes); } + catch (IOException ex) { throw new Conflict("Unreadable project YAML: " + ex.getMessage()); } + } + + private static List files(Path folder) throws IOException { + if (!Files.isDirectory(folder)) return List.of(); + try (var paths = Files.list(folder)) { return paths.sorted().toList(); } + } + + private static List projects(Path folder) throws IOException { + return files(folder).stream().filter(file -> file.getFileName().toString().endsWith(".yml")).toList(); + } + + static List ids(Properties journal) { + return journal.stringPropertyNames().stream().filter(key -> key.endsWith(".state") + && !key.startsWith("held.") && !key.startsWith("held-source.")) + .map(key -> key.substring(0, key.length() - 6)).sorted().toList(); + } + + private static void removeEntry(Properties journal, String id) { + new ArrayList<>(journal.stringPropertyNames()).stream().filter(key -> key.startsWith(id + ".")).forEach(journal::remove); + } +} diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java index 787ab53..3ed5fbb 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java @@ -43,6 +43,9 @@ public final class StudioService { private final InstrumentPlugin plugin; private final InstrumentManager manager; private final StudioStore store; + private final StudioIdentity identity; + private final StudioOwnership ownership; + private final Map owners = new HashMap<>(); private final StudioSettings settings; private final StudioStation station; private final DiscItems discs; @@ -71,6 +74,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) { private final ExecutorService checkpointWriter; private long clock; private boolean closed; + private boolean migrationAttempted; private volatile long shutdownDeadline; public StudioService(InstrumentPlugin plugin, InstrumentManager manager) throws IOException { @@ -97,17 +101,29 @@ private static YamlConfiguration loadSettings(InstrumentPlugin plugin) throws IO StudioService(InstrumentPlugin plugin, InstrumentManager manager, StudioStore store, StudioSettings settings, StudioStation station, DiscItems discs) { + this(plugin, manager, store, settings, station, discs, StudioIdentity.load()); + } + + StudioService(InstrumentPlugin plugin, InstrumentManager manager, StudioStore store, StudioSettings settings, + StudioStation station, DiscItems discs, StudioIdentity identity) { this(plugin, manager, store, settings, station, discs, Executors.newSingleThreadExecutor( - Thread.ofPlatform().daemon(true).name("musical-instruments-checkpoints").factory())); + Thread.ofPlatform().daemon(true).name("musical-instruments-checkpoints").factory()), identity); } StudioService(InstrumentPlugin plugin, InstrumentManager manager, StudioStore store, StudioSettings settings, StudioStation station, DiscItems discs, ExecutorService checkpointWriter) { + this(plugin, manager, store, settings, station, discs, checkpointWriter, StudioIdentity.load()); + } + + StudioService(InstrumentPlugin plugin, InstrumentManager manager, StudioStore store, StudioSettings settings, + StudioStation station, DiscItems discs, ExecutorService checkpointWriter, StudioIdentity identity) { this.plugin = plugin; this.manager = manager; this.settings = settings; this.station = station; this.store = store; + this.identity = identity; + this.ownership = new StudioOwnership(store); this.discs = discs; this.checkpointWriter = checkpointWriter; task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 1L, 1L); @@ -117,6 +133,45 @@ private static YamlConfiguration loadSettings(InstrumentPlugin plugin) throws IO public StudioSettings settings() { return settings; } public StudioStation station() { return station; } + public StudioIdentity.Token token(Player player) { + StudioIdentity.Token token = identity.current(player).orElseThrow( + () -> new IllegalArgumentException("Choose an active character before using the recording studio")); + if (!current(token, player)) throw new IllegalArgumentException("Your character session changed"); + owners.put(token.owner().character(), token.owner()); + try { + if (!migrationAttempted && identity.heirsReady()) { + ownership.migrate(identity); + migrationAttempted = true; + } + ownership.requireWritable(token.owner().character()); + } + catch (IOException ex) { throw new IllegalArgumentException("Studio ownership needs recovery: " + ex.getMessage(), ex); } + return token; + } + + public boolean current(StudioIdentity.Token token, Player player) { + return token != null && token.owner().account().equals(player.getUniqueId()) && identity.current(token); + } + + private UUID owner(Player player) { return token(player).owner().character(); } + + public void startOwnership(StudioMenu menu) { + plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + try { ownership.migrate(identity); migrationAttempted = identity.heirsReady(); } + catch (IOException ex) { plugin.getLogger().warning("Studio migration held: " + ex.getMessage()); } + }, 1L, 72_000L); + if (identity instanceof CharacterStudioBridge) CharacterStudioBridge.start(plugin, this, menu); + StudioEstate.startIfAvailable(plugin, this, ownership); + } + + boolean quiescent(UUID character) { + completeSaves(); + return !pendingSaves.containsKey(character) && !unsaved.contains(character) + && recordings.values().stream().noneMatch(capture -> capture.token.owner().character().equals(character)); + } + + void transferred(UUID character) { projects.remove(character); owners.remove(character); } + public record Activity(int track, int seconds, int notes, int countdown, boolean previewing) { public boolean recording() { return track > 0; } } @@ -132,11 +187,12 @@ public Activity activity(Player player) { } public Project project(Player player) throws IOException { - Project project = projects.get(player.getUniqueId()); + Project project = projects.get(owner(player)); if (project == null) { - project = store.project(player.getUniqueId()).orElse(null); + project = store.project(owner(player)).orElse(null); if (project != null) { - projects.put(player.getUniqueId(), project); + requireOwner(player, project); + projects.put(owner(player), project); } } return project; @@ -170,9 +226,11 @@ public void requireProjectSpace(Player player) throws IOException { /** Loads only the requested project; a cached version may contain changes not yet written. */ public Project project(Player player, UUID id) throws IOException { - Project current = projects.get(player.getUniqueId()); + Project current = projects.get(owner(player)); if (current != null && current.song().id().equals(id)) return current; - return store.project(player.getUniqueId(), id); + Project loaded = store.project(owner(player), id); + requireOwner(player, loaded); + return loaded; } /** Deletes the project only if it still matches what the player confirmed. */ @@ -184,11 +242,11 @@ public void deleteProject(Player player, Project expected) throws IOException { if (!project(player, id).equals(expected)) { throw new IllegalArgumentException("The project changed. Open the delete confirmation again"); } - store.delete(player.getUniqueId(), id); - Project current = projects.get(player.getUniqueId()); + store.delete(owner(player), id); + Project current = projects.get(owner(player)); if (current != null && current.song().id().equals(id)) { - projects.remove(player.getUniqueId()); - unsaved.remove(player.getUniqueId()); + projects.remove(owner(player)); + unsaved.remove(owner(player)); stopPreview(player); } player.sendMessage("Deleted '" + expected.song().title() + "'. Published discs keep their music."); @@ -196,9 +254,9 @@ public void deleteProject(Player player, Project expected) throws IOException { public List listProjects(Player player) throws IOException { Map all = new LinkedHashMap<>(); - for (ProjectSummary summary : store.summaries(player.getUniqueId())) all.put(summary.id(), summary); + for (ProjectSummary summary : store.summaries(owner(player))) all.put(summary.id(), summary); // An uncached project matches its files; a cached one may be newer than them. - Project active = projects.get(player.getUniqueId()); + Project active = projects.get(owner(player)); if (active != null) all.put(active.song().id(), ProjectSummary.of(active)); return all.values().stream().sorted(ProjectSummary.ORDER).toList(); } @@ -207,10 +265,11 @@ public void selectProject(Player player, UUID id) throws IOException { Project current = project(player); if (current != null && current.song().id().equals(id)) return; requireSaved(player); // The next project is read from its file, which a queued write could still change. - Project next = store.project(player.getUniqueId(), id); + Project next = store.project(owner(player), id); + requireOwner(player, next); persist(player); - store.select(player.getUniqueId(), id); // Both projects already match their files. - projects.put(player.getUniqueId(), next); + store.select(owner(player), id); // Both projects already match their files. + projects.put(owner(player), next); stopPreview(player); } @@ -218,9 +277,9 @@ public void selectProject(Player player, UUID id) throws IOException { public void save(Player player, Project project) throws IOException { requireOwner(player, project); requireSaved(player); - store.save(project); // Persist successfully before replacing the in-memory version. - projects.put(player.getUniqueId(), project); - unsaved.remove(player.getUniqueId()); + store.saveOwned(token(player).owner(), project); // Persist successfully before replacing the in-memory version. + projects.put(owner(player), project); + unsaved.remove(owner(player)); } /** @@ -230,35 +289,35 @@ public void save(Player player, Project project) throws IOException { public void edit(Player player, Project project) { requireOwner(player, project); requireIdle(player); - projects.put(player.getUniqueId(), project); - unsaved.add(player.getUniqueId()); - queueSave(project); + projects.put(owner(player), project); + unsaved.add(owner(player)); + queueSave(token(player).owner(), project); } - private static void requireOwner(Player player, Project project) { - if (!project.song().owner().equals(player.getUniqueId())) { - throw new IllegalArgumentException("This is not your project"); - } + private void requireOwner(Player player, Project project) { + StudioIdentity.Owner ref = token(player).owner(); + try { store.requireAuthority(ref, project.song().id(), project.song().owner()); } + catch (IOException ex) { throw new IllegalArgumentException("Could not verify your project's owner", ex); } } // Retries a write that failed. A project with no failed write already matches its files. private void persist(Player player) throws IOException { - Project current = projects.get(player.getUniqueId()); - if (unsaved.contains(player.getUniqueId())) save(player, current); + Project current = projects.get(owner(player)); + if (unsaved.contains(owner(player))) save(player, current); } public void requireIdle(Player player) { - if (recordings.containsKey(player.getUniqueId())) { + if (recordings.containsKey(owner(player))) { throw new IllegalArgumentException("Stop the recording with /music stop first"); } - CompletableFuture pending = pendingSaves.get(player.getUniqueId()); - if (pending != null && pending.isDone()) completeSave(player.getUniqueId(), pending); + CompletableFuture pending = pendingSaves.get(owner(player)); + if (pending != null && pending.isDone()) completeSave(owner(player), pending); } /** Also requires the project's files to be current, for changes that read or remove them. */ public void requireSaved(Player player) { requireIdle(player); - if (pendingSaves.containsKey(player.getUniqueId())) { + if (pendingSaves.containsKey(owner(player))) { throw new IllegalArgumentException("Your project is still saving. Try again shortly"); } } @@ -279,14 +338,14 @@ public void record(Player player, int slot) throws IOException { } stopPreview(player); capacity(); - recordings.put(player.getUniqueId(), new Capture(project, slot, clock + settings.countInTicks())); + recordings.put(player.getUniqueId(), new Capture(project, slot, clock + settings.countInTicks(), token(player))); player.sendMessage("Recording starts in " + settings.countInTicks() / 20 + " seconds. Play with the hotbar, " + "or right-click your instrument for the keyboard. /music stop ends the take."); } public void capture(Player player, String instrument, String sound, float volume, float pitch) { Capture capture = recordings.get(player.getUniqueId()); - if (capture == null || clock < capture.start || clock - capture.start >= settings.durationTicks()) { + if (capture == null || !current(capture.token, player) || clock < capture.start || clock - capture.start >= settings.durationTicks()) { return; } int at = (int) (clock - capture.start); @@ -359,7 +418,7 @@ public void previewTrack(Player player, int slot) throws IOException { private void previewSong(Player player, Song song) { stopPreview(player); capacity(); - previews.put(player.getUniqueId(), new Preview(song, clock + 1)); + previews.put(player.getUniqueId(), new Preview(song, clock + 1, token(player))); } private void stopPreview(Player player) { @@ -418,7 +477,7 @@ private Song publishEdition(Player player) throws IOException { throw new IllegalArgumentException("Wait " + seconds + " seconds before publishing again"); } // No item is consumed when publication fails. - Song edition = store.publishEdition(project.song(), settings.editionsPerDay()); + Song edition = store.publishEdition(project.song(), player.getUniqueId(), settings.editionsPerDay()); lastPublished.put(player.getUniqueId(), clock); editions.put(edition.id(), edition); return edition; @@ -573,6 +632,7 @@ private void tick() { continue; // PlayerQuitEvent persists the take while the player is still accessible. } Capture capture = entry.getValue(); + if (!current(capture.token, player)) { finish(player, capture, "Recording stopped when the character changed"); continue; } long elapsed = clock - capture.start; if (elapsed < 0) { if (-elapsed % 20 == 0) { @@ -605,7 +665,7 @@ private void tick() { if (capture.checkpoint.isCompletedExceptionally()) { finish(player, capture, "Checkpoint failed; recording stopped to protect your take"); } else if (capture.checkpoint.isDone()) { - capture.checkpoint = write(capture.snapshot((int) elapsed)); + capture.checkpoint = write(capture.token.owner(), capture.snapshot((int) elapsed)); } } } @@ -614,7 +674,7 @@ private void tick() { Player player = plugin.getServer().getPlayer(entry.getKey()); Preview preview = entry.getValue(); int elapsed = (int) (clock - preview.start); - if (player == null || elapsed >= preview.song.lengthTicks() + settings.tailTicks()) { + if (player == null || !current(preview.token, player) || elapsed >= preview.song.lengthTicks() + settings.tailTicks()) { it.remove(); if (player != null) stopPrivateSounds(player); else privateSounds.remove(entry.getKey()); @@ -688,20 +748,21 @@ private void finish(Player player, Capture capture, String reason) { } Project result = capture.snapshot((int) Math.max(1, Math.min(settings.durationTicks(), clock - capture.start + 1))); // The take is kept in memory even if the disk fails; it can be reviewed while it is written. - projects.put(player.getUniqueId(), result); - unsaved.add(player.getUniqueId()); - queueSave(result); + UUID character = capture.token.owner().character(); + projects.put(character, result); + unsaved.add(character); + queueSave(capture.token.owner(), result); player.sendMessage(reason + ". Review the take: /music preview take, then /music keep or /music discard."); } - private void queueSave(Project project) { - pendingSaves.put(project.song().owner(), write(project)); + private void queueSave(StudioIdentity.Owner ref, Project project) { + pendingSaves.put(ref.character(), write(ref, project)); } // One queued snapshot per owner, plus the single snapshot currently being written. Changes // arriving during a write go to the tail, letting other owners save before this owner runs again. - private CompletableFuture write(Project project) { - UUID owner = project.song().owner(); + private CompletableFuture write(StudioIdentity.Owner ref, Project project) { + UUID owner = ref.character(); synchronized (queuedSaves) { QueuedSave queued = queuedSaves.get(owner); if (queued != null) { @@ -716,7 +777,7 @@ private CompletableFuture write(Project project) { queuedSaves.remove(owner); snapshot = next.project; } - saveSnapshot(snapshot); + saveSnapshot(ref, snapshot); }, checkpointWriter).whenComplete((ignored, error) -> { if (error == null) next.future.complete(null); else next.future.completeExceptionally(error); @@ -725,16 +786,16 @@ private CompletableFuture write(Project project) { } } - private void saveSnapshot(Project project) { + private void saveSnapshot(StudioIdentity.Owner ref, Project project) { try { try { - store.save(project); + store.saveOwned(ref, project); } catch (IOException ex) { // A failure can appear only after close has checked the pending saves. Retry it // once on this same worker, even after shutdown(), while the close budget remains. long deadline = shutdownDeadline; if (deadline == 0 || System.nanoTime() - deadline >= 0) throw ex; - store.save(project); + store.saveOwned(ref, project); } } catch (IOException ex) { plugin.getLogger().log(Level.SEVERE, "Could not save studio project for " + project.song().owner(), ex); @@ -744,7 +805,9 @@ private void saveSnapshot(Project project) { private void completeSave(UUID owner, CompletableFuture pending) { pendingSaves.remove(owner); - Player player = plugin.getServer().getPlayer(owner); + StudioIdentity.Owner ref = owners.get(owner); + Player player = plugin.getServer().getPlayer(ref.account()); + if (player != null && identity.current(player).map(token -> token.owner().character().equals(owner)).orElse(false) == false) player = null; if (pending.isCompletedExceptionally()) { // Retried by the next change, every minute, on disconnect and on shutdown. if (player != null) player.sendMessage("Could not save your project. Your changes are kept and will be saved again; " @@ -767,7 +830,7 @@ private void completeSaves() { // its checkpoints and final take include the cached project, which still lacks the take and would erase it. private void retryFailedSaves() { for (UUID owner : unsaved) { - if (!pendingSaves.containsKey(owner) && !recordings.containsKey(owner)) queueSave(projects.get(owner)); + if (!pendingSaves.containsKey(owner) && recordings.values().stream().noneMatch(capture -> capture.token.owner().character().equals(owner))) queueSave(owners.get(owner), projects.get(owner)); } } @@ -777,12 +840,25 @@ public void quit(Player player) { finish(player, capture, "Recording stopped on disconnect"); } stopPreview(player); - UUID owner = player.getUniqueId(); + StudioIdentity.Token token = identity.current(player).orElse(null); + if (token == null) return; + UUID owner = token.owner().character(); if (pendingSaves.containsKey(owner)) return; // completeSave evicts the project once it is written. - if (unsaved.contains(owner)) queueSave(projects.get(owner)); // Retry a write that failed. + if (unsaved.contains(owner)) queueSave(owners.get(owner), projects.get(owner)); // Retry a write that failed. else projects.remove(owner); // Its files are current; the next visit reloads it. } + public void end(Player player, StudioIdentity.Token ending) { + Capture capture = recordings.get(player.getUniqueId()); + if (capture != null && capture.token.equals(ending)) finish(player, capture, "Recording stopped when the character session ended"); + stopPreview(player); + UUID character = ending.owner().character(); + if (!pendingSaves.containsKey(character)) { + if (unsaved.contains(character)) queueSave(ending.owner(), projects.get(character)); + else projects.remove(character); + } + } + public void close() { if (closed) return; shutdownDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(SHUTDOWN_WAIT_SECONDS); @@ -839,13 +915,15 @@ private QueuedSave(Project project) { private static final class Capture { private final Project project; + private final StudioIdentity.Token token; private final int slot; private final long start; private final Timeline backing; private final List notes = new ArrayList<>(); private CompletableFuture checkpoint = CompletableFuture.completedFuture(null); - private Capture(Project project, int slot, long start) { + private Capture(Project project, int slot, long start, StudioIdentity.Token token) { + this.token = token; this.project = project; this.slot = slot; this.start = start; @@ -865,7 +943,10 @@ private static final class Preview { private final long start; private final Timeline timeline; - private Preview(Song song, long start) { + private final StudioIdentity.Token token; + + private Preview(Song song, long start, StudioIdentity.Token token) { + this.token = token; this.song = song; this.start = start; timeline = new Timeline(song, -1); diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java index 9821dfa..ef5698d 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioStore.java @@ -98,11 +98,11 @@ public List summaries(UUID owner) throws IOException { return index(owner).values().stream().sorted(ProjectSummary.ORDER).toList(); } - private Path directory(UUID owner) { - return root.resolve("projects").resolve(owner.toString()); + Path directory(UUID owner) { + return root.resolve("character-projects").resolve(owner.toString()); } - private Path projectFile(UUID owner, UUID id) { + Path projectFile(UUID owner, UUID id) { return directory(owner).resolve(id + ".yml"); } @@ -256,7 +256,8 @@ private Project decodeProject(Path file, UUID owner, UUID id) throws IOException YamlConfiguration yaml = read(file); try { Song song = decodeSong(yaml); - if (!song.owner().equals(owner)) { + String authority = yaml.getString("owner-character"); + if (!(authority == null ? song.owner() : UUID.fromString(authority)).equals(owner)) { throw new IllegalArgumentException("Project owner mismatch"); } if (!song.id().equals(id)) throw new IllegalArgumentException("Project ID mismatch"); @@ -267,6 +268,22 @@ private Project decodeProject(Path file, UUID owner, UUID id) throws IOException } } + public void requireAuthority(StudioIdentity.Owner owner, UUID id, UUID authorAccount) throws IOException { + Path file = projectFile(owner.character(), id); + if (!Files.exists(file)) { + if (!authorAccount.equals(owner.account())) throw new IllegalArgumentException("This is not your project"); + return; + } + YamlConfiguration yaml = read(file); + String account = yaml.getString("owner-account"); + String character = yaml.getString("owner-character"); + if (!owner.account().toString().equals(account) || !owner.character().toString().equals(character)) { + throw new IllegalArgumentException("This is not your character's project"); + } + } + + Path root() { return root; } + public Song song(UUID id) throws IOException { YamlConfiguration yaml = read(root.resolve("songs").resolve(id + ".yml")); try { @@ -281,22 +298,77 @@ public Song song(UUID id) throws IOException { } public void save(Project project) throws IOException { - UUID owner = project.song().owner(); + saveAt(project.song().owner(), null, project); + } + + public void saveOwned(StudioIdentity.Owner owner, Project project) throws IOException { + requireAuthority(owner, project.song().id(), project.song().owner()); + saveAt(owner.character(), owner, project); + } + + private void saveAt(UUID owner, StudioIdentity.Owner authority, Project project) throws IOException { Path file = projectFile(owner, project.song().id()); Files.createDirectories(file.getParent()); - write(file, encodeProject(project), true); + if (Files.exists(file)) read(file); + Map yaml = Files.exists(file) ? RawYaml.read(Files.readAllBytes(file)) : new LinkedHashMap<>(); + preserveProject(yaml, project); + if (authority != null) { + yaml.put("owner-account", authority.account().toString()); + yaml.put("owner-character", authority.character().toString()); + } + write(file, RawYaml.bytes(yaml), true); point(owner, project.song().id()); remember(project, file); } - private YamlConfiguration encodeProject(Project project) { - YamlConfiguration yaml = encodeSong(project.song()); - yaml.set("bpm", project.bpm()); - yaml.set("metronome", project.metronome()); + private void preserveProject(Map yaml, Project project) { + YamlConfiguration known = encodeSong(project.song()); + Object oldTracks = yaml.get("tracks"); + for (String key : known.getKeys(false)) yaml.put(key, known.get(key)); + yaml.put("tracks", preserveTracks(yaml, oldTracks, project.song().tracks())); + yaml.put("bpm", project.bpm()); + yaml.put("metronome", project.metronome()); if (project.pending() != null) { - yaml.set("pending", encodeTrack(project.pending())); + Map pending = new LinkedHashMap<>(); + if (yaml.containsKey("pending")) map(yaml.get("pending")).forEach((key, value) -> pending.put(key.toString(), value)); + pending.putAll(encodeTrack(project.pending())); + yaml.put("pending", pending); + } else { + // Keep unknown extension fields when a pending take is removed. + Object old = yaml.get("pending"); + if (old != null) { + Map extra = new LinkedHashMap<>(); + map(old).forEach((key, value) -> { if (!TRACK_KEYS.contains(key.toString())) extra.put(key.toString(), value); }); + if (!extra.isEmpty()) yaml.put("retained-pending", extra); + } + yaml.remove("pending"); } - return yaml; + } + + private static final java.util.Set TRACK_KEYS = java.util.Set.of("slot", "length", "gain", "muted", "names", "notes"); + + private List> preserveTracks(Map yaml, Object raw, List tracks) { + Map> old = new LinkedHashMap<>(); + if (raw instanceof List values) for (Object value : values) { + Map row = map(value); + Map copy = new LinkedHashMap<>(); + row.forEach((key, item) -> copy.put(key.toString(), item)); + old.put(integer(row.get("slot")), copy); + } + List> rows = new ArrayList<>(); + for (Track track : tracks) { + Map row = old.remove(track.slot()); + if (row == null) row = new LinkedHashMap<>(); + row.putAll(encodeTrack(track)); + rows.add(row); + } + if (!old.isEmpty()) { + Map retained = new LinkedHashMap<>(); + if (yaml.containsKey("retained-tracks")) map(yaml.get("retained-tracks")).forEach((key, value) -> retained.put(key.toString(), value)); + old.forEach((slot, row) -> retained.put(slot.toString(), row)); + yaml.put("retained-tracks", retained); + } + return rows; } public void publish(Song song) throws IOException { @@ -308,6 +380,10 @@ public void publish(Song song) throws IOException { * republishing an unchanged mix returns the earlier edition instead of adding another file. */ public Song publishEdition(Song song, int perDay) throws IOException { + return publishEdition(song, song.owner(), perDay); + } + + public Song publishEdition(Song song, UUID publishingAccount, int perDay) throws IOException { // Muted and silent parts are never played, so they are not kept forever either. List tracks = song.audibleMix().tracks(); String content = encodeSong(new Song(CONTENT_ID, song.owner(), song.author(), song.title(), tracks)).saveToString(); @@ -323,7 +399,7 @@ public Song publishEdition(Song song, int perDay) throws IOException { return edition; } long now = clock.millis(); - List recent = recentEditions(song.owner(), now); + List recent = recentEditions(publishingAccount, now); if (perDay > 0 && recent.size() >= perDay) { long minutes = (recent.getFirst() + DAY_MILLIS - now + 59_999) / 60_000; throw new IllegalArgumentException("You can publish " + perDay + " new song editions a day. Try again in " @@ -331,7 +407,7 @@ public Song publishEdition(Song song, int perDay) throws IOException { } publish(edition); recent.add(now); - Path log = editionLog(song.owner()); + Path log = editionLog(publishingAccount); Files.createDirectories(log.getParent()); write(log, String.join("\n", recent.stream().map(String::valueOf).toList()).getBytes(StandardCharsets.UTF_8), true); return edition; @@ -388,25 +464,7 @@ private void write(Path file, byte[] contents, boolean replace) throws IOExcepti if (contents.length > MAX_FILE_BYTES) { throw new IOException("Studio file exceeds the size limit: " + file.getFileName()); } - Path temp = Files.createTempFile(file.getParent(), "studio-", ".tmp"); - try { - Files.write(temp, contents); - try { - if (replace) { - Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } else { - Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE); - } - } catch (AtomicMoveNotSupportedException ex) { - if (replace) { - Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); - } else { - Files.move(temp, file); - } - } - } finally { - Files.deleteIfExists(temp); - } + DurableFiles.write(file, contents, replace, false); } private YamlConfiguration encodeSong(Song song) { diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index fd37b36..02c2466 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,7 @@ version: ${project.version} name: MusicalInstruments author: Justin api-version: '1.21.10' -softdepend: [MMOItems, ItemsAdder, Nexo] +softdepend: [TLibs, RPCharacters, MMOItems, ItemsAdder, Nexo] commands: instruments: diff --git a/src/test/java/net/tfminecraft/musicalinstruments/InstrumentPluginTest.java b/src/test/java/net/tfminecraft/musicalinstruments/InstrumentPluginTest.java index 2f64f41..6c72dd1 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/InstrumentPluginTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/InstrumentPluginTest.java @@ -154,6 +154,14 @@ void recordsPlayedNotesIntoAStudioTake() { plugin.getConfig().set("lute.item", "v.note_block"); server.getScheduler().performOneTick(); PlayerMock player = server.addPlayer(); + var identity = org.mockito.Mockito.mock(net.tfminecraft.tlibs.character.CharacterIdentity.class); + var session = new net.tfminecraft.tlibs.character.CharacterSession( + new net.tfminecraft.tlibs.character.CharacterRef(player.getUniqueId(), player.getUniqueId()), 1); + org.mockito.Mockito.when(identity.currentSession(player)).thenReturn(java.util.Optional.of(session)); + org.mockito.Mockito.when(identity.isCurrent(session)).thenReturn(true); + server.getServicesManager().register(net.tfminecraft.tlibs.character.CharacterIdentity.class, identity, plugin, + org.bukkit.plugin.ServicePriority.Normal); + player.getInventory().setItemInOffHand(new ItemStack(Material.NOTE_BLOCK)); assertTrue(player.performCommand("music new Song")); assertTrue(player.performCommand("music record 1")); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioBridgeTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioBridgeTest.java new file mode 100644 index 0000000..afbabbf --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioBridgeTest.java @@ -0,0 +1,129 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.Characters; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class StudioBridgeTest { + @Test void optionalCharacterBridgeBindsFullRefAndOutgoingEventWithoutCurrentness() throws Exception { + var player = mock(Player.class); UUID account = UUID.randomUUID(); when(player.getUniqueId()).thenReturn(account); + var session = new CharacterSession(new CharacterRef(account, UUID.randomUUID()), 19); + var bridge = new CharacterStudioBridge(); + try (var characters = mockStatic(Characters.class); var bukkit = mockStatic(Bukkit.class)) { + assertTrue(bridge.current((Player) null).isEmpty()); + assertTrue(bridge.heir(null).isEmpty()); + assertFalse(bridge.current((StudioIdentity.Token) null)); + var token = CharacterStudioBridge.token(session); + assertFalse(bridge.current(token)); + bukkit.when(() -> Bukkit.getPlayer(account)).thenReturn(player); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of(session)); + assertTrue(bridge.current(player).isEmpty()); // Provider rejected the captured session. + characters.when(() -> Characters.isCurrent(session)).thenReturn(true); + assertEquals(token, bridge.current(player).orElseThrow()); assertTrue(bridge.current(token)); + assertFalse(bridge.current(new StudioIdentity.Token(token.owner(), 20))); + characters.when(() -> Characters.legacyHeir(account)).thenReturn(Optional.of(session.ref())); + assertEquals(token.owner(), bridge.heir(account).orElseThrow()); + characters.when(Characters::legacyHeirsReady).thenReturn(true); assertTrue(bridge.heirsReady()); + Plugin plugin = mock(Plugin.class); var server = mock(org.bukkit.Server.class); var manager = mock(org.bukkit.plugin.PluginManager.class); + when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(manager); + StudioService studio = mock(StudioService.class); StudioMenu menu = mock(StudioMenu.class); + CharacterStudioBridge.start(plugin, studio, menu); + var listener = ArgumentCaptor.forClass(Listener.class); verify(manager).registerEvents(listener.capture(), eq(plugin)); + characters.when(() -> Characters.isCurrent(session)).thenReturn(false); + listener.getValue().getClass().getMethod("end", CharacterSessionEndEvent.class).invoke(listener.getValue(), + new CharacterSessionEndEvent(player, session, CharacterSessionEndEvent.Reason.SWITCH)); + verify(studio).end(player, token); verify(menu).end(player); + } + } + + @Test void providersCannotRouteAnotherAccountsLiveOrStaleSessionOrReviewedHeir() { + Player player = mock(Player.class); UUID account = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(account); + var foreign = new CharacterSession(new CharacterRef(UUID.randomUUID(), UUID.randomUUID()), 31); + var bridge = new CharacterStudioBridge(); + try (var characters = mockStatic(Characters.class)) { + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of(foreign)); + characters.when(() -> Characters.isCurrent(foreign)).thenReturn(true); + assertTrue(bridge.current(player).isEmpty()); + characters.when(() -> Characters.isCurrent(foreign)).thenReturn(false); + assertTrue(bridge.current(player).isEmpty()); + characters.when(() -> Characters.legacyHeir(account)).thenReturn(Optional.of(foreign.ref())); + assertTrue(bridge.heir(account).isEmpty()); + characters.when(() -> Characters.legacyHeir(account)).thenReturn(Optional.empty()); + assertTrue(bridge.heir(account).isEmpty()); + } + } + + @Test void olderOrAbsentTlibsLoadsTheFailClosedAdapterAndLeavesEstateIntegrationUnavailable() throws Exception { + var classes = StudioIdentity.class.getProtectionDomain().getCodeSource().getLocation(); + try (var loader = new URLClassLoader(new java.net.URL[]{classes}, getClass().getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("net.tfminecraft.tlibs.character")) throw new ClassNotFoundException("Older TLibs fixture: " + name); + if (name.startsWith("net.tfminecraft.musicalinstruments.studio")) { + Class type = findLoadedClass(name); + if (type == null) type = findClass(name); + if (resolve) resolveClass(type); + return type; + } + return super.loadClass(name, resolve); + } + }) { + Class identity = loader.loadClass(StudioIdentity.class.getName()); + Object unavailable = identity.getMethod("load").invoke(null); + assertEquals(Optional.empty(), identity.getMethod("current", Player.class).invoke(unavailable, mock(Player.class))); + Class token = loader.loadClass(StudioIdentity.Token.class.getName()); + assertEquals(false, identity.getMethod("current", token).invoke(unavailable, new Object[]{null})); + assertEquals(false, identity.getMethod("heirsReady").invoke(unavailable)); + assertEquals(Optional.empty(), identity.getMethod("heir", UUID.class).invoke(unavailable, UUID.randomUUID())); + Class estate = loader.loadClass(StudioEstate.class.getName()); + var method = estate.getDeclaredMethod("startIfAvailable", Plugin.class, loader.loadClass(StudioService.class.getName()), loader.loadClass(StudioOwnership.class.getName())); + method.setAccessible(true); + Plugin plugin = mock(Plugin.class); when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + assertDoesNotThrow(() -> method.invoke(null, plugin, null, null)); + } + } + + @Test void theActualTlibs23ReleaseLoadsAndFailsClosedWithoutTheLegacyHeirApi() throws Exception { + Path repository = Path.of(System.getProperty("maven.repo.local", Path.of(System.getProperty("user.home"), ".m2/repository").toString())); + Path olderJar = repository.resolve("me/plugins/tlibs/2.3.0/tlibs-2.3.0.jar"); + assertTrue(java.nio.file.Files.isRegularFile(olderJar), "Install the pinned .github/compatibility/tlibs-2.3.xml fixture"); + var classes = StudioIdentity.class.getProtectionDomain().getCodeSource().getLocation(); + try (var loader = new URLClassLoader(new java.net.URL[]{classes, olderJar.toUri().toURL()}, getClass().getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("net.tfminecraft.tlibs") || name.startsWith("net.tfminecraft.musicalinstruments.studio")) { + Class type = findLoadedClass(name); if (type == null) type = findClass(name); + if (resolve) resolveClass(type); return type; + } + return super.loadClass(name, resolve); + } + }) { + Class api = loader.loadClass("net.tfminecraft.tlibs.character.Characters"); + assertTrue(api.getProtectionDomain().getCodeSource().getLocation().toString().contains("2.3.0.jar")); + assertThrows(NoSuchMethodException.class, () -> api.getMethod("legacyHeir", UUID.class)); + Class identity = loader.loadClass(StudioIdentity.class.getName()); + Object fallback = identity.getMethod("load").invoke(null); + assertEquals(Optional.empty(), identity.getMethod("current", Player.class).invoke(fallback, mock(Player.class))); + } + } + + @Test void rawYamlRefusesAmbiguousOrNonMappingDocumentsWithoutNormalizingThem() throws Exception { + assertThrows(java.io.IOException.class, () -> RawYaml.read("a: 1\na: 2\n".getBytes())); + assertThrows(java.io.IOException.class, () -> RawYaml.read("a plain string".getBytes())); + assertThrows(java.io.IOException.class, () -> RawYaml.read("2: value".getBytes())); + assertThrows(java.io.IOException.class, () -> RawYaml.read(new byte[]{(byte)0xff})); + } +} diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java new file mode 100644 index 0000000..7147250 --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java @@ -0,0 +1,85 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.bukkit.Server; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.musicalinstruments.InstrumentPlugin; +import net.tfminecraft.musicalinstruments.managers.InstrumentManager; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class StudioCharacterTest { + @TempDir Path root; + @Test void sameAccountCharactersHaveSeparateLibrariesAndOutgoingTakeFinishesAfterSessionBecomesNoncurrent() throws Exception { + var plugin = mock(InstrumentPlugin.class); var server = mock(Server.class); var scheduler = mock(BukkitScheduler.class); + when(plugin.getServer()).thenReturn(server); when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + when(server.getScheduler()).thenReturn(scheduler); + var tick = new java.util.concurrent.atomic.AtomicReference(); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenAnswer(call -> { tick.set(call.getArgument(1)); return mock(BukkitTask.class); }); + var player = mock(Player.class); var inventory = mock(PlayerInventory.class); var world = mock(World.class); + UUID account = UUID.randomUUID(); when(player.getUniqueId()).thenReturn(account); when(player.getName()).thenReturn("Account name"); + when(player.getInventory()).thenReturn(inventory); when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(server.getPlayer(account)).thenReturn(player); + var instruments = mock(InstrumentManager.class); when(instruments.getInstrument(any())).thenReturn("lute"); + var identity = new TestIdentity(); + var first = new StudioIdentity.Token(new StudioIdentity.Owner(account, UUID.randomUUID()), 1); + var second = new StudioIdentity.Token(new StudioIdentity.Owner(account, UUID.randomUUID()), 2); + identity.sessions.put(account, first); + var store = new StudioStore(root); var writer = Executors.newSingleThreadExecutor(); + var studio = new StudioService(plugin, instruments, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0, 0), StudioStation.none(), mock(DiscItems.class), writer, identity); + try { + studio.create(player, "First", false); UUID firstId = studio.requireProject(player).song().id(); + studio.record(player, 1); tick.get().run(); tick.get().run(); studio.capture(player, "lute", "original_session_sample", 1, 1); + assertFalse(studio.quiescent(first.owner().character())); + identity.sessions.put(account, second); // End event deliberately sees a noncurrent outgoing session. + studio.end(player, first); + writer.submit(() -> {}).get(3, TimeUnit.SECONDS); tick.get().run(); + assertTrue(studio.quiescent(first.owner().character())); + assertFalse(studio.current(first, player)); assertTrue(studio.current(second, player)); + assertFalse(studio.current(null, player)); + var foreign = mock(Player.class); when(foreign.getUniqueId()).thenReturn(UUID.randomUUID()); assertFalse(studio.current(second, foreign)); + assertTrue(studio.listProjects(player).isEmpty()); + studio.create(player, "Second", false); + UUID secondId = studio.requireProject(player).song().id(); assertNotEquals(firstId, secondId); + assertEquals("First", store.project(first.owner().character(), firstId).song().title()); + assertEquals("original_session_sample", store.project(first.owner().character(), firstId).pending().notes().getFirst().sound()); + assertEquals("Second", store.project(second.owner().character(), secondId).song().title()); + // Estate delivery evicts the recipient cache, including an already selected project. + var changed = store.project(second.owner().character(), secondId); + store.saveOwned(second.owner(), new Project(new Song(changed.song().id(), account, "Account name", "Delivered update", List.of()), null, 100, false)); + studio.transferred(second.owner().character()); + assertEquals("Delivered update", studio.requireProject(player).song().title()); + identity.sessions.put(account, new StudioIdentity.Token(first.owner(), 3)); assertEquals(firstId, studio.requireProject(player).song().id()); + assertEquals(account, studio.requireProject(player).song().owner()); + identity.sessions.put(account, null); assertThrows(IllegalArgumentException.class, () -> studio.listProjects(player)); + } finally { studio.close(); } + } + + @Test void inheritedOverflowIsRetainedAndCannotCreateAnotherProject() throws Exception { + var store = new StudioStore(root); var from = new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + var to = new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + for (int i = 0; i < 36; i++) store.saveOwned(to, new Project(new Song(UUID.randomUUID(), to.account(), "Heir", "Existing " + i, List.of()), null, 100, false)); + for (int i = 0; i < 2; i++) store.saveOwned(from, new Project(new Song(UUID.randomUUID(), from.account(), "Author", "Inherited " + i, List.of()), null, 100, false)); + new StudioOwnership(store).transfer(UUID.randomUUID(), "decision", from, to, () -> true); + var plugin = mock(InstrumentPlugin.class); var server = mock(Server.class); var scheduler = mock(BukkitScheduler.class); + when(plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(scheduler); + when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())).thenReturn(mock(BukkitTask.class)); + var player = mock(Player.class); when(player.getUniqueId()).thenReturn(to.account()); + var identity = new TestIdentity(); identity.sessions.put(to.account(), new StudioIdentity.Token(to, 1)); + var studio = new StudioService(plugin, mock(InstrumentManager.class), store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0, 0), StudioStation.none(), mock(DiscItems.class), identity); + try { assertEquals(38, studio.listProjects(player).size()); assertThrows(IllegalArgumentException.class, () -> studio.create(player, "Extra", false)); } + finally { studio.close(); } + } +} diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioDialogWorkflowTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioDialogWorkflowTest.java index b0d5415..be91f00 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioDialogWorkflowTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioDialogWorkflowTest.java @@ -41,6 +41,9 @@ class StudioDialogWorkflowTest { @BeforeEach void setup() throws Exception { + when(studio.token(any())).thenAnswer(call -> TestIdentity.INSTANCE.current(call.getArgument(0, Player.class)).orElseThrow()); + when(studio.current(any(), any())).thenAnswer(call -> TestIdentity.INSTANCE.current(call.getArgument(0, StudioIdentity.Token.class)) + && call.getArgument(0, StudioIdentity.Token.class).owner().account().equals(call.getArgument(1, Player.class).getUniqueId())); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); when(player.isOnline()).thenReturn(true); when(plugin.isEnabled()).thenReturn(true); @@ -225,4 +228,15 @@ public void close() { instances.close(); } } + @Test void aDeferredDialogResponseCannotCreateAProjectAfterTheSessionChangesOnTheSameAccount() throws Exception { + try (Harness h = new Harness()) { + dialogs.create(player, null); + var response = mock(DialogResponseView.class); when(response.getText("title")).thenReturn("Must not create"); + h.callbacks.getFirst().accept(response, player); + when(studio.current(any(), eq(player))).thenReturn(false); + queued.removeFirst().run(); + verify(studio, never()).create(any(), anyString(), anyBoolean()); + } + } + } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioEstateTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioEstateTest.java new file mode 100644 index 0000000..5465a0d --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioEstateTest.java @@ -0,0 +1,106 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.Estates; +import net.tfminecraft.tlibs.character.estate.Recipient; +import net.tfminecraft.tlibs.character.estate.Outcome; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class StudioEstateTest { + @TempDir Path root; + final CharacterRef from = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final CharacterRef to = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + EstateDecision decision(Recipient property, long revision) { + return new EstateDecision(UUID.randomUUID(), from, Optional.of(Instant.EPOCH), Instant.EPOCH, + Optional.of(to), Optional.empty(), Optional.empty(), Recipient.FROZEN, property, Recipient.FROZEN, Recipient.SENDER, revision); + } + Plugin plugin() { Plugin plugin = mock(Plugin.class); when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); return plugin; } + + @Test void exactEstateGuardWaitAndWriterDrainPrecedeMovement() throws Exception { + Plugin plugin = plugin(); StudioService studio = mock(StudioService.class); + StudioOwnership ownership = new StudioOwnership(new StudioStore(root)); + CharacterStudioEstate estate = new CharacterStudioEstate(plugin, studio, ownership); + var decision = decision(Recipient.character(to), 1); + try (var estates = mockStatic(Estates.class)) { + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + estates.when(() -> Estates.guardHolds(decision)).thenReturn(true); + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(studio.quiescent(from.characterId())).thenReturn(true); + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(studio.quiescent(to.characterId())).thenReturn(true); + assertEquals(Outcome.Kind.DONE, estate.settle(decision).kind()); + verify(studio).transferred(from.characterId()); verify(studio).transferred(to.characterId()); + var frozen = decision(Recipient.FROZEN, 2); estates.when(() -> Estates.guardHolds(frozen)).thenReturn(true); + assertEquals(Outcome.Kind.DONE, estate.settle(frozen).kind()); + var waiting = decision(Recipient.WAITING, 3); estates.when(() -> Estates.guardHolds(waiting)).thenReturn(true); + assertEquals(Outcome.Kind.DONE, estate.settle(waiting).kind()); + estates.when(() -> Estates.guardHolds(decision)).thenReturn(false); // revival, later death or newer exact decision + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + } + assertEquals("studio", estate.id()); assertEquals(Map.of(), estate.describe(from.characterId()).counts()); + } + + @Test void cumulativeReceiptsAndRawHoldingsSurviveARepeatedSettlement() throws Exception { + Plugin plugin = plugin(); StudioService studio = mock(StudioService.class); when(studio.quiescent(any())).thenReturn(true); + StudioStore store = new StudioStore(root); + Project project = new Project(new Song(UUID.randomUUID(), from.ownerId(), "Author", "Song", List.of()), null, 100, false); + store.saveOwned(new StudioIdentity.Owner(from.ownerId(), from.characterId()), project); + CharacterStudioEstate estate = new CharacterStudioEstate(plugin, studio, new StudioOwnership(store)); + assertEquals(Map.of("projects", 1), estate.describe(from.characterId()).counts()); + var decision = decision(Recipient.character(to), 1); + try (var estates = mockStatic(Estates.class)) { + estates.when(() -> Estates.guardHolds(decision)).thenReturn(true); + var first = (Outcome.Done) estate.settle(decision); + assertEquals(List.of(project.song().id().toString()), first.movedIds()); + assertEquals(first, estate.settle(decision)); + var frozen = new EstateDecision(decision.deathId(), from, decision.diedAt(), decision.decidedAt(), decision.heir(), decision.deathGuild(), decision.deathRealm(), Recipient.FROZEN, Recipient.FROZEN, Recipient.FROZEN, Recipient.SENDER, 2); + estates.when(() -> Estates.guardHolds(frozen)).thenReturn(true); + assertEquals(first.movedIds(), ((Outcome.Done) estate.settle(frozen)).movedIds()); + } + } + + @Test void ioErrorsRetryConflictsBlockAndBadDescriptionsRemainVisible() throws Exception { + Plugin plugin = plugin(); StudioService studio = mock(StudioService.class); when(studio.quiescent(any())).thenReturn(true); + StudioOwnership ownership = mock(StudioOwnership.class); + CharacterStudioEstate estate = new CharacterStudioEstate(plugin, studio, ownership); + var decision = decision(Recipient.character(to), 1); + try (var estates = mockStatic(Estates.class)) { + estates.when(() -> Estates.guardHolds(decision)).thenReturn(true); + when(ownership.transfer(any(), anyString(), any(), any(), any())).thenThrow(new IOException("disk unavailable")); + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + doThrow(new StudioOwnership.Conflict("changed recipient")).when(ownership).transfer(any(), anyString(), any(), any(), any()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision).kind()); + } + when(ownership.holdings(any())).thenThrow(new IOException("unloaded unreadable library")); + assertEquals(Map.of("heldLibraries", 1), estate.describe(from.characterId()).counts()); + } + + @Test void reconciliationReportsOnlyChangedRevisionOutcomesAndIsolatesFailures() throws Exception { + Plugin plugin = plugin(); StudioService studio = mock(StudioService.class); when(studio.quiescent(any())).thenReturn(true); + var estate = new CharacterStudioEstate(plugin, studio, new StudioOwnership(new StudioStore(root))); + var first = decision(Recipient.FROZEN, 1); var invalid = decision(Recipient.FROZEN, 1); + try (var estates = mockStatic(Estates.class)) { + estates.when(Estates::decided).thenReturn(List.of(first, invalid)); + estates.when(() -> Estates.guardHolds(first)).thenReturn(true); + estate.reconcile(); // No service accepts reports yet; replay later. + estates.when(() -> Estates.report(eq(first.deathId()), eq("studio"), eq(1L), any())).thenReturn(true); + estate.reconcile(); estate.reconcile(); + estates.verify(() -> Estates.report(eq(first.deathId()), eq("studio"), eq(1L), any()), times(2)); + estates.when(() -> Estates.guardHolds(invalid)).thenThrow(new IllegalStateException("one bad estate")); + assertDoesNotThrow(estate::reconcile); + } + } +} diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioMenuTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioMenuTest.java index 006379d..f241bf3 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioMenuTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioMenuTest.java @@ -78,6 +78,9 @@ void takePreviewAndKeepUseNewVolumeAndCanUnmuteAPreviouslyMutedTrack() throws Ex @BeforeEach void setup() throws Exception { + when(studio.token(any())).thenAnswer(call -> TestIdentity.INSTANCE.current(call.getArgument(0, Player.class)).orElseThrow()); + when(studio.current(any(), any())).thenAnswer(call -> TestIdentity.INSTANCE.current(call.getArgument(0, StudioIdentity.Token.class)) + && call.getArgument(0, StudioIdentity.Token.class).owner().account().equals(call.getArgument(1, Player.class).getUniqueId())); Server server = mock(Server.class); when(plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(scheduler); @@ -626,4 +629,22 @@ void rightClickPublishingAndBottomInventoryClicksAreSupported() throws Exception assertThrows(IllegalArgumentException.class, () -> menu.openProject(player, station)); } + @Test void sessionEndClosesOnlyStudioMenusAndDeferredClicksRecheckTheCapturedSession() throws Exception { + click(11, ClickType.LEFT); + when(studio.current(any(), eq(player))).thenReturn(false); + executeClick(); verify(studio, never()).edit(eq(player), any()); + menu.end(player); verify(player).closeInventory(); + when(view.getTopInventory()).thenReturn(mock(Inventory.class)); + menu.end(player); verify(player, times(1)).closeInventory(); + } + + @Test void deferredDiscPublicationDoesNotConsumeTheCursorAfterItsSessionChanges() throws Exception { + var event = mock(InventoryClickEvent.class); var disc = prepareCursorDisc(); + when(event.getView()).thenReturn(view); when(event.getWhoClicked()).thenReturn(player); + when(event.getRawSlot()).thenReturn(50); when(event.getClick()).thenReturn(ClickType.LEFT); when(event.getCursor()).thenReturn(disc); + menu.click(event); + when(studio.current(any(), eq(player))).thenReturn(false); + executeClick(); verify(studio, never()).publishOnDisc(any(), any()); + } + } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java new file mode 100644 index 0000000..65892bd --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java @@ -0,0 +1,464 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class StudioOwnershipTest { + @TempDir Path root; + final UUID account = UUID.randomUUID(); + final StudioIdentity.Owner from = new StudioIdentity.Owner(account, UUID.randomUUID()); + final StudioIdentity.Owner to = new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + + Project project() { + Track track = new Track(1, 20, 1, false, List.of(new Note(0, "lute", "sound", 1, 1))); + return new Project(new Song(UUID.randomUUID(), account, "Original author", "Original title", List.of(track)), track, 100, true); + } + StudioStore store() throws IOException { return new StudioStore(root); } + Path legacy(Project project) throws Exception { + StudioStore store = store(); + store.save(project); + Path legacy = root.resolve("projects").resolve(account.toString()); + Files.createDirectories(legacy.getParent()); + Files.move(store.directory(account), legacy); + return legacy.resolve(project.song().id() + ".yml"); + } + TestIdentity reviewed() { + TestIdentity identity = new TestIdentity(); + identity.defaultHeirs = false; identity.heirs.put(account, from); + return identity; + } + + @Test void migrationKeepsEditionBytesAttributionSelectionAndOneReviewedHeir() throws Exception { + StudioStore store = store(); Project project = project(); Path source = legacy(project); + Song edition = store.publishEdition(project.song(), 20); + byte[] published = Files.readAllBytes(root.resolve("songs").resolve(edition.id() + ".yml")); + byte[] quota = Files.readAllBytes(root.resolve("editions").resolve(account + ".log")); + byte[] old = Files.readAllBytes(source); + StudioOwnership migration = new StudioOwnership(store); + TestIdentity identity = reviewed(); identity.ready = false; + migration.migrate(identity); assertFalse(Files.exists(store.projectFile(from.character(), project.song().id()))); + identity.ready = true; identity.heirs.clear(); migration.migrate(identity); + assertFalse(Files.exists(store.projectFile(from.character(), project.song().id()))); + identity.heirs.put(account, from); migration.migrate(identity); + assertEquals(project, store.project(from.character()).orElseThrow()); + assertArrayEquals(old, Files.readAllBytes(source)); + assertArrayEquals(published, Files.readAllBytes(root.resolve("songs").resolve(edition.id() + ".yml"))); + assertArrayEquals(quota, Files.readAllBytes(root.resolve("editions").resolve(account + ".log"))); + identity.heirs.put(account, to); migration.migrate(identity); + assertFalse(Files.exists(store.projectFile(to.character(), project.song().id()))); + assertEquals(List.of(project.song().id().toString()), migration.holdings(from.character())); + migration.requireWritable(from.character()); + } + + @Test void unknownFieldsSurviveMigrationMutationSaveShutdownAndRestart() throws Exception { + StudioStore store = store(); Project project = project(); Path source = legacy(project); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(source.toFile()); + yaml.set("foreign.deep", Map.of("value", List.of("keep", 42))); + var tracks = (List>) yaml.getList("tracks"); + tracks.getFirst().put("foreign-track", Map.of("nested", "keep")); yaml.set("tracks", tracks); + yaml.set("pending.foreign-pending.nested", "keep"); yaml.save(source.toFile()); + var raw = RawYaml.read(Files.readAllBytes(source)); raw.put("future-null", null); + var nested = new java.util.LinkedHashMap(); nested.put("nullable", null); raw.put("future-object", nested); + Files.write(source, RawYaml.bytes(raw)); + new StudioOwnership(store).migrate(reviewed()); + Project loaded = store.project(from.character()).orElseThrow(); + store.saveOwned(from, new Project(loaded.song(), loaded.pending(), 123, false)); + StudioStore restarted = store(); + assertEquals(123, restarted.project(from.character()).orElseThrow().bpm()); + var preserved = RawYaml.read(Files.readAllBytes(restarted.projectFile(from.character(), project.song().id()))); + assertTrue(preserved.containsKey("future-null")); assertNull(preserved.get("future-null")); + assertTrue(((Map) preserved.get("future-object")).containsKey("nullable")); + Path current = restarted.projectFile(from.character(), project.song().id()); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(current.toFile()); + assertEquals(List.of("keep", 42), saved.getList("foreign.deep.value")); + assertEquals(Map.of("nested", "keep"), ((Map) saved.getList("tracks").getFirst()).get("foreign-track")); + assertEquals("keep", saved.getString("pending.foreign-pending.nested")); + store.saveOwned(from, new Project(loaded.song().remove(1), null, 123, false)); + saved = YamlConfiguration.loadConfiguration(current.toFile()); + assertEquals("keep", saved.getString("retained-pending.foreign-pending.nested")); + assertNotNull(saved.get("retained-tracks.1")); + store.saveOwned(from, new Project(loaded.song(), null, 124, true)); + store.saveOwned(from, new Project(loaded.song().remove(1), null, 124, true)); + assertNotNull(YamlConfiguration.loadConfiguration(current.toFile()).get("retained-tracks.1")); + } + + @Test void malformedLegacyFilesAreRetainedAndReportedWithoutActiveFallback() throws Exception { + Project project = project(); Path source = legacy(project); Path folder = source.getParent(); + Files.writeString(source, "broken: ["); + Files.writeString(folder.resolve("not-a-uuid.yml"), "still: preserved"); + Files.writeString(folder.resolve("selected"), "bad pointer"); + Path ignored = root.resolve("projects/unreviewed-folder"); Files.createDirectories(ignored); + Files.writeString(ignored.resolve("opaque.yml"), "keep"); + StudioOwnership ownership = new StudioOwnership(store()); ownership.migrate(reviewed()); + var journal = StudioOwnership.read(root.resolve("ownership/migration").resolve(account + ".xml")); + assertTrue(journal.containsKey("held." + project.song().id())); + assertTrue(journal.containsKey("held.not-a-uuid")); + assertEquals("Invalid project selection", journal.getProperty("held.selected")); + assertEquals("broken: [", Files.readString(source)); + assertTrue(ownership.holdings(from.character()).isEmpty()); + } + + @Test void migrationValidatesIdentityAndDoesNotOverwriteRecipient() throws Exception { + Project project = project(); Path source = legacy(project); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(source.toFile()); + yaml.set("owner", to.account().toString()); yaml.save(source.toFile()); + StudioOwnership ownership = new StudioOwnership(store()); ownership.migrate(reviewed()); + assertTrue(StudioOwnership.read(root.resolve("ownership/migration").resolve(account + ".xml")).containsKey("held." + project.song().id())); + } + + @Test void transfersReplayOnceConserveProjectsAndPreserveRecipientSelection() throws Exception { + StudioStore store = store(); Project project = project(); Project rawRecipient = project(); + Project recipient = new Project(new Song(rawRecipient.song().id(), to.account(), "Recipient author", "Existing", rawRecipient.song().tracks()), null, 100, false); + store.saveOwned(from, project); store.saveOwned(to, recipient); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + byte[] authorBytes = Files.readAllBytes(store.projectFile(from.character(), project.song().id())); + List moved = ownership.transfer(death, "revision1 exact decision", from, to, () -> true); + assertEquals(List.of(project.song().id().toString()), moved); + assertFalse(Files.exists(store.projectFile(from.character(), project.song().id()))); + assertEquals(recipient.song().id(), store.project(to.character()).orElseThrow().song().id()); + assertEquals(project, store.project(to.character(), project.song().id())); + assertEquals(moved, new StudioOwnership(store()).transfer(death, "revision1 exact decision", from, to, () -> true)); + assertEquals(moved, ownership.retainedReceipts(death, "revision2 frozen", from)); + assertArrayEquals(authorBytes, Files.readAllBytes(root.resolve("ownership/backups").resolve(StudioOwnership.hash(authorBytes) + ".yml"))); + var journal = StudioOwnership.read(root.resolve("ownership/estates").resolve(death + ".xml")); + assertEquals(to.account().toString(), journal.getProperty(project.song().id() + ".to-account")); + assertEquals(from.character().toString(), journal.getProperty(project.song().id() + ".from-character")); + } + + @Test void decisionGuardsPrecedeEveryPropertyPublicationAndSourceRemoval() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + AtomicInteger checks = new AtomicInteger(); + assertThrows(IOException.class, () -> ownership.transfer(death, "revision1", from, to, () -> checks.incrementAndGet() < 3)); + assertTrue(Files.exists(store.projectFile(from.character(), project.song().id()))); + assertTrue(Files.exists(store.projectFile(to.character(), project.song().id()))); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireWritable(from.character())); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireWritable(to.character())); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "revision2", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "revision2 frozen", from)); + assertEquals(List.of(project.song().id().toString()), ownership.transfer(death, "revision1", from, to, () -> true)); + ownership.requireWritable(from.character()); ownership.requireWritable(to.character()); + } + + @Test void anUntouchedPlanCanBeReplacedButPublishedOutputCannotFollowAnObsoleteRecipient() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + AtomicInteger calls = new AtomicInteger(); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> calls.incrementAndGet() == 1)); + StudioIdentity.Owner replacement = new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + assertEquals(List.of(project.song().id().toString()), ownership.transfer(death, "two", from, replacement, () -> true)); + assertFalse(Files.exists(store.projectFile(to.character(), project.song().id()))); + assertTrue(Files.exists(store.projectFile(replacement.character(), project.song().id()))); + } + + @Test void interruptedDestinationWriteAndSourceDeletionRecoverOnlyForTheSameDecision() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + Path destination = store.projectFile(to.character(), project.song().id()); + try (var disk = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + disk.when(() -> DurableFiles.write(eq(destination), any(byte[].class), eq(false))).thenAnswer(call -> { + call.callRealMethod(); throw new IOException("crash after destination publication"); + }); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + } + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "two", from, to, () -> true)); + Path source = store.projectFile(from.character(), project.song().id()); + try (var disk = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + byte[] sourceBytes = Files.readAllBytes(source); + disk.when(() -> DurableFiles.delete(source)).thenAnswer(call -> { call.callRealMethod(); throw new IOException("crash after source removal"); }); + Files.write(source, sourceBytes); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + } + assertEquals(List.of(project.song().id().toString()), ownership.transfer(death, "one", from, to, () -> true)); + } + + @Test void conflictsHoldChangedSourcesRecipientIdsAndReappearingOldRecords() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + Path destination = store.projectFile(to.character(), project.song().id()); Files.createDirectories(destination.getParent()); + Files.writeString(destination, "conflicting project"); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + Files.delete(destination); + Path source = store.projectFile(from.character(), project.song().id()); byte[] original = Files.readAllBytes(source); + Files.writeString(source, "changed"); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + Files.delete(source); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + Files.write(source, original); ownership.transfer(death, "one", from, to, () -> true); + Files.write(source, original); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "two", from, to, () -> true)); + } + + @Test void journalIdentityMalformedOwnerUnreadableProjectAndEscapedPathsBlock() throws Exception { + StudioStore store = store(); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, from, () -> true)); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> false)); + Project project = project(); store.saveOwned(from, project); + Path source = store.projectFile(from.character(), project.song().id()); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(source.toFile()); yaml.set("owner-account", "unknown"); yaml.save(source.toFile()); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + store.save(project); Files.writeString(store.directory(from.character()).resolve("bad.yml"), "bad"); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + Files.delete(store.directory(from.character()).resolve("bad.yml")); + yaml.set("owner-account", from.account().toString()); yaml.save(source.toFile()); + AtomicInteger checks = new AtomicInteger(); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> checks.incrementAndGet() == 1)); + Path file = root.resolve("ownership/estates").resolve(death + ".xml"); + var journal = StudioOwnership.read(file); journal.setProperty("from-character", "broken"); StudioOwnership.write(file, journal); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + journal.setProperty("from-character", from.character().toString()); journal.setProperty(project.song().id() + ".target", "../outside.yml"); StudioOwnership.write(file, journal); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "two", from, to, () -> true)); + journal.setProperty("death", UUID.randomUUID().toString()); StudioOwnership.write(file, journal); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + journal.setProperty("format", "99"); StudioOwnership.write(file, journal); + assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file)); + } + + @Test void quotaBelongsToThePublishingAccountEvenWhenItsCharacterInheritsOtherAuthorsWork() throws Exception { + StudioStore store = store(); Project project = project(); + store.publishEdition(project.song(), to.account(), 1); + Project another = new Project(new Song(UUID.randomUUID(), account, "Author", "Another", project.song().tracks()), null, 100, false); + assertThrows(IllegalArgumentException.class, () -> store.publishEdition(another.song(), to.account(), 1)); + assertFalse(Files.exists(root.resolve("editions").resolve(account + ".log"))); + assertTrue(Files.exists(root.resolve("editions").resolve(to.account() + ".log"))); + } + @Test void existingSelectionsOpaquePathsAndMalformedIdentitiesRemainConserved() throws Exception { + StudioStore store = store(); Project project = project(); Path source = legacy(project); + var original = RawYaml.read(Files.readAllBytes(source)); + var wrongFormat = new java.util.LinkedHashMap<>(original); wrongFormat.put("format", 9); Files.write(source, RawYaml.bytes(wrongFormat)); + UUID wrongId = UUID.randomUUID(); Files.write(source.getParent().resolve(wrongId + ".yml"), RawYaml.bytes(original)); + UUID goodId = UUID.randomUUID(); var good = new java.util.LinkedHashMap<>(original); good.put("id", goodId.toString()); + Files.write(source.getParent().resolve(goodId + ".yml"), RawYaml.bytes(good)); + Project existing = project(); store.saveOwned(from, existing); + Files.writeString(root.resolve("projects/legacy-selected.yml"), "opaque legacy file"); + var ownership = new StudioOwnership(store); ownership.migrate(reviewed()); ownership.migrate(reviewed()); + assertEquals(existing.song().id(), store.project(from.character()).orElseThrow().song().id()); + assertTrue(Files.exists(store.projectFile(from.character(), goodId))); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireEstateReady(from)); + ownership.requireEstateReady(new StudioIdentity.Owner(account, UUID.randomUUID())); + } + + @Test void missingSelectionAndUnfinishedMappingDelayEstateRatherThanPretendThereAreNoProjects() throws Exception { + Project project = project(); Path source = legacy(project); Files.delete(source.getParent().resolve("selected")); + StudioOwnership ownership = new StudioOwnership(store()); + assertThrows(IOException.class, () -> ownership.requireEstateReady(from)); + ownership.migrate(reviewed()); ownership.requireEstateReady(from); + assertFalse(Files.exists(store().directory(from.character()).resolve("selected"))); + } + + @Test void aFrozenRevisionCancelsOnlyAnUntouchedPlanAndPreservesPreviousReceipts() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger checks = new AtomicInteger(); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> checks.incrementAndGet() == 1)); + assertEquals(List.of(), ownership.retainedReceipts(death, "one", from)); + ownership.requireWritable(UUID.randomUUID()); + assertEquals(List.of(), ownership.retainedReceipts(death, "two frozen", from)); ownership.requireWritable(from.character()); + checks.set(0); + assertThrows(IOException.class, () -> ownership.transfer(death, "three", from, to, () -> checks.incrementAndGet() < 3)); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "four frozen", from)); + assertEquals(List.of(project.song().id().toString()), ownership.transfer(death, "three", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "three", new StudioIdentity.Owner(account, UUID.randomUUID()))); + } + + @Test void corruptManifestsAndMismatchedRecipientPayloadsAreBlockedBeforePublication() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger checks = new AtomicInteger(); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> checks.incrementAndGet() == 1)); + Path file = root.resolve("ownership/estates").resolve(death + ".xml"); var valid = StudioOwnership.read(file); String id = project.song().id().toString(); + for (String field : List.of("kind", id + ".state", id + ".before", id + ".before-hash", id + ".after-hash", id + ".to-account", id + ".to-character")) { + var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(field, "invalid"); StudioOwnership.write(file, changed); + assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file), field); + } + for (String field : List.of("kind", id + ".source", id + ".target", id + ".after")) { + var changed = new java.util.Properties(); changed.putAll(valid); changed.remove(field); StudioOwnership.write(file, changed); + assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file), field); + } + for (String field : List.of(id + ".to-account", id + ".to-character")) { + var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(field, UUID.randomUUID().toString()); StudioOwnership.write(file, changed); + assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file), field); + } + for (String target : List.of(valid.getProperty(id + ".source"), "character-projects/" + UUID.randomUUID() + "/" + id + ".yml")) { + var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(id + ".target", target); StudioOwnership.write(file, changed); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + } + var changed = new java.util.Properties(); changed.putAll(valid); + var after = RawYaml.read(java.util.Base64.getDecoder().decode(changed.getProperty(id + ".after"))); + UUID otherAccount = UUID.randomUUID(); changed.setProperty(id + ".to-account", otherAccount.toString()); after.put("owner-account", otherAccount.toString()); + byte[] bytes = RawYaml.bytes(after); changed.setProperty(id + ".after", java.util.Base64.getEncoder().encodeToString(bytes)); changed.setProperty(id + ".after-hash", StudioOwnership.hash(bytes)); + StudioOwnership.write(file, changed); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + Files.writeString(file, "broken XML"); assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file)); + } + + @Test void damagedImmutableBackupsAndInvalidSourceAuthoritiesNeverProduceAnotherCopy() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + Path source = store.projectFile(from.character(), project.song().id()); byte[] original = Files.readAllBytes(source); + Path backup = root.resolve("ownership/backups").resolve(StudioOwnership.hash(original) + ".yml"); + Files.createDirectories(backup.getParent()); Files.writeString(backup, "tampered backup"); + StudioOwnership ownership = new StudioOwnership(store); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(UUID.randomUUID(), "one", from, to, () -> true)); + Files.delete(backup); + for (String field : List.of("id", "owner-character")) { + var raw = RawYaml.read(original); raw.put(field, UUID.randomUUID().toString()); Files.write(source, RawYaml.bytes(raw)); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(UUID.randomUUID(), "one", from, to, () -> true)); + } + Files.write(source, original); + UUID death = UUID.randomUUID(); AtomicInteger calls = new AtomicInteger(); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> calls.incrementAndGet() < 3)); + Files.delete(source); Files.delete(store.projectFile(to.character(), project.song().id())); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + } + + @Test void pendingDestinationCannotBeCancelledByAChangedFrozenDecisionEvenBeforeTheReceiptWrites() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); Path target = store.projectFile(to.character(), project.song().id()); + try (var disk = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + disk.when(() -> DurableFiles.write(eq(target), any(byte[].class), eq(false))).thenAnswer(call -> { call.callRealMethod(); throw new IOException("after publication"); }); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + } + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "two frozen", from)); + } + + @Test void strictOwnershipPublicationsRefuseAFileSystemWithoutAtomicMoves() throws Exception { + Path target = root.resolve("original"); Files.writeString(target, "original"); + try (var disk = mockStatic(Files.class, CALLS_REAL_METHODS)) { + disk.when(() -> Files.move(any(Path.class), any(Path.class), eq(java.nio.file.StandardCopyOption.ATOMIC_MOVE), eq(java.nio.file.StandardCopyOption.REPLACE_EXISTING))) + .thenThrow(new java.nio.file.AtomicMoveNotSupportedException("a", "b", "unsupported")); + assertThrows(IOException.class, () -> DurableFiles.write(target, "new".getBytes(), true)); + } + assertEquals("original", Files.readString(target)); + } + + @Test void hashUnavailableFailsBeforeAnyOwnershipCanBePublished() throws Exception { + try (var digest = mockStatic(java.security.MessageDigest.class)) { + digest.when(() -> java.security.MessageDigest.getInstance("SHA-256")).thenThrow(new java.security.NoSuchAlgorithmException("unavailable")); + assertThrows(AssertionError.class, () -> StudioOwnership.hash("unchanged".getBytes())); + } + } + + @Test void pendingLegacyPublicationsBlockOnlyTheirPlannedCharacterUntilRetryFinishes() throws Exception { + StudioStore store = store(); Project project = project(); legacy(project); + Files.writeString(root.resolve("projects/opaque.state"), "keep this opaque state file"); + StudioOwnership ownership = new StudioOwnership(store); Path target = store.projectFile(from.character(), project.song().id()); + try (var disk = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + disk.when(() -> DurableFiles.write(eq(target), any(byte[].class), eq(false))).thenThrow(new IOException("temporary disk failure")); + assertThrows(IOException.class, () -> ownership.migrate(reviewed())); + } + assertThrows(IOException.class, () -> ownership.requireEstateReady(from)); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireWritable(from.character())); + ownership.requireWritable(UUID.randomUUID()); + ownership.migrate(reviewed()); ownership.requireWritable(from.character()); ownership.requireEstateReady(from); + assertEquals("keep this opaque state file", Files.readString(root.resolve("projects/opaque.state"))); + } + + @Test void validButDifferentJournalTestatorsAndUnrecognizedFilesAreHeld() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger checks = new AtomicInteger(); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> checks.incrementAndGet() == 1)); + Path file = root.resolve("ownership/estates").resolve(death + ".xml"); + var different = new StudioIdentity.Owner(account, UUID.randomUUID()); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", different, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "one", different)); + var journal = StudioOwnership.read(file); + journal.setProperty("death", UUID.randomUUID().toString()); StudioOwnership.write(file, journal); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "one", from)); + journal.setProperty("death", death.toString()); + journal.setProperty("from-character", UUID.randomUUID().toString()); StudioOwnership.write(file, journal); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "one", from)); + journal.setProperty("death", UUID.randomUUID().toString()); StudioOwnership.write(file, journal); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "one", from)); + Path bad = store.directory(from.character()).resolve("0-not-a-uuid.yml"); Files.writeString(bad, "unreadable identity"); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(UUID.randomUUID(), "new", from, to, () -> true)); + } + + @Test void wrongAccountOrCharacterAuthorityCannotEditAProjectEvenWhenItsDirectoryIsKnown() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + Path file = store.projectFile(from.character(), project.song().id()); var valid = RawYaml.read(Files.readAllBytes(file)); + for (String field : List.of("owner-account", "owner-character")) { + var changed = new java.util.LinkedHashMap<>(valid); changed.put(field, UUID.randomUUID().toString()); Files.write(file, RawYaml.bytes(changed)); + assertThrows(IllegalArgumentException.class, () -> store.requireAuthority(from, project.song().id(), account)); + } + } + + @Test void immutablePublicationCannotOverwriteADestinationCreatedAfterTheExistenceCheck() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + Path target = store.projectFile(to.character(), project.song().id()); + byte[] rival = "racing recipient content".getBytes(StandardCharsets.UTF_8); + UUID death = UUID.randomUUID(); var ownership = new StudioOwnership(store); + try (var disk = mockStatic(Files.class, CALLS_REAL_METHODS)) { + disk.when(() -> Files.createLink(eq(target), any(Path.class))).thenAnswer(call -> { + Files.write(target, rival); return call.callRealMethod(); + }); + assertThrows(java.nio.file.FileAlreadyExistsException.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + } + assertArrayEquals(rival, Files.readAllBytes(target)); + assertTrue(Files.exists(store.projectFile(from.character(), project.song().id()))); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + try (var disk = mockStatic(Files.class, CALLS_REAL_METHODS)) { + disk.when(() -> Files.createLink(any(Path.class), any(Path.class))).thenThrow(new UnsupportedOperationException("unsupported links")); + assertThrows(IOException.class, () -> DurableFiles.write(root.resolve("unsupported"), rival, false)); + } + assertFalse(Files.exists(root.resolve("unsupported"))); + } + + @Test void acknowledgedPublicationForcesEveryDirectoryLinkIncludingPreviouslyCreatedParents() throws Exception { + Path target = root.resolve("new/character-projects/owner/project.yml"); + var forced = new java.util.ArrayList(); + try (var disk = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + disk.when(() -> DurableFiles.force(any(Path.class))).thenAnswer(call -> { forced.add(call.getArgument(0)); return call.callRealMethod(); }); + DurableFiles.write(target, "first".getBytes(), false); + var expected = new java.util.ArrayList(); + for (Path folder = target.toAbsolutePath().getParent(); folder != null; folder = folder.getParent()) expected.add(folder); + assertEquals(expected, forced); forced.clear(); + DurableFiles.write(target, "second".getBytes(), true); + assertEquals(expected, forced); + } + assertEquals("second", Files.readString(target)); + } + + @Test void replayRejectsInRootWrongSourcesAndFullReferenceOrPayloadAuthorityChanges() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + var ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); var calls = new AtomicInteger(); + assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> calls.incrementAndGet() == 1)); + Path file = root.resolve("ownership/estates").resolve(death + ".xml"); + var original = StudioOwnership.read(file); String id = project.song().id().toString(); + Path wrongSource = store.projectFile(UUID.randomUUID(), project.song().id()); Files.createDirectories(wrongSource.getParent()); + byte[] sourceBytes = Files.readAllBytes(store.projectFile(from.character(), project.song().id())); Files.write(wrongSource, sourceBytes); + var changed = new java.util.Properties(); changed.putAll(original); changed.setProperty(id + ".source", root.relativize(wrongSource).toString()); + StudioOwnership.write(file, changed); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertArrayEquals(sourceBytes, Files.readAllBytes(wrongSource)); + for (String field : List.of(id + ".from-account", id + ".from-character")) { + changed = new java.util.Properties(); changed.putAll(original); changed.setProperty(field, UUID.randomUUID().toString()); StudioOwnership.write(file, changed); + assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file), field); + } + for (String part : List.of("before", "after")) for (String field : List.of("id", "owner-account", "owner-character")) { + changed = new java.util.Properties(); changed.putAll(original); + var payload = RawYaml.read(java.util.Base64.getDecoder().decode(changed.getProperty(id + "." + part))); + payload.put(field, UUID.randomUUID().toString()); byte[] bytes = RawYaml.bytes(payload); + changed.setProperty(id + "." + part, java.util.Base64.getEncoder().encodeToString(bytes)); changed.setProperty(id + "." + part + "-hash", StudioOwnership.hash(bytes)); + StudioOwnership.write(file, changed); assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file), part + ":" + field); + } + // Reviewed migration uses the account source envelope, never a same-byte file elsewhere. + Files.delete(file); legacy(project); ownership.migrate(reviewed()); + Path migration = root.resolve("ownership/migration").resolve(account + ".xml"); var valid = StudioOwnership.read(migration); + for (String field : List.of("from-account", "to-account", "to-character")) { + changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(field, UUID.randomUUID().toString()); StudioOwnership.write(migration, changed); + assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(migration), field); + } + } + +} diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioSaveQueueTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioSaveQueueTest.java index e954cf7..224a4c5 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioSaveQueueTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioSaveQueueTest.java @@ -49,14 +49,14 @@ void inFlightSnapshotRemainsImmutableAndNewEditsYieldToAnotherOwner() throws Exc var started = new CountDownLatch(1); var release = new CountDownLatch(1); doAnswer(invocation -> { - Project snapshot = invocation.getArgument(0); + Project snapshot = invocation.getArgument(1); writes.add(snapshot); if (writes.size() == 1) { started.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("In-flight save timed out"); } return invocation.callRealMethod(); - }).when(fixture.store).save(any()); + }).when(fixture.store).saveOwned(any(), any()); Project firstEdit = new Project(original.song(), null, 80, false); Project finalEdit = new Project(original.song(), null, 180, true); try { @@ -83,12 +83,12 @@ void persistentStorageFailureDuringShutdownGetsOnlyOneFinalRetry() throws Except Player player = fixture.player(); fixture.studio.save(player, fixture.project(player)); clearInvocations(fixture.store); - doThrow(new IOException("Storage stays unavailable")).when(fixture.store).save(any()); + doThrow(new IOException("Storage stays unavailable")).when(fixture.store).saveOwned(any(), any()); try { fixture.take(player, "retained_take"); worker.submit(() -> {}).get(3, TimeUnit.SECONDS); // Failure completes before close. fixture.studio.close(); - verify(fixture.store, times(3)).save(any()); // Initial attempt, closing attempt, one closing retry. + verify(fixture.store, times(3)).saveOwned(any(), any()); // Initial attempt, closing attempt, one closing retry. assertNotNull(fixture.studio.requireProject(player).pending()); assertNull(new StudioStore(directory).project(player.getUniqueId()).orElseThrow().pending()); } finally { @@ -108,7 +108,7 @@ void failureAfterTheShutdownDeadlineDoesNotStartAnotherWrite() throws Exception started.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Late save timed out"); throw new IOException("The write failed after the shutdown deadline"); - }).when(fixture.store).save(any()); + }).when(fixture.store).saveOwned(any(), any()); try { fixture.take(player, "take_to_retry_when_storage_recovers"); assertTrue(started.await(2, TimeUnit.SECONDS)); @@ -117,11 +117,11 @@ void failureAfterTheShutdownDeadlineDoesNotStartAnotherWrite() throws Exception deadline.setLong(fixture.studio, System.nanoTime() - 1); release.countDown(); worker.submit(() -> {}).get(3, TimeUnit.SECONDS); - verify(fixture.store, times(2)).save(any()); // Original project and one failed take write. + verify(fixture.store, times(2)).saveOwned(any(), any()); // Original project and one failed take write. assertNotNull(fixture.studio.requireProject(player).pending()); } finally { release.countDown(); - doCallRealMethod().when(fixture.store).save(any()); + doCallRealMethod().when(fixture.store).saveOwned(any(), any()); fixture.studio.close(); } } @@ -137,9 +137,9 @@ void anotherMusiciansFinalTakeMustNotWaitBehindEveryObsoleteEdit() throws Except fixture.studio.save(second, fixture.project(second)); var writes = java.util.Collections.synchronizedList(new ArrayList()); doAnswer(invocation -> { - writes.add(((Project) invocation.getArgument(0)).song().owner()); + writes.add(((Project) invocation.getArgument(1)).song().owner()); return invocation.callRealMethod(); - }).when(fixture.store).save(any()); + }).when(fixture.store).saveOwned(any(), any()); var started = new CountDownLatch(1); var release = new CountDownLatch(1); worker.execute(() -> { @@ -157,7 +157,7 @@ void anotherMusiciansFinalTakeMustNotWaitBehindEveryObsoleteEdit() throws Except worker.submit(() -> {}).get(10, TimeUnit.SECONDS); int olderWrites = writes.indexOf(second.getUniqueId()); System.out.println("READINESS_FAIRNESS obsolete_edits_before_other_take=" + olderWrites); - assertTrue(olderWrites <= 2, + assertTrue(olderWrites >= 0 && olderWrites <= 2, "Other musicians' final takes should not wait behind " + olderWrites + " obsolete edits"); } finally { release.countDown(); @@ -183,7 +183,7 @@ void transientFailureCompletingDuringShutdownMustNotLoseTheOnlyNewTake() throws throw new IOException("One transient final-save failure; subsequent saves would succeed"); } return invocation.callRealMethod(); - }).when(fixture.store).save(any()); + }).when(fixture.store).saveOwned(any(), any()); // The failure is discovered only after close has checked which writes need retrying. doAnswer(invocation -> { release.countDown(); @@ -216,12 +216,12 @@ void retryingAFailedEditDoesNotEraseTheCheckpointOfARecordingInProgress() throws Project original = fixture.project(player); fixture.studio.save(player, original); Project edited = new Project(original.song(), null, 120, false); - doThrow(new IOException("One transient failure before the recording")).when(fixture.store).save(edited); + doThrow(new IOException("One transient failure before the recording")).when(fixture.store).saveOwned(any(), eq(edited)); try { fixture.studio.edit(player, edited); worker.submit(() -> {}).get(3, TimeUnit.SECONDS); fixture.tick.run(); // Clock 1 observes the failure; the edit stays cached and unsaved. - doCallRealMethod().when(fixture.store).save(any()); + doCallRealMethod().when(fixture.store).saveOwned(any(), any()); fixture.studio.record(player, 2); // The take starts at clock 3. fixture.tick.run(); fixture.tick.run(); fixture.studio.capture(player, "lute", "new_take", 1, 1); @@ -263,7 +263,7 @@ private Fixture(Path directory, ExecutorService worker) throws Exception { store = spy(new StudioStore(directory)); studio = new StudioService(plugin, manager, store, new StudioSettings(4, 3600, 1500, 6000, 32, 32, 2, 60, 200, 600, 20), - StudioStation.none(), mock(DiscItems.class), worker); + StudioStation.none(), mock(DiscItems.class), worker, TestIdentity.INSTANCE); } private Player player() { @@ -307,10 +307,11 @@ void repeatedEditsMustNotCreateAnUnboundedSaveBacklogBehindASlowDisk() throws Ex when(player.hasPermission("instruments.record")).thenReturn(true); when(server.getPlayer(owner)).thenReturn(player); var store = mock(StudioStore.class); + when(store.root()).thenReturn(directory); var worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); var studio = new StudioService(plugin, mock(InstrumentManager.class), store, new StudioSettings(4, 3600, 1500, 6000, 32, 32, 60, 60, 200, 600, 20), - StudioStation.none(), mock(DiscItems.class), worker); + StudioStation.none(), mock(DiscItems.class), worker, TestIdentity.INSTANCE); var song = new Song(UUID.randomUUID(), owner, "Musician", "Song", List.of(new Track(1, 20, 1, false, List.of(new Note(0, "lute", "note", 1, 1))))); studio.save(player, new Project(song, null, 100, false)); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioServiceTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioServiceTest.java index 1231615..98732a6 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioServiceTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioServiceTest.java @@ -59,7 +59,7 @@ void slowCheckpointDoesNotBlockStopCaller() throws Exception { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Checkpoint timed out"); } return invocation.callRealMethod(); - }).when(store).save(any()); + }).when(store).saveOwned(any(), any()); studio.record(player, 1); tick.run(); tick.run(); @@ -89,12 +89,13 @@ void editsDuringAPendingTakeSaveApplyAtOnceAndAreWrittenAfterIt() throws Excepti if (!release.await(3, TimeUnit.SECONDS)) throw new IOException("Save timed out"); } return invocation.callRealMethod(); - }).when(store).save(any()); + }).when(store).saveOwned(any(), any()); studio.record(player, 1); tick.run(); tick.run(); studio.capture(player, "lute", "final", 1, 1); studio.stop(player); assertTrue(started.await(3, TimeUnit.SECONDS)); + assertFalse(studio.quiescent(owner)); // An in-flight final take must drain before inheritance. Project kept = studio.requireProject(player).accept(); try { studio.edit(player, kept); // Does not wait for the take to be written. @@ -155,14 +156,14 @@ void aFailedSaveAfterDisconnectRetainsTheTakeForRetryOnRejoin() throws Exception studio.record(player, 1); tick.run(); tick.run(); studio.capture(player, "lute", "retained", 1, 1); - doThrow(new IOException("Disk full")).when(store).save(any()); + doThrow(new IOException("Disk full")).when(store).saveOwned(any(), any()); studio.quit(player); when(server.getPlayer(owner)).thenReturn(null); awaitSaves(); when(server.getPlayer(owner)).thenReturn(player); Project retained = studio.requireProject(player); assertEquals("retained", retained.pending().notes().getFirst().sound()); - doCallRealMethod().when(store).save(any()); + doCallRealMethod().when(store).saveOwned(any(), any()); studio.edit(player, retained.accept()); awaitSaves(); assertEquals("retained", new StudioStore(directory).project(owner).orElseThrow() @@ -185,10 +186,10 @@ void shutdownRetriesAFailedFinalSaveWhenStorageHasRecovered() throws Exception { studio.create(player, "Song", false); studio.record(player, 1); tick.run(); tick.run(); studio.capture(player, "lute", "recovered", 1, 1); - doThrow(new IOException("Temporary disk failure")).when(store).save(any()); + doThrow(new IOException("Temporary disk failure")).when(store).saveOwned(any(), any()); studio.stop(player); awaitSaveFutures(); // The failure has not yet been observed by the musical clock. - doCallRealMethod().when(store).save(any()); + doCallRealMethod().when(store).saveOwned(any(), any()); studio.close(); assertEquals("recovered", new StudioStore(directory).project(owner).orElseThrow() .pending().notes().getFirst().sound()); @@ -201,7 +202,7 @@ void disconnectAndShutdownDoNotRewriteProjectsAlreadyOnDisk() throws Exception { clearInvocations(store); studio.quit(player); studio.close(); - verify(store, never()).save(any()); + verify(store, never()).saveOwned(any(), any()); verify(player, never()).sendMessage(startsWith("Take saved")); } @@ -209,7 +210,7 @@ void disconnectAndShutdownDoNotRewriteProjectsAlreadyOnDisk() throws Exception { void disconnectEvictsACurrentProjectSoTheNextVisitReadsItsFiles() throws Exception { studio.save(player, new Project(song(track(1, "before")), null, 100, false)); studio.quit(player); - new StudioStore(directory).save(new Project(song(track(1, "after")), null, 100, false)); + new StudioStore(directory).saveOwned(new StudioIdentity.Owner(owner, owner), new Project(song(track(1, "after")), null, 100, false)); assertEquals("after", studio.requireProject(player).song().track(1).notes().getFirst().sound()); } @@ -218,10 +219,10 @@ void disconnectRetriesATakeWhoseSaveFailedWhileOnline() throws Exception { studio.create(player, "Song", false); studio.record(player, 1); tick.run(); tick.run(); studio.capture(player, "lute", "retry", 1, 1); - doThrow(new IOException("Temporary disk failure")).when(store).save(any()); + doThrow(new IOException("Temporary disk failure")).when(store).saveOwned(any(), any()); studio.stop(player); awaitSaves(); - doCallRealMethod().when(store).save(any()); + doCallRealMethod().when(store).saveOwned(any(), any()); studio.quit(player); when(server.getPlayer(owner)).thenReturn(null); awaitSaves(); @@ -234,13 +235,13 @@ void failedEditsAreKeptAndRetriedEveryMinuteUntilTheyAreWritten() throws Excepti Project project = new Project(song(track(1, "note")), null, 100, false); studio.save(player, project); Project renamed = new Project(new Song(project.song().id(), owner, "Musician", "Renamed", project.song().tracks()), null, 100, false); - doThrow(new IOException("Disk full")).when(store).save(any()); + doThrow(new IOException("Disk full")).when(store).saveOwned(any(), any()); studio.edit(player, renamed); awaitSaves(); verify(player).sendMessage(startsWith("Could not save your project")); assertEquals(renamed, studio.requireProject(player)); assertEquals(project, new StudioStore(directory).project(owner).orElseThrow()); - doCallRealMethod().when(store).save(any()); + doCallRealMethod().when(store).saveOwned(any(), any()); clock(StudioService.RETRY_TICKS - 2); tick.run(); assertFalse(awaitSaveFutures()); // Not retried before the minute. @@ -250,7 +251,7 @@ void failedEditsAreKeptAndRetriedEveryMinuteUntilTheyAreWritten() throws Excepti clearInvocations(store); clock(2 * StudioService.RETRY_TICKS - 1); tick.run(); - verify(store, never()).save(any()); // Nothing left to retry. + verify(store, never()).saveOwned(any(), any()); // Nothing left to retry. } @Test @@ -261,18 +262,18 @@ void switchingProjectsOnlyRewritesTheSelectionUnlessAWriteFailed() throws Except studio.save(player, second); clearInvocations(store); studio.selectProject(player, first.song().id()); - verify(store, never()).save(any()); + verify(store, never()).saveOwned(any(), any()); assertEquals(first, new StudioStore(directory).project(owner).orElseThrow()); Project renamed = new Project(new Song(first.song().id(), owner, "Musician", "Renamed", first.song().tracks()), null, 100, false); - doThrow(new IOException("Disk full")).when(store).save(any()); + doThrow(new IOException("Disk full")).when(store).saveOwned(any(), any()); studio.edit(player, renamed); awaitSaves(); - doCallRealMethod().when(store).save(any()); + doCallRealMethod().when(store).saveOwned(any(), any()); studio.selectProject(player, second.song().id()); assertEquals(renamed, new StudioStore(directory).project(owner, first.song().id())); assertEquals(second, new StudioStore(directory).project(owner).orElseThrow()); doReturn(second).when(store).project(owner, first.song().id()); // The file disappears after it was read. - java.nio.file.Files.delete(directory.resolve("projects").resolve(owner.toString()).resolve(first.song().id() + ".yml")); + java.nio.file.Files.delete(directory.resolve("character-projects").resolve(owner.toString()).resolve(first.song().id() + ".yml")); assertThrows(IllegalArgumentException.class, () -> studio.selectProject(player, first.song().id())); } @@ -286,7 +287,7 @@ void shutdownWaitIsBoundedAndInterruptionPreservesQueuedSaves(boolean interrupte if (interrupted) doThrow(new InterruptedException("Shutdown interrupted")).when(worker).awaitTermination(StudioService.SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS); else doReturn(false).when(worker).awaitTermination(StudioService.SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS); studio = new StudioService(plugin, manager, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0, 0), - StudioStation.none(), discs, worker); + StudioStation.none(), discs, worker, TestIdentity.INSTANCE); var callbacks = ArgumentCaptor.forClass(Runnable.class); verify(server.getScheduler(), times(2)).runTaskTimer(eq(plugin), callbacks.capture(), eq(1L), eq(1L)); tick = callbacks.getAllValues().getLast(); @@ -299,7 +300,7 @@ void shutdownWaitIsBoundedAndInterruptionPreservesQueuedSaves(boolean interrupte if (!release.await(3, TimeUnit.SECONDS)) throw new IOException("Save timed out"); } return invocation.callRealMethod(); - }).when(store).save(any()); + }).when(store).saveOwned(any(), any()); studio.record(player, 1); tick.run(); tick.run(); studio.capture(player, "lute", "final", 1, 1); studio.stop(player); @@ -364,7 +365,7 @@ void setup() throws Exception { when(world.getPlayers()).thenReturn(List.of(player)); when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenReturn(task); studio = new StudioService(plugin, manager, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0, 0), - StudioStation.none(), discs); + StudioStation.none(), discs, TestIdentity.INSTANCE); var callback = ArgumentCaptor.forClass(Runnable.class); verify(scheduler).runTaskTimer(eq(plugin), callback.capture(), eq(1L), eq(1L)); tick = callback.getValue(); @@ -481,7 +482,7 @@ void checkpointsAndShutdownRecoverAnUnacceptedTake() throws Exception { Object result = invocation.callRealMethod(); checkpointed.countDown(); return result; - }).when(store).save(any()); + }).when(store).saveOwned(any(), any()); studio.record(player, 1); tick.run(); tick.run(); @@ -521,7 +522,7 @@ void anOlderBackgroundCheckpointCannotOverwriteTheFinalTake() throws Exception { } } return invocation.callRealMethod(); - }).when(store).save(any()); + }).when(store).saveOwned(any(), any()); studio.record(player, 1); tick.run(); tick.run(); @@ -799,7 +800,7 @@ void fullLibraryRejectsCreationUntilAProjectIsDeletedAndResetRemainsAllowed() th assertEquals(36, studio.listProjects(player).size()); // Without an active project, a reset would add a 37th project. studio.quit(player); - java.nio.file.Files.writeString(directory.resolve("projects").resolve(owner.toString()).resolve(StudioStore.SELECTED), "garbage"); + java.nio.file.Files.writeString(directory.resolve("character-projects").resolve(owner.toString()).resolve(StudioStore.SELECTED), "garbage"); assertNull(studio.project(player)); assertThrows(IllegalArgumentException.class, () -> studio.create(player, "Untitled", true)); assertEquals(36, studio.listProjects(player).size()); @@ -860,12 +861,12 @@ void deletionMustRejectAReplacementTakeOfTheSameDuration() throws Exception { @Test void deletionRejectsEditsToAnInactiveProjectAndLoadsOnlyThatProject() throws Exception { Project original = new Project(song(track(1, "original")), null, 100, false); - store.save(original); + store.saveOwned(new StudioIdentity.Owner(owner, owner), original); Project active = new Project(song(track(1, "other_song")), null, 100, false); studio.save(player, active); Project confirmation = studio.project(player, original.song().id()); Project changed = new Project(original.song().replace(track(1, "replacement")), null, 100, false); - store.save(changed); + store.saveOwned(new StudioIdentity.Owner(owner, owner), changed); store.select(owner, active.song().id()); assertEquals(ProjectSummary.of(original), ProjectSummary.of(changed)); assertThrows(IllegalArgumentException.class, () -> studio.deleteProject(player, confirmation)); @@ -910,14 +911,15 @@ void deletionChecksTheNewerCachedContentAfterItsWriteFails() throws Exception { studio.save(player, original); Project confirmation = studio.project(player, original.song().id()); Project changed = new Project(original.song().replace(track(1, "replacement")), null, 100, false); - doThrow(new IOException("Write failed")).when(store).save(changed); + doThrow(new IOException("Write failed")).when(store).saveOwned(any(), eq(changed)); studio.edit(player, changed); awaitSaves(); assertEquals(original, store.project(owner, original.song().id())); + assertFalse(studio.quiescent(owner)); // A completed failed write still holds the unsaved property. assertEquals(changed, studio.project(player, original.song().id())); assertThrows(IllegalArgumentException.class, () -> studio.deleteProject(player, confirmation)); assertEquals(original, store.project(owner, original.song().id())); assertEquals(changed, studio.requireProject(player)); - doCallRealMethod().when(store).save(changed); + doCallRealMethod().when(store).saveOwned(any(), eq(changed)); } @Test @@ -948,6 +950,7 @@ void providedDiscSurvivesPublicationFailureAndPendingTakesCannotBePublished() th when(original.getAmount()).thenReturn(1); assertThrows(IllegalArgumentException.class, () -> studio.publishOnDisc(player, original)); studio.edit(player, project.discard()); + awaitSaves(); doThrow(new IOException("disk full")).when(store).publish(any()); assertThrows(IOException.class, () -> studio.publishOnDisc(player, original)); verify(discs, never()).disc(any(Song.class), any(ItemStack.class)); @@ -1119,7 +1122,7 @@ void failedCheckpointAndFinalSaveRetainTheTakeInMemoryForRetry() throws Exceptio throw new IOException("Checkpoint disk full"); } return invocation.callRealMethod(); - }).when(store).save(any()); + }).when(store).saveOwned(any(), any()); studio.record(player, 1); tick.run(); tick.run(); studio.capture(player, "lute", "saved", 1, 1); @@ -1133,7 +1136,7 @@ void failedCheckpointAndFinalSaveRetainTheTakeInMemoryForRetry() throws Exceptio checkpointField.setAccessible(true); assertThrows(java.util.concurrent.CompletionException.class, () -> ((CompletableFuture) checkpointField.get(capture)).join()); - doThrow(new IOException("Final disk full")).when(store).save(any()); + doThrow(new IOException("Final disk full")).when(store).saveOwned(any(), any()); tick.run(); tick.run(); assertFalse(studio.activity(player).recording()); assertEquals("saved", studio.requireProject(player).pending().notes().getFirst().sound()); @@ -1141,7 +1144,7 @@ void failedCheckpointAndFinalSaveRetainTheTakeInMemoryForRetry() throws Exceptio assertNotNull(studio.requireProject(player).pending()); studio.close(); verify(player, atLeastOnce()).sendMessage(startsWith("Could not save your project")); - doCallRealMethod().when(store).save(any()); + doCallRealMethod().when(store).saveOwned(any(), any()); } @Test @@ -1215,7 +1218,7 @@ void jukeboxInsertionRejectsInvalidDiscsOccupiedBoxesAndChangedBlocks() throws E void newSessionsCanSelectStoredProjectsAndDeleteAnUnselectedProject() throws Exception { Project first = new Project(song(), null, 100, false); Project second = new Project(song(), null, 100, false); - store.save(first); store.save(second); + store.saveOwned(new StudioIdentity.Owner(owner, owner), first); store.saveOwned(new StudioIdentity.Owner(owner, owner), second); assertEquals(first, studio.project(player, first.song().id())); studio.deleteProject(player, first); assertEquals(second, studio.requireProject(player)); @@ -1267,7 +1270,7 @@ void copyingFromInventoryReadsImmutableEditionsAndEvictsOldCachedSongs() throws private void configure(StudioSettings settings) { studio.close(); - studio = new StudioService(plugin, manager, store, settings, StudioStation.none(), discs); + studio = new StudioService(plugin, manager, store, settings, StudioStation.none(), discs, TestIdentity.INSTANCE); var callbacks = ArgumentCaptor.forClass(Runnable.class); verify(plugin.getServer().getScheduler(), times(2)).runTaskTimer(eq(plugin), callbacks.capture(), eq(1L), eq(1L)); tick = callbacks.getAllValues().getLast(); @@ -1329,7 +1332,7 @@ void jukeboxLifecycleHandlesUnloadedChunksReplacedBlocksAndFuturePlayback() thro @Test void archivedProjectCanBeDeletedWhenTheSelectedFileIsMissing() throws Exception { Project project = new Project(song(), null, 100, false); - store.save(project); + store.saveOwned(new StudioIdentity.Owner(owner, owner), project); doReturn(java.util.Optional.empty()).when(store).project(owner); assertEquals(project, studio.project(player, project.song().id())); assertThrows(IllegalArgumentException.class, () -> studio.project(player, UUID.randomUUID())); @@ -1341,7 +1344,7 @@ void archivedProjectCanBeDeletedWhenTheSelectedFileIsMissing() throws Exception void anEmptyCaptureDoesNotQueueCheckpointsAndInvalidDiscsCannotPublish() throws Exception { studio.create(player, "Empty", false); studio.record(player, 1); for (int index = 0; index < 6; index++) tick.run(); - verify(store, times(1)).save(any()); + verify(store, times(1)).saveOwned(any(), any()); studio.stop(player); awaitSaves(); assertThrows(IllegalArgumentException.class, () -> studio.publishOnDisc(player, mock(ItemStack.class))); } @@ -1358,4 +1361,91 @@ void changedJukeboxStatesAreClearedAndAReplacedRecordStopsThePreviousSong() thro tick.run(); verify(player, never()).playSound(any(Location.class), eq("note"), any(SoundCategory.class), anyFloat(), anyFloat()); } + @Test void outgoingCleanupRetriesFailedEditsEvenWithoutACurrentSession() throws Exception { + studio.save(player, new Project(song(track(1, "original")), null, 100, false)); + Project changed = new Project(studio.requireProject(player).song(), null, 125, true); + doThrow(new IOException("temporary disk failure")).when(store).saveOwned(any(), any()); + studio.edit(player, changed); awaitSaves(); + assertFalse(studio.quiescent(owner)); + doCallRealMethod().when(store).saveOwned(any(), any()); + TestIdentity.INSTANCE.sessions.put(owner, null); + try { + studio.quit(player); // Join/quit without a current identity never selects an account fallback. + studio.end(player, new StudioIdentity.Token(new StudioIdentity.Owner(owner, owner), 1)); + awaitSaves(); + assertEquals(125, store.project(owner).orElseThrow().bpm()); + studio.end(player, new StudioIdentity.Token(new StudioIdentity.Owner(owner, owner), 1)); + assertThrows(IllegalArgumentException.class, () -> studio.listProjects(player)); + } finally { TestIdentity.INSTANCE.sessions.remove(owner); } + } + + @Test void staleCaptureAndPreviewCannotFollowAnotherCharacterOnTheSameAccount() throws Exception { + studio.save(player, new Project(song(track(1, "original")), null, 100, false)); + studio.record(player, 1); tick.run(); tick.run(); studio.capture(player, "lute", "outgoing", 1, 1); + assertFalse(studio.quiescent(owner)); assertTrue(studio.quiescent(UUID.randomUUID())); + TestIdentity.INSTANCE.sessions.put(owner, new StudioIdentity.Token(new StudioIdentity.Owner(owner, UUID.randomUUID()), 2)); + try { + studio.capture(player, "lute", "incoming_must_be_ignored", 1, 1); + tick.run(); awaitSaves(); + assertEquals(List.of("outgoing"), store.project(owner).orElseThrow().pending().notes().stream().map(Note::sound).toList()); + } finally { TestIdentity.INSTANCE.sessions.remove(owner); } + studio.edit(player, studio.requireProject(player).discard()); awaitSaves(); + studio.preview(player, false); tick.run(); + TestIdentity.INSTANCE.sessions.put(owner, new StudioIdentity.Token(new StudioIdentity.Owner(owner, UUID.randomUUID()), 3)); + try { tick.run(); assertFalse(studio.activity(player).previewing()); } + finally { TestIdentity.INSTANCE.sessions.remove(owner); } + } + + @Test void aFailedEditIsNotRewrittenOverAnActiveCaptureAtTheRetryTick() throws Exception { + studio.save(player, new Project(song(track(1, "original")), null, 100, false)); + doThrow(new IOException("disk failure")).when(store).saveOwned(any(), any()); + studio.edit(player, new Project(studio.requireProject(player).song(), null, 125, false)); awaitSaves(); + clock(StudioService.RETRY_TICKS - 1); studio.record(player, 1); clearInvocations(store); + tick.run(); verify(store, never()).saveOwned(any(), any()); + studio.end(player, new StudioIdentity.Token(new StudioIdentity.Owner(owner, UUID.randomUUID()), 99)); + doCallRealMethod().when(store).saveOwned(any(), any()); studio.stop(player); awaitSaves(); + } + + @Test void unknownDiskAuthorityAndCorruptOwnershipJournalsFailClosed() throws Exception { + doThrow(new IOException("owner file inaccessible")).when(store).requireAuthority(any(), any(), any()); + assertThrows(IllegalArgumentException.class, () -> studio.create(player, "Unavailable", false)); + doCallRealMethod().when(store).requireAuthority(any(), any(), any()); + var folder = directory.resolve("ownership/estates"); java.nio.file.Files.createDirectories(folder); + var corrupt = folder.resolve("broken.xml"); java.nio.file.Files.writeString(corrupt, "broken XML"); + assertThrows(IllegalArgumentException.class, () -> studio.token(player)); + java.nio.file.Files.delete(corrupt); + studio.create(player, "Recovered", false); + } + + @Test void runtimeMigrationReportsDurableWriteFailuresWithoutChangingTheLegacySource() throws Exception { + Project project = new Project(song(track(1, "original")), null, 100, false); studio.save(player, project); + var source = directory.resolve("projects").resolve(owner.toString()).resolve(project.song().id() + ".yml"); + java.nio.file.Files.createDirectories(source.getParent()); + java.nio.file.Files.copy(store.projectFile(owner, project.song().id()), source); + java.nio.file.Files.createDirectories(directory.resolve("ownership")); + var blocked = directory.resolve("ownership/migration"); java.nio.file.Files.writeString(blocked, "block directory creation"); + studio.startOwnership(mock(StudioMenu.class)); + var callback = ArgumentCaptor.forClass(Runnable.class); + verify(server.getScheduler()).runTaskTimer(eq(plugin), callback.capture(), eq(1L), eq(72_000L)); + assertDoesNotThrow(() -> callback.getValue().run()); + assertTrue(java.nio.file.Files.exists(source)); + java.nio.file.Files.delete(blocked); + } + + @Test void staleProviderTokensAreRejectedBeforePropertyOperations() throws Exception { + var identity = mock(StudioIdentity.class); + var token = new StudioIdentity.Token(new StudioIdentity.Owner(owner, owner), 1); + when(identity.current(player)).thenReturn(java.util.Optional.of(token)); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + var closedStudio = new StudioService(plugin, manager, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0, 0), StudioStation.none(), discs, worker, identity); + try { + assertThrows(IllegalArgumentException.class, () -> closedStudio.token(player)); + when(identity.current(token)).thenReturn(true); + assertEquals(token, closedStudio.token(player)); + } + finally { closedStudio.close(); } + var optionalStudio = new StudioService(plugin, manager, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0, 0), StudioStation.none(), discs, java.util.concurrent.Executors.newSingleThreadExecutor()); + optionalStudio.close(); + } + } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreRecoveryTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreRecoveryTest.java index cbcbcdc..b47d691 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreRecoveryTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreRecoveryTest.java @@ -20,7 +20,7 @@ class StudioStoreRecoveryTest { void oversizedSerializationCannotReplaceTheLastSavedFile() throws Exception { StudioStore store = new StudioStore(directory); Project project = project(); store.save(project); - Path selected = directory.resolve("projects/" + project.song().owner() + "/" + project.song().id() + ".yml"); + Path selected = directory.resolve("character-projects/" + project.song().owner() + "/" + project.song().id() + ".yml"); byte[] original = Files.readAllBytes(selected); YamlConfiguration oversized = mock(YamlConfiguration.class); when(oversized.saveToString()).thenReturn("x".repeat(StudioStore.MAX_FILE_BYTES + 1)); @@ -45,7 +45,7 @@ void removedProjectFilesAreNoLongerAvailable() throws Exception { store.save(original); UUID owner = original.song().owner(); assertThrows(IllegalArgumentException.class, () -> store.project(owner, UUID.randomUUID())); - Files.delete(directory.resolve("projects/" + owner + "/" + original.song().id() + ".yml")); + Files.delete(directory.resolve("character-projects/" + owner + "/" + original.song().id() + ".yml")); assertThrows(IllegalArgumentException.class, () -> store.project(owner, original.song().id())); assertTrue(store.project(owner).isEmpty()); assertTrue(store.summaries(owner).isEmpty()); @@ -55,7 +55,7 @@ void removedProjectFilesAreNoLongerAvailable() throws Exception { void malformedArchiveNamesAndMismatchedIdsAreRejected() throws Exception { StudioStore store = new StudioStore(directory); Project project = project(); store.save(project); - Path folder = directory.resolve("projects/" + project.song().owner()); + Path folder = directory.resolve("character-projects/" + project.song().owner()); Files.writeString(folder.resolve("ignore.tmp"), "ignored"); assertEquals(List.of(ProjectSummary.of(project)), store.summaries(project.song().owner())); Files.writeString(folder.resolve("invalid.yml"), "invalid"); @@ -76,7 +76,7 @@ void deletionOfAnUnselectedProjectKeepsSelectionAndSortsEqualTitlesById() throws assertEquals(2, store.summaries(song.owner()).size()); store.delete(song.owner(), first.song().id()); assertEquals(second, store.project(song.owner()).orElseThrow()); - Files.delete(directory.resolve("projects/" + song.owner() + "/" + StudioStore.SELECTED)); + Files.delete(directory.resolve("character-projects/" + song.owner() + "/" + StudioStore.SELECTED)); store.delete(song.owner(), second.song().id()); assertTrue(store.summaries(song.owner()).isEmpty()); } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java index 99b4c79..8e53286 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioStoreTest.java @@ -25,7 +25,7 @@ void maximumSizeProjectAndReplacementTakeStayCompactAndReadable() throws Excepti saved, 100, false); StudioStore store = new StudioStore(directory); store.save(project); - long bytes = Files.size(directory.resolve("projects").resolve(project.song().owner().toString()).resolve(project.song().id() + ".yml")); + long bytes = Files.size(directory.resolve("character-projects").resolve(project.song().owner().toString()).resolve(project.song().id() + ".yml")); assertTrue(bytes < 600_000, "A YAML list per note needed over 8 MB for this project: " + bytes); assertEquals(project, store.project(project.song().owner()).orElseThrow()); } @@ -64,8 +64,8 @@ void savesWriteOneProjectCopyAndASmallSelection() throws Exception { store.save(first); store.save(first.accept()); store.save(second); - Path folder = directory.resolve("projects").resolve(song.owner().toString()); - try (var files = Files.list(directory.resolve("projects"))) { + Path folder = directory.resolve("character-projects").resolve(song.owner().toString()); + try (var files = Files.list(directory.resolve("character-projects"))) { assertEquals(List.of(folder), files.toList()); } try (var files = Files.list(folder)) { @@ -86,7 +86,7 @@ void savesWriteOneProjectCopyAndASmallSelection() throws Exception { void startupRemovesTemporaryFilesLeftByAnInterruptedWrite() throws Exception { Project project = project(); new StudioStore(directory).save(project); - Path folder = directory.resolve("projects").resolve(project.song().owner().toString()); + Path folder = directory.resolve("character-projects").resolve(project.song().owner().toString()); Path leftover = Files.writeString(folder.resolve("studio-123.tmp"), "partial"); Path unrelated = Files.writeString(folder.resolve("notes.tmp"), "kept"); Path similar = Files.writeString(directory.resolve("songs").resolve("studio-readme.txt"), "kept"); @@ -220,7 +220,7 @@ void unreadableProjectsAreSkippedAndReportedOnceWhileTheRestOfTheLibraryWorks() Project healthy = new Project(new Song(UUID.randomUUID(), owner, "Musician", "Healthy", List.of()), null, 100, false); store.save(damaged); store.save(healthy); - Path folder = directory.resolve("projects").resolve(owner.toString()); + Path folder = directory.resolve("character-projects").resolve(owner.toString()); Path broken = folder.resolve(damaged.song().id() + ".yml"); Files.writeString(broken, "format: 2\nid: [broken"); Files.writeString(folder.resolve("notes.yml"), "not a project"); @@ -257,7 +257,7 @@ void invalidUtf8IsDamagedContentAndLeavesTheLibrarySelectionAndPublishingUsable( Project healthy = new Project(new Song(UUID.randomUUID(), owner, "Musician", "Healthy", List.of()), null, 100, false); store.save(healthy); store.save(damaged); - Path folder = directory.resolve("projects").resolve(owner.toString()); + Path folder = directory.resolve("character-projects").resolve(owner.toString()); Files.write(folder.resolve(damaged.song().id() + ".yml"), invalid); assertEquals(List.of(ProjectSummary.of(healthy)), store.summaries(owner)); assertTrue(store.project(owner).isEmpty()); // The damaged project was the active one. @@ -281,9 +281,9 @@ void filenameIdentityMustMatchTheStoredOwnerOrEdition() throws Exception { Project project = project(); store.save(project); UUID anotherOwner = UUID.randomUUID(); - Path foreign = directory.resolve("projects").resolve(anotherOwner.toString()); + Path foreign = directory.resolve("character-projects").resolve(anotherOwner.toString()); Files.createDirectories(foreign); - Files.copy(directory.resolve("projects").resolve(project.song().owner().toString()).resolve(project.song().id() + ".yml"), + Files.copy(directory.resolve("character-projects").resolve(project.song().owner().toString()).resolve(project.song().id() + ".yml"), foreign.resolve(project.song().id() + ".yml")); Files.writeString(foreign.resolve(StudioStore.SELECTED), project.song().id().toString()); assertTrue(store.project(anotherOwner).isEmpty()); @@ -313,7 +313,7 @@ void libraryListingUsesSavedSummariesWithoutReparsingProjects() throws Exception StudioStore store = new StudioStore(directory); Project project = project(); store.save(project); - Path file = directory.resolve("projects").resolve(project.song().owner().toString()).resolve(project.song().id() + ".yml"); + Path file = directory.resolve("character-projects").resolve(project.song().owner().toString()).resolve(project.song().id() + ".yml"); var modified = Files.getLastModifiedTime(file); Files.writeString(file, "x".repeat((int) Files.size(file))); // Same size and time: the index entry still applies. Files.setLastModifiedTime(file, modified); @@ -326,7 +326,7 @@ void filesChangedOutsideSaveAndDamagedIndexEntriesAreReparsed() throws Exception Project project = project(); UUID owner = project.song().owner(); store.save(project); - Path folder = directory.resolve("projects").resolve(owner.toString()); + Path folder = directory.resolve("character-projects").resolve(owner.toString()); Path file = folder.resolve(project.song().id() + ".yml"); var yaml = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(file.toFile()); yaml.set("title", "Edited outside the studio"); @@ -349,7 +349,7 @@ void filesChangedOutsideSaveAndDamagedIndexEntriesAreReparsed() throws Exception void anUnwritableIndexLeavesTheLibraryUsable() throws Exception { StudioStore store = new StudioStore(directory); Project project = project(); - Path index = directory.resolve("projects").resolve(project.song().owner().toString()).resolve(StudioStore.INDEX); + Path index = directory.resolve("character-projects").resolve(project.song().owner().toString()).resolve(StudioStore.INDEX); Files.createDirectories(index.resolve("blocked")); // A non-empty directory cannot be replaced by the cache file. store.save(project); assertEquals(List.of(ProjectSummary.of(project)), store.summaries(project.song().owner())); @@ -363,7 +363,7 @@ void deletionRemovesTheSelectionAndRejectsUnknownProjects() throws Exception { store.save(project); assertThrows(IllegalArgumentException.class, () -> store.delete(owner, UUID.randomUUID())); store.delete(owner, project.song().id()); - assertFalse(Files.exists(directory.resolve("projects").resolve(owner.toString()).resolve(StudioStore.SELECTED))); + assertFalse(Files.exists(directory.resolve("character-projects").resolve(owner.toString()).resolve(StudioStore.SELECTED))); assertTrue(store.project(owner).isEmpty()); assertTrue(store.summaries(owner).isEmpty()); } @@ -377,8 +377,8 @@ void projectLibrariesAndDirectSelectionCannotCrossOwners() throws Exception { store.save(other); assertEquals(List.of(ProjectSummary.of(first)), store.summaries(first.song().owner())); assertThrows(IllegalArgumentException.class, () -> store.project(first.song().owner(), other.song().id())); - Path copied = directory.resolve("projects").resolve(first.song().owner().toString()).resolve(other.song().id() + ".yml"); - Files.copy(directory.resolve("projects").resolve(other.song().owner().toString()).resolve(other.song().id() + ".yml"), copied); + Path copied = directory.resolve("character-projects").resolve(first.song().owner().toString()).resolve(other.song().id() + ".yml"); + Files.copy(directory.resolve("character-projects").resolve(other.song().owner().toString()).resolve(other.song().id() + ".yml"), copied); assertEquals(List.of(ProjectSummary.of(first)), store.summaries(first.song().owner())); assertThrows(IOException.class, () -> store.project(first.song().owner(), other.song().id())); } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/TestIdentity.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/TestIdentity.java new file mode 100644 index 0000000..15459e8 --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/TestIdentity.java @@ -0,0 +1,26 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; + +/** Explicit identity fixture; production always uses the optional registered character service. */ +final class TestIdentity implements StudioIdentity { + static final TestIdentity INSTANCE = new TestIdentity(); + final Map sessions = new HashMap<>(); + final Map heirs = new HashMap<>(); + boolean ready = true; + boolean defaultHeirs = true; + public Optional current(Player player) { + UUID account = player.getUniqueId(); + return Optional.ofNullable(sessions.getOrDefault(account, new Token(new Owner(account, account), 1))); + } + public boolean current(Token token) { + return token != null && token.equals(sessions.getOrDefault(token.owner().account(), + new Token(new Owner(token.owner().account(), token.owner().account()), 1))); + } + public boolean heirsReady() { return ready; } + public Optional heir(UUID account) { return Optional.ofNullable(heirs.getOrDefault(account, defaultHeirs ? new Owner(account, account) : null)); } +} diff --git a/tools/studio-ownership.py b/tools/studio-ownership.py new file mode 100755 index 0000000..f0dc648 --- /dev/null +++ b/tools/studio-ownership.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Inspect studio ownership journals and preview/apply an offline guarded rollback.""" +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import tempfile +import xml.etree.ElementTree as ET + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def safe(root, relative): + path = (root / relative).resolve() + if not path.is_relative_to(root.resolve()): + raise ValueError("journal path escapes studio root") + return path + + +def durable(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix="studio-", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + force_ancestors(path.parent) + finally: + if os.path.exists(name): + os.unlink(name) + + +def force(folder): + descriptor = os.open(folder, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def force_ancestors(folder): + force(folder) + for parent in folder.parents: + force(parent) + + +def inspect(root, kind, apply=False): + result = {"journals": [], "unplanned_projects": [], "unassigned_legacy": []} + expected = set() + files = list((root / "ownership" / kind).glob("*.xml")) + for journal in sorted(files, key=lambda path: path.stat().st_mtime_ns, reverse=True): + report = {"journal": str(journal), "records": [], "held": {}} + try: + fields = {entry.attrib["key"]: entry.text or "" for entry in ET.parse(journal).getroot().findall("entry")} + if fields.get("format") != "1": + raise ValueError("unsupported journal format") + report["held"] = {key: value for key, value in fields.items() if key.startswith("held.")} + ids = sorted(key[:-6] for key in fields if key.endswith(".state") + and not key.startswith(("held.", "held-source."))) + for identity in ids: + source = safe(root, fields[identity + ".source"]) + target = safe(root, fields[identity + ".target"]) + expected.add(target) + before = base64.b64decode(fields[identity + ".before"], validate=True) + if digest(before) != fields[identity + ".before-hash"]: + raise ValueError("source backup hash mismatch") + row = {"id": identity, "state": fields[identity + ".state"]} + source_ok = not source.exists() or digest(source.read_bytes()) == fields[identity + ".before-hash"] + target_ok = not target.exists() or digest(target.read_bytes()) == fields[identity + ".after-hash"] + if not source_ok or not target_ok: + row["result"] = "conflict; preserve current files for staff" + elif not target.exists() and not source.exists(): + row["result"] = "conflict; both copies missing" + else: + row["result"] = "safe offline rollback" + if apply: + if not source.exists(): + durable(source, before) + if target.exists(): + target.unlink() + force(target.parent) + row["result"] = "restored" + report["records"].append(row) + # Keep receipt available while any conflicting publication needs staff recovery. + if apply and all(row["result"] == "restored" for row in report["records"]): + archive = root / "ownership" / "rolled-back" / kind / journal.name + if archive.exists(): + raise ValueError("rollback archive already exists") + archive.parent.mkdir(parents=True, exist_ok=True) + os.rename(journal, archive) + force_ancestors(archive.parent) + force(journal.parent) + report["archived"] = str(archive) + except (OSError, ValueError, KeyError, ET.ParseError) as error: + report["error"] = str(error) + result["journals"].append(report) + for path in sorted((root / "character-projects").glob("*/*.yml")): + if path.resolve() not in expected: + result["unplanned_projects"].append(str(path)) + for folder in sorted((root / "projects").iterdir()) if (root / "projects").is_dir() else []: + if not folder.is_dir() or not (root / "ownership/migration" / (folder.name + ".xml")).exists(): + result["unassigned_legacy"].append(str(folder)) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--studio", type=Path, required=True, help="plugins/MusicalInstruments/studio") + parser.add_argument("--kind", choices=("migration", "estates"), default="migration") + parser.add_argument("--apply", action="store_true", help="restore checked original records; server must be stopped") + args = parser.parse_args() + print(json.dumps(inspect(args.studio.resolve(), args.kind, args.apply), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_studio_ownership.py b/tools/test_studio_ownership.py new file mode 100644 index 0000000..196ba7f --- /dev/null +++ b/tools/test_studio_ownership.py @@ -0,0 +1,83 @@ +import base64 +import importlib.util +from pathlib import Path +import tempfile +import unittest +import xml.etree.ElementTree as ET + +spec = importlib.util.spec_from_file_location("studio_ownership", Path(__file__).with_name("studio-ownership.py")) +studio = importlib.util.module_from_spec(spec) +spec.loader.exec_module(studio) + + +class RollbackTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.source = self.root / "character-projects/old/project.yml" + self.target = self.root / "character-projects/new/project.yml" + self.journal = self.root / "ownership/estates/death.xml" + self.target.parent.mkdir(parents=True) + self.target.write_bytes(b"after") + self.journal.parent.mkdir(parents=True) + fields = {"format": "1", "kind": "estate", "project.state": "moved", + "project.source": str(self.source.relative_to(self.root)), + "project.target": str(self.target.relative_to(self.root)), + "project.before": base64.b64encode(b"before").decode(), + "project.before-hash": studio.digest(b"before"), + "project.after-hash": studio.digest(b"after")} + element = ET.Element("properties") + for key, value in fields.items(): + ET.SubElement(element, "entry", key=key).text = value + ET.ElementTree(element).write(self.journal) + + def test_preview_then_apply_restore_exact_original_and_archive_receipt(self): + report = studio.inspect(self.root, "estates") + self.assertEqual(report["journals"][0]["records"][0]["result"], "safe offline rollback") + self.assertFalse(self.source.exists()) + result = studio.inspect(self.root, "estates", True) + self.assertEqual(self.source.read_bytes(), b"before") + self.assertFalse(self.target.exists()) + self.assertIn("archived", result["journals"][0]) + self.assertFalse(self.journal.exists()) + + def test_later_gameplay_edits_are_reported_and_preserved(self): + self.target.write_bytes(b"edited since inheritance") + result = studio.inspect(self.root, "estates", True) + self.assertIn("conflict", result["journals"][0]["records"][0]["result"]) + self.assertEqual(self.target.read_bytes(), b"edited since inheritance") + self.assertTrue(self.journal.exists()) + self.assertFalse(self.source.exists()) + + def test_invalid_backups_paths_and_missing_copies_are_held(self): + self.target.unlink() + report = studio.inspect(self.root, "estates", True) + self.assertIn("both copies missing", report["journals"][0]["records"][0]["result"]) + self.assertRaises(ValueError, studio.safe, self.root, "../outside") + self.journal.write_text("broken XML") + self.assertIn("error", studio.inspect(self.root, "estates", True)["journals"][0]) + + def test_new_projects_and_unassigned_legacy_data_are_listed(self): + extra = self.target.parent / "newly-created.yml" + extra.write_bytes(b"preserve") + legacy = self.root / "projects/unreviewed" + legacy.mkdir(parents=True) + result = studio.inspect(self.root, "estates") + self.assertEqual(result["unplanned_projects"], [str(extra)]) + self.assertEqual(result["unassigned_legacy"], [str(legacy)]) + self.assertTrue(extra.exists()) + + def test_held_state_filename_is_reported_without_becoming_a_transfer(self): + element = ET.parse(self.journal).getroot() + ET.SubElement(element, "entry", key="held.opaque.state").text = "retained unknown record" + ET.SubElement(element, "entry", key="held-source.opaque.state").text = base64.b64encode(b"opaque").decode() + ET.ElementTree(element).write(self.journal) + report = studio.inspect(self.root, "estates")["journals"][0] + self.assertNotIn("error", report) + self.assertEqual(len(report["records"]), 1) + self.assertEqual(report["held"]["held.opaque.state"], "retained unknown record") + + +if __name__ == "__main__": + unittest.main() From 88336a9e9c55f00cc124e5d1c870d402f659c0a1 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:01:22 +0000 Subject: [PATCH 2/7] fix: validate studio ownership replay receipts --- .../studio/StudioOwnership.java | 59 ++++-- .../studio/StudioCharacterTest.java | 3 +- .../studio/StudioOwnershipTest.java | 180 ++++++++++++------ tools/studio-ownership.py | 180 +++++++++++++++--- tools/test_studio_ownership.py | 175 +++++++++++++++-- 5 files changed, 479 insertions(+), 118 deletions(-) diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java index 89c633a..3767206 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java @@ -105,6 +105,7 @@ private void holdUnrecognized(Path source) throws IOException { List transfer(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to, BooleanSupplier guard) throws IOException { if (from.equals(to)) throw new Conflict("Estate recipient is the testator"); + long revision = validateDecision(exactDecision, death, from, to); Path file = root.resolve("ownership/estates").resolve(death + ".xml"); Properties journal = read(file); if (journal.isEmpty()) { @@ -124,8 +125,6 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro || Files.exists(path(journal.getProperty(id + ".target")))) throw new Conflict("Stale partial transfer needs staff recovery: " + id); removeEntry(journal, id); - } else if (!to.equals(owner(journal, id + ".to"))) { - throw new Conflict("The planned recipient differs from the current exact decision"); } } if (!guard.getAsBoolean()) throw new IOException("Estate decision no longer holds"); @@ -145,6 +144,7 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro journal.setProperty(id + ".from-account", from.account().toString()); journal.setProperty(id + ".from-character", from.character().toString()); journal.setProperty(id + ".decision", exactDecision); + journal.setProperty(id + ".revision", Long.toString(revision)); } write(file, journal); for (String id : ids(journal)) { @@ -158,6 +158,11 @@ List retainedReceipts(UUID death, String exactDecision, StudioIdentity.O Properties journal = read(file); if (!journal.isEmpty() && (!death.toString().equals(journal.getProperty("death")) || !from.equals(owner(journal, "from")))) throw new Conflict("Estate journal identity differs from this death"); + for (Path source : projects(store.directory(from.character()))) { + String id = source.getFileName().toString().replace(".yml", ""); + if ("moved".equals(journal.getProperty(id + ".state"))) + throw new Conflict("Transferred project reappeared at its previous owner: " + id); + } for (String id : ids(journal)) { if ("moved".equals(journal.getProperty(id + ".state"))) continue; if (!exactDecision.equals(journal.getProperty(id + ".decision"))) { @@ -170,6 +175,18 @@ List retainedReceipts(UUID death, String exactDecision, StudioIdentity.O return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList(); } + private static long validateDecision(String decision, UUID death, StudioIdentity.Owner from, StudioIdentity.Owner to) throws IOException { + var suffix = java.util.regex.Pattern.compile(", revision=(0|[1-9][0-9]*)]$").matcher(decision); + if (!decision.startsWith("EstateDecision[deathId=" + death + ", testator=" + reference(from) + ", diedAt=") + || !decision.contains(", property=Character[ref=" + reference(to) + "], guildLocks=") || !suffix.find()) + throw new Conflict("Project plan differs from its captured full decision"); + try { return Long.parseLong(suffix.group(1)); } + catch (NumberFormatException ex) { throw new Conflict("Unsupported project estate revision"); } + } + private static String reference(StudioIdentity.Owner owner) { + return "CharacterRef[ownerId=" + owner.account() + ", characterId=" + owner.character() + "]"; + } + private void plan(Properties journal, String id, Path source, Path target, byte[] bytes, StudioIdentity.Owner to) throws IOException { Path backup = root.resolve("ownership/backups").resolve(hash(bytes) + ".yml"); if (!Files.exists(backup)) DurableFiles.write(backup, bytes, false); @@ -194,9 +211,6 @@ private void apply(Path file, Properties journal, String id, BooleanSupplier gua Path source = path(journal.getProperty(id + ".source")); Path target = path(journal.getProperty(id + ".target")); if (source.equals(target)) throw new Conflict("Source and destination refer to the same project"); - StudioIdentity.Owner to = owner(journal, id + ".to"); - if (!target.equals(store.projectFile(to.character(), UUID.fromString(id)).normalize())) - throw new Conflict("Destination path differs from its planned character"); if (Files.exists(source) && !hash(Files.readAllBytes(source)).equals(journal.getProperty(id + ".before-hash"))) throw new Conflict("Project changed after ownership planning: " + source); if (!Files.exists(source) && "planned".equals(journal.getProperty(id + ".state"))) @@ -253,11 +267,7 @@ void requireEstateReady(StudioIdentity.Owner from) throws IOException { throw new Conflict("Legacy projects for this character remain held for staff"); } - private Path path(String relative) throws Conflict { - Path result = root.resolve(relative).normalize(); - if (!result.startsWith(root.normalize())) throw new Conflict("Invalid ownership journal path"); - return result; - } + private Path path(String relative) { return root.resolve(relative).normalize(); } private static StudioIdentity.Owner owner(Properties values, String prefix) throws Conflict { try { return new StudioIdentity.Owner(UUID.fromString(values.getProperty(prefix + "-account")), @@ -266,10 +276,19 @@ private static StudioIdentity.Owner owner(Properties values, String prefix) thro } static Properties read(Path file) throws IOException { - Properties result = new Properties(); + class Journal extends Properties { + boolean loading; + @Override public synchronized Object put(Object key, Object value) { + if (loading && containsKey(key)) throw new IllegalArgumentException("Duplicate ownership XML key"); + return super.put(key, value); + } + } + Journal result = new Journal(); if (Files.exists(file)) try (var input = Files.newInputStream(file)) { + result.loading = true; try { result.loadFromXML(input); } - catch (java.util.InvalidPropertiesFormatException ex) { throw new Conflict("Unreadable ownership journal"); } + catch (java.util.InvalidPropertiesFormatException | IllegalArgumentException ex) { throw new Conflict("Unreadable ownership journal"); } + finally { result.loading = false; } if (!"1".equals(result.getProperty("format"))) throw new Conflict("Unsupported ownership journal format"); String kind = result.getProperty("kind"); if (!"migration".equals(kind) && !"estate".equals(kind)) throw new Conflict("Unknown ownership journal kind"); @@ -291,6 +310,9 @@ static Properties read(Path file) throws IOException { if (result.getProperty(id + ".source") == null || result.getProperty(id + ".target") == null) throw new Conflict("Ownership publication path missing"); var before = yaml(Base64.getDecoder().decode(result.getProperty(id + ".before"))); + if (!Integer.valueOf(StudioStore.FORMAT).equals(before.get("format")) + || !Integer.valueOf(StudioStore.FORMAT).equals(after.get("format"))) + throw new Conflict("Unsupported project payload schema"); if (!id.equals(before.get("id"))) throw new Conflict("Planned source ID differs from its payload"); Path expectedSource; if ("estate".equals(kind)) { @@ -299,6 +321,8 @@ static Properties read(Path file) throws IOException { || !from.account().toString().equals(before.get("owner-account")) || !from.character().toString().equals(before.get("owner-character"))) throw new Conflict("Planned source authority differs from its payload"); + if (!Long.toString(validateDecision(result.getProperty(id + ".decision"), UUID.fromString(result.getProperty("death")), from, to)) + .equals(result.getProperty(id + ".revision"))) throw new Conflict("Project revision differs from its captured decision"); expectedSource = Path.of("character-projects", from.character().toString(), id + ".yml"); } else { UUID account = UUID.fromString(result.getProperty("from-account")); @@ -306,8 +330,15 @@ static Properties read(Path file) throws IOException { throw new Conflict("Planned legacy authority differs from its payload"); expectedSource = Path.of("projects", account.toString(), id + ".yml"); } - if (!expectedSource.equals(Path.of(result.getProperty(id + ".source")))) - throw new Conflict("Source path differs from its planned owner"); + var expected = new java.util.LinkedHashMap<>(before); + expected.put("owner-account", to.account().toString()); expected.put("owner-character", to.character().toString()); + if (!java.util.Arrays.equals(RawYaml.bytes(expected), RawYaml.bytes(after))) + throw new Conflict("Ownership plan changes project content or unrelated metadata"); + if (expectedSource.equals(Path.of("character-projects", to.character().toString(), id + ".yml"))) + throw new Conflict("Source and destination refer to the same project"); + if (!expectedSource.equals(Path.of(result.getProperty(id + ".source"))) + || !Path.of("character-projects", to.character().toString(), id + ".yml").equals(Path.of(result.getProperty(id + ".target")))) + throw new Conflict("Project path differs from its planned owner"); } } catch (IllegalArgumentException | NullPointerException ex) { throw new Conflict("Malformed ownership publication record"); } } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java index 7147250..c2c89a3 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioCharacterTest.java @@ -72,7 +72,8 @@ class StudioCharacterTest { var to = new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); for (int i = 0; i < 36; i++) store.saveOwned(to, new Project(new Song(UUID.randomUUID(), to.account(), "Heir", "Existing " + i, List.of()), null, 100, false)); for (int i = 0; i < 2; i++) store.saveOwned(from, new Project(new Song(UUID.randomUUID(), from.account(), "Author", "Inherited " + i, List.of()), null, 100, false)); - new StudioOwnership(store).transfer(UUID.randomUUID(), "decision", from, to, () -> true); + UUID death = UUID.randomUUID(); + new StudioOwnership(store).transfer(death, StudioOwnershipTest.decision(death, "one", from, to), from, to, () -> true); var plugin = mock(InstrumentPlugin.class); var server = mock(Server.class); var scheduler = mock(BukkitScheduler.class); when(plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(scheduler); when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())).thenReturn(mock(BukkitTask.class)); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java index 65892bd..a0e4856 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java @@ -40,6 +40,20 @@ TestIdentity reviewed() { return identity; } + static String decision(UUID death, String label, StudioIdentity.Owner source, StudioIdentity.Owner recipient) { + long revision = label.startsWith("one") || label.startsWith("revision1") ? 1 : label.startsWith("two") || label.startsWith("revision2") ? 2 : label.startsWith("three") ? 3 : 4; + var from = new net.tfminecraft.tlibs.character.CharacterRef(source.account(), source.character()); + var to = new net.tfminecraft.tlibs.character.CharacterRef(recipient.account(), recipient.character()); + var property = label.contains("frozen") ? net.tfminecraft.tlibs.character.estate.Recipient.FROZEN : label.contains("waiting") ? net.tfminecraft.tlibs.character.estate.Recipient.WAITING : net.tfminecraft.tlibs.character.estate.Recipient.character(to); + return new net.tfminecraft.tlibs.character.estate.EstateDecision(death, from, java.util.Optional.empty(), java.time.Instant.EPOCH, java.util.Optional.empty(), java.util.Optional.empty(), java.util.Optional.empty(), net.tfminecraft.tlibs.character.estate.Recipient.FROZEN, property, net.tfminecraft.tlibs.character.estate.Recipient.FROZEN, net.tfminecraft.tlibs.character.estate.Recipient.SENDER, revision).toString(); + } + List transfer(StudioOwnership ownership, UUID death, String label, StudioIdentity.Owner source, StudioIdentity.Owner recipient, java.util.function.BooleanSupplier guard) throws IOException { + return ownership.transfer(death, decision(death, label, source, recipient), source, recipient, guard); + } + List retained(StudioOwnership ownership, UUID death, String label, StudioIdentity.Owner source) throws IOException { + return ownership.retainedReceipts(death, decision(death, label, source, to), source); + } + @Test void migrationKeepsEditionBytesAttributionSelectionAndOneReviewedHeir() throws Exception { StudioStore store = store(); Project project = project(); Path source = legacy(project); Song edition = store.publishEdition(project.song(), 20); @@ -124,13 +138,13 @@ TestIdentity reviewed() { store.saveOwned(from, project); store.saveOwned(to, recipient); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); byte[] authorBytes = Files.readAllBytes(store.projectFile(from.character(), project.song().id())); - List moved = ownership.transfer(death, "revision1 exact decision", from, to, () -> true); + List moved = transfer(ownership,death, "revision1 exact decision", from, to, () -> true); assertEquals(List.of(project.song().id().toString()), moved); assertFalse(Files.exists(store.projectFile(from.character(), project.song().id()))); assertEquals(recipient.song().id(), store.project(to.character()).orElseThrow().song().id()); assertEquals(project, store.project(to.character(), project.song().id())); - assertEquals(moved, new StudioOwnership(store()).transfer(death, "revision1 exact decision", from, to, () -> true)); - assertEquals(moved, ownership.retainedReceipts(death, "revision2 frozen", from)); + assertEquals(moved, transfer(new StudioOwnership(store()),death, "revision1 exact decision", from, to, () -> true)); + assertEquals(moved, retained(ownership,death, "revision2 frozen", from)); assertArrayEquals(authorBytes, Files.readAllBytes(root.resolve("ownership/backups").resolve(StudioOwnership.hash(authorBytes) + ".yml"))); var journal = StudioOwnership.read(root.resolve("ownership/estates").resolve(death + ".xml")); assertEquals(to.account().toString(), journal.getProperty(project.song().id() + ".to-account")); @@ -141,14 +155,14 @@ TestIdentity reviewed() { StudioStore store = store(); Project project = project(); store.saveOwned(from, project); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger checks = new AtomicInteger(); - assertThrows(IOException.class, () -> ownership.transfer(death, "revision1", from, to, () -> checks.incrementAndGet() < 3)); + assertThrows(IOException.class, () -> transfer(ownership,death, "revision1", from, to, () -> checks.incrementAndGet() < 3)); assertTrue(Files.exists(store.projectFile(from.character(), project.song().id()))); assertTrue(Files.exists(store.projectFile(to.character(), project.song().id()))); assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireWritable(from.character())); assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireWritable(to.character())); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "revision2", from, to, () -> true)); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "revision2 frozen", from)); - assertEquals(List.of(project.song().id().toString()), ownership.transfer(death, "revision1", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "revision2", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership,death, "revision2 frozen", from)); + assertEquals(List.of(project.song().id().toString()), transfer(ownership,death, "revision1", from, to, () -> true)); ownership.requireWritable(from.character()); ownership.requireWritable(to.character()); } @@ -156,9 +170,9 @@ TestIdentity reviewed() { StudioStore store = store(); Project project = project(); store.saveOwned(from, project); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger calls = new AtomicInteger(); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> calls.incrementAndGet() == 1)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> calls.incrementAndGet() == 1)); StudioIdentity.Owner replacement = new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); - assertEquals(List.of(project.song().id().toString()), ownership.transfer(death, "two", from, replacement, () -> true)); + assertEquals(List.of(project.song().id().toString()), transfer(ownership,death, "two", from, replacement, () -> true)); assertFalse(Files.exists(store.projectFile(to.character(), project.song().id()))); assertTrue(Files.exists(store.projectFile(replacement.character(), project.song().id()))); } @@ -171,17 +185,17 @@ TestIdentity reviewed() { disk.when(() -> DurableFiles.write(eq(destination), any(byte[].class), eq(false))).thenAnswer(call -> { call.callRealMethod(); throw new IOException("crash after destination publication"); }); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> true)); } - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "two", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "two", from, to, () -> true)); Path source = store.projectFile(from.character(), project.song().id()); try (var disk = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { byte[] sourceBytes = Files.readAllBytes(source); disk.when(() -> DurableFiles.delete(source)).thenAnswer(call -> { call.callRealMethod(); throw new IOException("crash after source removal"); }); Files.write(source, sourceBytes); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> true)); } - assertEquals(List.of(project.song().id().toString()), ownership.transfer(death, "one", from, to, () -> true)); + assertEquals(List.of(project.song().id().toString()), transfer(ownership,death, "one", from, to, () -> true)); } @Test void conflictsHoldChangedSourcesRecipientIdsAndReappearingOldRecords() throws Exception { @@ -189,39 +203,39 @@ TestIdentity reviewed() { StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); Path destination = store.projectFile(to.character(), project.song().id()); Files.createDirectories(destination.getParent()); Files.writeString(destination, "conflicting project"); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); Files.delete(destination); Path source = store.projectFile(from.character(), project.song().id()); byte[] original = Files.readAllBytes(source); Files.writeString(source, "changed"); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); Files.delete(source); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); - Files.write(source, original); ownership.transfer(death, "one", from, to, () -> true); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); + Files.write(source, original); transfer(ownership,death, "one", from, to, () -> true); Files.write(source, original); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "two", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "two", from, to, () -> true)); } @Test void journalIdentityMalformedOwnerUnreadableProjectAndEscapedPathsBlock() throws Exception { StudioStore store = store(); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, from, () -> true)); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> false)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, from, () -> true)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> false)); Project project = project(); store.saveOwned(from, project); Path source = store.projectFile(from.character(), project.song().id()); YamlConfiguration yaml = YamlConfiguration.loadConfiguration(source.toFile()); yaml.set("owner-account", "unknown"); yaml.save(source.toFile()); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); store.save(project); Files.writeString(store.directory(from.character()).resolve("bad.yml"), "bad"); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); Files.delete(store.directory(from.character()).resolve("bad.yml")); yaml.set("owner-account", from.account().toString()); yaml.save(source.toFile()); AtomicInteger checks = new AtomicInteger(); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> checks.incrementAndGet() == 1)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> checks.incrementAndGet() == 1)); Path file = root.resolve("ownership/estates").resolve(death + ".xml"); var journal = StudioOwnership.read(file); journal.setProperty("from-character", "broken"); StudioOwnership.write(file, journal); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); journal.setProperty("from-character", from.character().toString()); journal.setProperty(project.song().id() + ".target", "../outside.yml"); StudioOwnership.write(file, journal); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "two", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "two", from, to, () -> true)); journal.setProperty("death", UUID.randomUUID().toString()); StudioOwnership.write(file, journal); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); journal.setProperty("format", "99"); StudioOwnership.write(file, journal); assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file)); } @@ -261,21 +275,21 @@ TestIdentity reviewed() { @Test void aFrozenRevisionCancelsOnlyAnUntouchedPlanAndPreservesPreviousReceipts() throws Exception { StudioStore store = store(); Project project = project(); store.saveOwned(from, project); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger checks = new AtomicInteger(); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> checks.incrementAndGet() == 1)); - assertEquals(List.of(), ownership.retainedReceipts(death, "one", from)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> checks.incrementAndGet() == 1)); + assertEquals(List.of(), retained(ownership,death, "one", from)); ownership.requireWritable(UUID.randomUUID()); - assertEquals(List.of(), ownership.retainedReceipts(death, "two frozen", from)); ownership.requireWritable(from.character()); + assertEquals(List.of(), retained(ownership,death, "two frozen", from)); ownership.requireWritable(from.character()); checks.set(0); - assertThrows(IOException.class, () -> ownership.transfer(death, "three", from, to, () -> checks.incrementAndGet() < 3)); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "four frozen", from)); - assertEquals(List.of(project.song().id().toString()), ownership.transfer(death, "three", from, to, () -> true)); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "three", new StudioIdentity.Owner(account, UUID.randomUUID()))); + assertThrows(IOException.class, () -> transfer(ownership,death, "three", from, to, () -> checks.incrementAndGet() < 3)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership,death, "four frozen", from)); + assertEquals(List.of(project.song().id().toString()), transfer(ownership,death, "three", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership,death, "three", new StudioIdentity.Owner(account, UUID.randomUUID()))); } @Test void corruptManifestsAndMismatchedRecipientPayloadsAreBlockedBeforePublication() throws Exception { StudioStore store = store(); Project project = project(); store.saveOwned(from, project); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger checks = new AtomicInteger(); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> checks.incrementAndGet() == 1)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> checks.incrementAndGet() == 1)); Path file = root.resolve("ownership/estates").resolve(death + ".xml"); var valid = StudioOwnership.read(file); String id = project.song().id().toString(); for (String field : List.of("kind", id + ".state", id + ".before", id + ".before-hash", id + ".after-hash", id + ".to-account", id + ".to-character")) { var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(field, "invalid"); StudioOwnership.write(file, changed); @@ -291,14 +305,14 @@ TestIdentity reviewed() { } for (String target : List.of(valid.getProperty(id + ".source"), "character-projects/" + UUID.randomUUID() + "/" + id + ".yml")) { var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(id + ".target", target); StudioOwnership.write(file, changed); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); } var changed = new java.util.Properties(); changed.putAll(valid); var after = RawYaml.read(java.util.Base64.getDecoder().decode(changed.getProperty(id + ".after"))); UUID otherAccount = UUID.randomUUID(); changed.setProperty(id + ".to-account", otherAccount.toString()); after.put("owner-account", otherAccount.toString()); byte[] bytes = RawYaml.bytes(after); changed.setProperty(id + ".after", java.util.Base64.getEncoder().encodeToString(bytes)); changed.setProperty(id + ".after-hash", StudioOwnership.hash(bytes)); StudioOwnership.write(file, changed); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); Files.writeString(file, "broken XML"); assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file)); } @@ -308,17 +322,17 @@ TestIdentity reviewed() { Path backup = root.resolve("ownership/backups").resolve(StudioOwnership.hash(original) + ".yml"); Files.createDirectories(backup.getParent()); Files.writeString(backup, "tampered backup"); StudioOwnership ownership = new StudioOwnership(store); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(UUID.randomUUID(), "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,UUID.randomUUID(), "one", from, to, () -> true)); Files.delete(backup); for (String field : List.of("id", "owner-character")) { var raw = RawYaml.read(original); raw.put(field, UUID.randomUUID().toString()); Files.write(source, RawYaml.bytes(raw)); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(UUID.randomUUID(), "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,UUID.randomUUID(), "one", from, to, () -> true)); } Files.write(source, original); UUID death = UUID.randomUUID(); AtomicInteger calls = new AtomicInteger(); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> calls.incrementAndGet() < 3)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> calls.incrementAndGet() < 3)); Files.delete(source); Files.delete(store.projectFile(to.character(), project.song().id())); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); } @Test void pendingDestinationCannotBeCancelledByAChangedFrozenDecisionEvenBeforeTheReceiptWrites() throws Exception { @@ -326,9 +340,9 @@ TestIdentity reviewed() { StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); Path target = store.projectFile(to.character(), project.song().id()); try (var disk = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { disk.when(() -> DurableFiles.write(eq(target), any(byte[].class), eq(false))).thenAnswer(call -> { call.callRealMethod(); throw new IOException("after publication"); }); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> true)); } - assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "two frozen", from)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership,death, "two frozen", from)); } @Test void strictOwnershipPublicationsRefuseAFileSystemWithoutAtomicMoves() throws Exception { @@ -366,22 +380,22 @@ TestIdentity reviewed() { @Test void validButDifferentJournalTestatorsAndUnrecognizedFilesAreHeld() throws Exception { StudioStore store = store(); Project project = project(); store.saveOwned(from, project); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger checks = new AtomicInteger(); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> checks.incrementAndGet() == 1)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> checks.incrementAndGet() == 1)); Path file = root.resolve("ownership/estates").resolve(death + ".xml"); var different = new StudioIdentity.Owner(account, UUID.randomUUID()); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", different, to, () -> true)); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "one", different)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", different, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership,death, "one", different)); var journal = StudioOwnership.read(file); journal.setProperty("death", UUID.randomUUID().toString()); StudioOwnership.write(file, journal); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "one", from)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership,death, "one", from)); journal.setProperty("death", death.toString()); journal.setProperty("from-character", UUID.randomUUID().toString()); StudioOwnership.write(file, journal); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "one", from)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership,death, "one", from)); journal.setProperty("death", UUID.randomUUID().toString()); StudioOwnership.write(file, journal); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(death, "one", from)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership,death, "one", from)); Path bad = store.directory(from.character()).resolve("0-not-a-uuid.yml"); Files.writeString(bad, "unreadable identity"); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(UUID.randomUUID(), "new", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,UUID.randomUUID(), "new", from, to, () -> true)); } @Test void wrongAccountOrCharacterAuthorityCannotEditAProjectEvenWhenItsDirectoryIsKnown() throws Exception { @@ -402,11 +416,11 @@ TestIdentity reviewed() { disk.when(() -> Files.createLink(eq(target), any(Path.class))).thenAnswer(call -> { Files.write(target, rival); return call.callRealMethod(); }); - assertThrows(java.nio.file.FileAlreadyExistsException.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(java.nio.file.FileAlreadyExistsException.class, () -> transfer(ownership,death, "one", from, to, () -> true)); } assertArrayEquals(rival, Files.readAllBytes(target)); assertTrue(Files.exists(store.projectFile(from.character(), project.song().id()))); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); try (var disk = mockStatic(Files.class, CALLS_REAL_METHODS)) { disk.when(() -> Files.createLink(any(Path.class), any(Path.class))).thenThrow(new UnsupportedOperationException("unsupported links")); assertThrows(IOException.class, () -> DurableFiles.write(root.resolve("unsupported"), rival, false)); @@ -432,14 +446,14 @@ TestIdentity reviewed() { @Test void replayRejectsInRootWrongSourcesAndFullReferenceOrPayloadAuthorityChanges() throws Exception { StudioStore store = store(); Project project = project(); store.saveOwned(from, project); var ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); var calls = new AtomicInteger(); - assertThrows(IOException.class, () -> ownership.transfer(death, "one", from, to, () -> calls.incrementAndGet() == 1)); + assertThrows(IOException.class, () -> transfer(ownership,death, "one", from, to, () -> calls.incrementAndGet() == 1)); Path file = root.resolve("ownership/estates").resolve(death + ".xml"); var original = StudioOwnership.read(file); String id = project.song().id().toString(); Path wrongSource = store.projectFile(UUID.randomUUID(), project.song().id()); Files.createDirectories(wrongSource.getParent()); byte[] sourceBytes = Files.readAllBytes(store.projectFile(from.character(), project.song().id())); Files.write(wrongSource, sourceBytes); var changed = new java.util.Properties(); changed.putAll(original); changed.setProperty(id + ".source", root.relativize(wrongSource).toString()); StudioOwnership.write(file, changed); - assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, "one", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership,death, "one", from, to, () -> true)); assertArrayEquals(sourceBytes, Files.readAllBytes(wrongSource)); for (String field : List.of(id + ".from-account", id + ".from-character")) { changed = new java.util.Properties(); changed.putAll(original); changed.setProperty(field, UUID.randomUUID().toString()); StudioOwnership.write(file, changed); @@ -461,4 +475,62 @@ TestIdentity reviewed() { } } + @Test void restoredSourceBlocksWaitingAndFrozenReceiptsAcrossRestartWithoutChangingAnyFile() throws Exception { + var store = store(); var project = project(); store.saveOwned(from, project); var ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + Path source = store.projectFile(from.character(), project.song().id()); byte[] original = Files.readAllBytes(source); + transfer(ownership, death, "one", from, to, () -> true); Path target = store.projectFile(to.character(), project.song().id()); byte[] moved = Files.readAllBytes(target); Path file = root.resolve("ownership/estates/" + death + ".xml"); byte[] receipt = Files.readAllBytes(file); + Files.write(source, original); + for (String label : List.of("two waiting", "three frozen")) { + assertThrows(StudioOwnership.Conflict.class, () -> retained(new StudioOwnership(store()), death, label, from)); + assertArrayEquals(original, Files.readAllBytes(source)); assertArrayEquals(moved, Files.readAllBytes(target)); assertArrayEquals(receipt, Files.readAllBytes(file)); + } + } + @Test void everyHistoricalReceiptBindsDeathFullReferencesRecipientAndSupportedRevision() throws Exception { + var store = store(); var project = project(); store.saveOwned(from, project); var ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + transfer(ownership, death, "one", from, to, () -> true); Path file = root.resolve("ownership/estates/" + death + ".xml"); var valid = StudioOwnership.read(file); String id = project.song().id().toString(); Path target = store.projectFile(to.character(), project.song().id()); byte[] original = Files.readAllBytes(target); + for (String field : List.of("to-account", "to-character")) { + var changed = new java.util.Properties(); changed.putAll(valid); String value = UUID.randomUUID().toString(); changed.setProperty(id + "." + field, value); + var after = RawYaml.read(java.util.Base64.getDecoder().decode(changed.getProperty(id + ".after"))); after.put(field.replace("to-", "owner-"), value); byte[] bytes = RawYaml.bytes(after); changed.setProperty(id + ".after", java.util.Base64.getEncoder().encodeToString(bytes)); changed.setProperty(id + ".after-hash", StudioOwnership.hash(bytes)); + StudioOwnership.write(file, changed); byte[] bad = Files.readAllBytes(file); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership, death, "two frozen", from)); assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership, death, "two", from, to, () -> true)); assertArrayEquals(bad, Files.readAllBytes(file)); assertArrayEquals(original, Files.readAllBytes(target)); + } + for (String suffix : List.of("-1", "01", "9223372036854775808")) { + var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(id + ".decision", changed.getProperty(id + ".decision").replace(", revision=1]", ", revision=" + suffix + "]")); StudioOwnership.write(file, changed); assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file)); + } + for (String field : List.of("death", "revision", "decision")) { + var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(field.equals("death") ? field : id + "." + field, field.equals("decision") ? "wrong" : field.equals("revision") ? "2" : UUID.randomUUID().toString()); StudioOwnership.write(file, changed); assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file)); + } + var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(id + ".decision", changed.getProperty(id + ".decision").replace(from.account().toString(), UUID.randomUUID().toString())); StudioOwnership.write(file, changed); assertThrows(StudioOwnership.Conflict.class, () -> StudioOwnership.read(file)); + StudioOwnership.write(file, valid); assertEquals(List.of(id), retained(new StudioOwnership(store()), death, "two waiting", from)); + UUID misplaced = UUID.randomUUID(); Files.copy(file, root.resolve("ownership/estates/" + misplaced + ".xml")); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership, misplaced, "two", from, to, () -> true)); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership, misplaced, "two frozen", from)); + } + @Test void hashConsistentOwnershipPlanCannotChangeTitlesTracksOrUnknownNestedNullMetadata() throws Exception { + var store = store(); var project = project(); store.saveOwned(from, project); Path source = store.projectFile(from.character(), project.song().id()); + var raw = RawYaml.read(Files.readAllBytes(source)); var future = new java.util.LinkedHashMap(); future.put("kept", null); future.put("nested", java.util.Arrays.asList(null, "value")); future.put("binary", new byte[]{0, 1, 2}); raw.put("future", future); Files.write(source, RawYaml.bytes(raw)); byte[] original = Files.readAllBytes(source); + var ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); AtomicInteger checks = new AtomicInteger(); assertThrows(IOException.class, () -> transfer(ownership, death, "one", from, to, () -> checks.incrementAndGet() == 1)); Path file = root.resolve("ownership/estates/" + death + ".xml"); var valid = StudioOwnership.read(file); String id = project.song().id().toString(); + for (String field : List.of("title", "tracks", "future")) { + var changed = new java.util.Properties(); changed.putAll(valid); var after = RawYaml.read(java.util.Base64.getDecoder().decode(changed.getProperty(id + ".after"))); after.put(field, "altered content"); byte[] bytes = RawYaml.bytes(after); changed.setProperty(id + ".after", java.util.Base64.getEncoder().encodeToString(bytes)); changed.setProperty(id + ".after-hash", StudioOwnership.hash(bytes)); StudioOwnership.write(file, changed); byte[] evidence = Files.readAllBytes(file); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership, death, "one", from, to, () -> true)); assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireWritable(from.character())); assertArrayEquals(original, Files.readAllBytes(source)); assertArrayEquals(evidence, Files.readAllBytes(file)); assertFalse(Files.exists(store.projectFile(to.character(), project.song().id()))); + } + StudioOwnership.write(file, valid); transfer(ownership, death, "one", from, to, () -> true); var moved = RawYaml.read(Files.readAllBytes(store.projectFile(to.character(), project.song().id()))); var movedFuture = (Map) moved.get("future"); assertTrue(movedFuture.containsKey("kept")); assertNull(movedFuture.get("kept")); assertEquals(future.get("nested"), movedFuture.get("nested")); assertArrayEquals((byte[]) future.get("binary"), (byte[]) movedFuture.get("binary")); assertEquals(raw.get("title"), moved.get("title")); assertEquals(raw.get("tracks"), moved.get("tracks")); + } + @Test void duplicateXmlUnsupportedPayloadSchemasAndHistoricalTargetPathsBlockBeforeAnyMutation() throws Exception { + var store = store(); var project = project(); store.saveOwned(from, project); var ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); transfer(ownership, death, "one", from, to, () -> true); + Path file = root.resolve("ownership/estates/" + death + ".xml"); var valid = StudioOwnership.read(file); String id = project.song().id().toString(); Path target = store.projectFile(to.character(), project.song().id()); byte[] original = Files.readAllBytes(target); + String xml = Files.readString(file); Files.writeString(file, xml.replace("", "1")); byte[] duplicate = Files.readAllBytes(file); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership, death, "two frozen", from)); assertArrayEquals(duplicate, Files.readAllBytes(file)); + for (String part : List.of("before", "after")) for (Object format : List.of(1, "2", 2.0, true)) { + var changed = new java.util.Properties(); changed.putAll(valid); var raw = RawYaml.read(java.util.Base64.getDecoder().decode(changed.getProperty(id + "." + part))); raw.put("format", format); byte[] bytes = RawYaml.bytes(raw); changed.setProperty(id + "." + part, java.util.Base64.getEncoder().encodeToString(bytes)); changed.setProperty(id + "." + part + "-hash", StudioOwnership.hash(bytes)); StudioOwnership.write(file, changed); byte[] evidence = Files.readAllBytes(file); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership, death, "two waiting", from)); assertArrayEquals(evidence, Files.readAllBytes(file)); assertArrayEquals(original, Files.readAllBytes(target)); + } + var changed = new java.util.Properties(); changed.putAll(valid); changed.setProperty(id + ".target", "character-projects/" + UUID.randomUUID() + "/" + id + ".yml"); StudioOwnership.write(file, changed); byte[] evidence = Files.readAllBytes(file); + assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership, death, "two frozen", from)); assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireWritable(to.character())); assertArrayEquals(evidence, Files.readAllBytes(file)); assertArrayEquals(original, Files.readAllBytes(target)); + } + @Test void anotherAccountCannotAddressTheSameCharacterFolderAsSourceAndRecipient() throws Exception { + var store = store(); var project = project(); store.saveOwned(from, project); var ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); var wrong = new StudioIdentity.Owner(UUID.randomUUID(), from.character()); Path source = store.projectFile(from.character(), project.song().id()); byte[] original = Files.readAllBytes(source); + assertThrows(StudioOwnership.Conflict.class, () -> transfer(ownership, death, "one", from, wrong, () -> true)); assertArrayEquals(original, Files.readAllBytes(source)); + Path file = root.resolve("ownership/estates/" + death + ".xml"); byte[] evidence = Files.readAllBytes(file); assertThrows(StudioOwnership.Conflict.class, () -> retained(ownership, death, "two frozen", from)); assertArrayEquals(evidence, Files.readAllBytes(file)); assertArrayEquals(original, Files.readAllBytes(source)); + } } diff --git a/tools/studio-ownership.py b/tools/studio-ownership.py index f0dc648..18dfe38 100755 --- a/tools/studio-ownership.py +++ b/tools/studio-ownership.py @@ -6,9 +6,43 @@ import json import os from pathlib import Path +import re import tempfile +import uuid import xml.etree.ElementTree as ET +import yaml + + +class ProjectLoader(yaml.SafeLoader): + def construct_mapping(self, node, deep=False): + self.flatten_mapping(node) + result = {} + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) + if key in result: + raise ValueError("duplicate YAML field") + result[key] = self.construct_object(value_node, deep=deep) + return result + + +def project(data): + value = yaml.load(data, Loader=ProjectLoader) + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value) or type(value.get("format")) is not int or value["format"] != 2: + raise ValueError("unsupported project schema") + identity(value["id"]) + return value + + +def identity(value): + if str(uuid.UUID(value)) != value: + raise ValueError("invalid ownership identity") + return value + + +def reference(account, character): + return f"CharacterRef[ownerId={account}, characterId={character}]" + def digest(data): return hashlib.sha256(data).hexdigest() @@ -50,54 +84,141 @@ def force_ancestors(folder): force(parent) -def inspect(root, kind, apply=False): +def journal_fields(file): + root = ET.parse(file).getroot() + if root.tag != "properties": + raise ValueError("unsupported XML envelope") + result = {} + for entry in root.findall("entry"): + key = entry.attrib["key"] + if key in result: + raise ValueError("duplicate XML field") + result[key] = entry.text or "" + return result + + +def captured_decision(fields, key): + decision = fields[key + ".decision"] + prefix = f"EstateDecision[deathId={fields['death']}, testator={reference(fields[key + '.from-account'], fields[key + '.from-character'])}, diedAt=" + recipient = f", property=Character[ref={reference(fields[key + '.to-account'], fields[key + '.to-character'])}], guildLocks=" + suffix = re.search(r", revision=(0|[1-9][0-9]*)]$", decision) + if not decision.startswith(prefix) or recipient not in decision or suffix is None: + raise ValueError("project plan differs from captured full decision") + revision = suffix.group(1) + if fields[key + ".revision"] != revision or int(revision) > 9223372036854775807: + raise ValueError("unsupported captured estate revision") + + +def same_content(left, right): + if type(left) is not type(right): + return False + if isinstance(left, dict): + return left.keys() == right.keys() and all( + any(type(other) is type(key) and other == key for other in right) + and same_content(value, right[key]) for key, value in left.items()) + if isinstance(left, list): + return len(left) == len(right) and all(same_content(a, b) for a, b in zip(left, right)) + return left == right + + +def planned(fields, key, root, kind): + identity(key) + if fields[key + ".state"] not in ("planned", "published", "moved"): + raise ValueError("unsupported project publication state") + before = base64.b64decode(fields[key + ".before"], validate=True) + after = base64.b64decode(fields[key + ".after"], validate=True) + if digest(before) != fields[key + ".before-hash"] or digest(after) != fields[key + ".after-hash"]: + raise ValueError("project payload hash mismatch") + original, output = project(before), project(after) + account = identity(fields[key + ".to-account"]) + character = identity(fields[key + ".to-character"]) + if original["id"] != key or output["id"] != key or output["owner-account"] != account or output["owner-character"] != character: + raise ValueError("project payload full authority differs") + source, target = safe(root, fields[key + ".source"]), safe(root, fields[key + ".target"]) + if kind == "estates": + from_account = identity(fields["from-account"]) + from_character = identity(fields["from-character"]) + if fields[key + ".from-account"] != from_account or fields[key + ".from-character"] != from_character or original["owner-account"] != from_account or original["owner-character"] != from_character: + raise ValueError("source full authority differs") + captured_decision(fields, key) + expected_source = root / "character-projects" / from_character / (key + ".yml") + else: + from_account = identity(fields["from-account"]) + if original["owner"] != from_account or fields["to-account"] != account or fields["to-character"] != character or account != from_account: + raise ValueError("legacy fixed recipient/source authority differs") + expected_source = root / "projects" / from_account / (key + ".yml") + if source != expected_source.resolve() or target != (root / "character-projects" / character / (key + ".yml")).resolve() or source == target: + raise ValueError("project path differs from fixed full references") + expected = dict(original, **{"owner-account": account, "owner-character": character}) + if not same_content(expected, output): + raise ValueError("ownership plan changes project content or unrelated metadata") + return source, target, before + + +def inspect(root, kind, apply=False, backup=None): + root = Path(root).resolve() result = {"journals": [], "unplanned_projects": [], "unassigned_legacy": []} + recovery = None + if apply: + if backup is None: + raise ValueError("--backup requires a new recovery directory") + recovery = Path(backup).resolve() + recovery.mkdir() + force_ancestors(recovery) expected = set() files = list((root / "ownership" / kind).glob("*.xml")) for journal in sorted(files, key=lambda path: path.stat().st_mtime_ns, reverse=True): report = {"journal": str(journal), "records": [], "held": {}} try: - fields = {entry.attrib["key"]: entry.text or "" for entry in ET.parse(journal).getroot().findall("entry")} - if fields.get("format") != "1": - raise ValueError("unsupported journal format") + fields = journal_fields(journal) + if fields.get("format") != "1" or fields.get("kind") != ("estate" if kind == "estates" else "migration"): + raise ValueError("unsupported journal format/kind") + if kind == "estates": + if identity(fields["death"]) != journal.stem: + raise ValueError("estate death differs from journal path") + identity(fields["from-account"]); identity(fields["from-character"]) report["held"] = {key: value for key, value in fields.items() if key.startswith("held.")} - ids = sorted(key[:-6] for key in fields if key.endswith(".state") - and not key.startswith(("held.", "held-source."))) - for identity in ids: - source = safe(root, fields[identity + ".source"]) - target = safe(root, fields[identity + ".target"]) + ids = sorted(key[:-6] for key in fields if key.endswith(".state") and not key.startswith(("held.", "held-source."))) + if kind == "migration" and ids and identity(fields["from-account"]) != journal.stem: + raise ValueError("legacy source account differs from journal path") + actions = [] + for key in ids: + source, target, before = planned(fields, key, root, kind) expected.add(target) - before = base64.b64decode(fields[identity + ".before"], validate=True) - if digest(before) != fields[identity + ".before-hash"]: - raise ValueError("source backup hash mismatch") - row = {"id": identity, "state": fields[identity + ".state"]} - source_ok = not source.exists() or digest(source.read_bytes()) == fields[identity + ".before-hash"] - target_ok = not target.exists() or digest(target.read_bytes()) == fields[identity + ".after-hash"] + row = {"id": key, "state": fields[key + ".state"]} + source_ok = not source.exists() or digest(source.read_bytes()) == fields[key + ".before-hash"] + target_ok = not target.exists() or digest(target.read_bytes()) == fields[key + ".after-hash"] if not source_ok or not target_ok: row["result"] = "conflict; preserve current files for staff" elif not target.exists() and not source.exists(): row["result"] = "conflict; both copies missing" else: row["result"] = "safe offline rollback" - if apply: - if not source.exists(): - durable(source, before) - if target.exists(): - target.unlink() - force(target.parent) - row["result"] = "restored" + actions.append((source, target, before)) report["records"].append(row) - # Keep receipt available while any conflicting publication needs staff recovery. - if apply and all(row["result"] == "restored" for row in report["records"]): + if apply and all(row["result"] == "safe offline rollback" for row in report["records"]): archive = root / "ownership" / "rolled-back" / kind / journal.name if archive.exists(): raise ValueError("rollback archive already exists") - archive.parent.mkdir(parents=True, exist_ok=True) - os.rename(journal, archive) - force_ancestors(archive.parent) + durable(recovery / journal.relative_to(root), journal.read_bytes()) + # Preserve every current copy before the first restore/removal in this journal. + for source, target, _ in actions: + for path in (source, target): + if path.exists(): + durable(recovery / path.relative_to(root), path.read_bytes()) + for source, target, before in actions: + if not source.exists(): + durable(source, before) + if target.exists(): + target.unlink() + force(target.parent) + for row in report["records"]: + row["result"] = "restored" + durable(archive, journal.read_bytes()) + journal.unlink() force(journal.parent) report["archived"] = str(archive) - except (OSError, ValueError, KeyError, ET.ParseError) as error: + except (OSError, ValueError, KeyError, TypeError, AttributeError, ET.ParseError, yaml.YAMLError) as error: report["error"] = str(error) result["journals"].append(report) for path in sorted((root / "character-projects").glob("*/*.yml")): @@ -114,8 +235,9 @@ def main(): parser.add_argument("--studio", type=Path, required=True, help="plugins/MusicalInstruments/studio") parser.add_argument("--kind", choices=("migration", "estates"), default="migration") parser.add_argument("--apply", action="store_true", help="restore checked original records; server must be stopped") + parser.add_argument("--backup", type=Path, help="new current-state recovery directory; required with --apply") args = parser.parse_args() - print(json.dumps(inspect(args.studio.resolve(), args.kind, args.apply), indent=2)) + print(json.dumps(inspect(args.studio, args.kind, args.apply, args.backup), indent=2)) if __name__ == "__main__": diff --git a/tools/test_studio_ownership.py b/tools/test_studio_ownership.py index 196ba7f..55f2be7 100644 --- a/tools/test_studio_ownership.py +++ b/tools/test_studio_ownership.py @@ -1,9 +1,13 @@ import base64 +import copy import importlib.util from pathlib import Path import tempfile import unittest +from unittest.mock import patch +import uuid import xml.etree.ElementTree as ET +import yaml spec = importlib.util.spec_from_file_location("studio_ownership", Path(__file__).with_name("studio-ownership.py")) studio = importlib.util.module_from_spec(spec) @@ -15,36 +19,59 @@ def setUp(self): self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) self.root = Path(self.directory.name) - self.source = self.root / "character-projects/old/project.yml" - self.target = self.root / "character-projects/new/project.yml" - self.journal = self.root / "ownership/estates/death.xml" + self.key, self.account, self.old, self.recipient, self.new, self.death = [str(uuid.uuid4()) for _ in range(6)] + self.source = self.root / "character-projects" / self.old / (self.key + ".yml") + self.target = self.root / "character-projects" / self.new / (self.key + ".yml") + self.journal = self.root / "ownership/estates" / (self.death + ".xml") + self.recovery = self.root / "new-recovery" + self.original = {"format": 2, "id": self.key, "owner": self.account, "owner-account": self.account, + "owner-character": self.old, "title": "Preserve title", "author": "Original author", + "tracks": [{"notes": [], "extension": None}], "future": {"nested": [None, 1], "binary": bytes([0, 1, 2])}} + self.output = dict(self.original, **{"owner-account": self.recipient, "owner-character": self.new}) + self.before, self.after = [yaml.safe_dump(row).encode() for row in (self.original, self.output)] self.target.parent.mkdir(parents=True) - self.target.write_bytes(b"after") + self.target.write_bytes(self.after) self.journal.parent.mkdir(parents=True) - fields = {"format": "1", "kind": "estate", "project.state": "moved", - "project.source": str(self.source.relative_to(self.root)), - "project.target": str(self.target.relative_to(self.root)), - "project.before": base64.b64encode(b"before").decode(), - "project.before-hash": studio.digest(b"before"), - "project.after-hash": studio.digest(b"after")} + decision = f"EstateDecision[deathId={self.death}, testator={studio.reference(self.account, self.old)}, diedAt=Optional.empty, property=Character[ref={studio.reference(self.recipient, self.new)}], guildLocks=Frozen[], revision=1]" + self.fields = {"format": "1", "kind": "estate", "death": self.death, "from-account": self.account, + "from-character": self.old, self.key + ".state": "moved", self.key + ".decision": decision, + self.key + ".revision": "1", self.key + ".from-account": self.account, + self.key + ".from-character": self.old, self.key + ".to-account": self.recipient, + self.key + ".to-character": self.new, self.key + ".source": str(self.source.relative_to(self.root)), + self.key + ".target": str(self.target.relative_to(self.root)), + self.key + ".before": base64.b64encode(self.before).decode(), + self.key + ".after": base64.b64encode(self.after).decode(), + self.key + ".before-hash": studio.digest(self.before), self.key + ".after-hash": studio.digest(self.after)} + self.write(self.fields) + + def write(self, fields): element = ET.Element("properties") for key, value in fields.items(): ET.SubElement(element, "entry", key=key).text = value ET.ElementTree(element).write(self.journal) - def test_preview_then_apply_restore_exact_original_and_archive_receipt(self): + def apply(self, kind="estates"): + return studio.inspect(self.root, kind, True, self.recovery) + + def payload(self, fields, part, row): + data = yaml.safe_dump(row).encode() + fields[self.key + "." + part] = base64.b64encode(data).decode() + fields[self.key + "." + part + "-hash"] = studio.digest(data) + + def test_preview_then_apply_restore_exact_original_preserve_current_and_archive_receipt(self): report = studio.inspect(self.root, "estates") self.assertEqual(report["journals"][0]["records"][0]["result"], "safe offline rollback") self.assertFalse(self.source.exists()) - result = studio.inspect(self.root, "estates", True) - self.assertEqual(self.source.read_bytes(), b"before") + result = self.apply() + self.assertEqual(self.source.read_bytes(), self.before) self.assertFalse(self.target.exists()) + self.assertEqual(self.after, (self.recovery / self.target.relative_to(self.root)).read_bytes()) self.assertIn("archived", result["journals"][0]) self.assertFalse(self.journal.exists()) def test_later_gameplay_edits_are_reported_and_preserved(self): self.target.write_bytes(b"edited since inheritance") - result = studio.inspect(self.root, "estates", True) + result = self.apply() self.assertIn("conflict", result["journals"][0]["records"][0]["result"]) self.assertEqual(self.target.read_bytes(), b"edited since inheritance") self.assertTrue(self.journal.exists()) @@ -52,11 +79,13 @@ def test_later_gameplay_edits_are_reported_and_preserved(self): def test_invalid_backups_paths_and_missing_copies_are_held(self): self.target.unlink() - report = studio.inspect(self.root, "estates", True) + report = self.apply() self.assertIn("both copies missing", report["journals"][0]["records"][0]["result"]) self.assertRaises(ValueError, studio.safe, self.root, "../outside") self.journal.write_text("broken XML") - self.assertIn("error", studio.inspect(self.root, "estates", True)["journals"][0]) + self.assertIn("error", studio.inspect(self.root, "estates")["journals"][0]) + self.assertRaises(ValueError, studio.inspect, self.root, "estates", True) + self.assertRaises(FileExistsError, studio.inspect, self.root, "estates", True, self.recovery) def test_new_projects_and_unassigned_legacy_data_are_listed(self): extra = self.target.parent / "newly-created.yml" @@ -69,15 +98,121 @@ def test_new_projects_and_unassigned_legacy_data_are_listed(self): self.assertTrue(extra.exists()) def test_held_state_filename_is_reported_without_becoming_a_transfer(self): - element = ET.parse(self.journal).getroot() - ET.SubElement(element, "entry", key="held.opaque.state").text = "retained unknown record" - ET.SubElement(element, "entry", key="held-source.opaque.state").text = base64.b64encode(b"opaque").decode() - ET.ElementTree(element).write(self.journal) + fields = dict(self.fields, **{"held.opaque.state": "retained unknown record", "held-source.opaque.state": base64.b64encode(b"opaque").decode()}) + self.write(fields) report = studio.inspect(self.root, "estates")["journals"][0] self.assertNotIn("error", report) self.assertEqual(len(report["records"]), 1) self.assertEqual(report["held"]["held.opaque.state"], "retained unknown record") + def test_hash_consistent_title_tracks_unknown_null_and_recipient_changes_do_not_restore(self): + for field in ("title", "tracks", "future", "owner-account", "owner-character"): + fields = dict(self.fields) + output = copy.deepcopy(self.output) + output[field] = str(uuid.uuid4()) if field.startswith("owner-") else "changed content" + if field.startswith("owner-"): + fields[self.key + "." + field.replace("owner-", "to-")] = output[field] + self.payload(fields, "after", output) + self.write(fields) + result = studio.inspect(self.root, "estates") + self.assertIn("error", result["journals"][0], field) + self.assertFalse(self.source.exists()) + self.assertEqual(self.after, self.target.read_bytes()) + + def test_paths_full_refs_schemas_and_decisions_are_checked_before_any_restore(self): + for field, value in [("source", "../outside"), ("source", f"character-projects/{self.new}/{self.key}.yml"), + ("target", str(self.source.relative_to(self.root))), ("before-hash", "wrong"), + ("decision", "wrong"), ("revision", "2"), ("state", "unknown"), ("from-account", str(uuid.uuid4()))]: + fields = dict(self.fields) + fields[self.key + "." + field] = value + self.write(fields) + self.assertIn("error", studio.inspect(self.root, "estates")["journals"][0], field) + self.assertFalse(self.source.exists()) + self.assertEqual(self.after, self.target.read_bytes()) + for format_value in (1, "2", 2.0, True): + fields = dict(self.fields) + self.payload(fields, "after", dict(self.output, format=format_value)) + self.write(fields) + self.assertIn("schema", studio.inspect(self.root, "estates")["journals"][0]["error"]) + self.assertFalse(self.source.exists()) + self.assertEqual(self.after, self.target.read_bytes()) + + def test_invalid_later_entry_never_restores_an_earlier_valid_record(self): + later = str(uuid.UUID(int=(1 << 128) - 1)) + if self.key == later: + self.fail("unexpected fixture UUID") + fields = dict(self.fields, **{later + ".state": "moved"}) + self.write(fields) + before = self.journal.read_bytes() + result = self.apply() + self.assertIn("error", result["journals"][0]) + self.assertFalse(self.source.exists()) + self.assertEqual(self.after, self.target.read_bytes()) + self.assertEqual(before, self.journal.read_bytes()) + + def test_duplicate_xml_and_nested_yaml_fields_are_rejected_without_mutation(self): + element = ET.parse(self.journal).getroot() + ET.SubElement(element, "entry", key="format").text = "1" + ET.ElementTree(element).write(self.journal) + self.assertIn("duplicate XML", studio.inspect(self.root, "estates")["journals"][0]["error"]) + fields = dict(self.fields) + after = self.after + b"future: {nested: null, nested: 1}\n" + fields[self.key + ".after"] = base64.b64encode(after).decode() + fields[self.key + ".after-hash"] = studio.digest(after) + self.write(fields) + self.assertIn("duplicate YAML", studio.inspect(self.root, "estates")["journals"][0]["error"]) + self.assertFalse(self.source.exists()) + self.assertEqual(self.after, self.target.read_bytes()) + + def test_recovery_write_failure_preserves_every_original_copy(self): + original = studio.durable + def failure(path, data): + if path == self.recovery / self.target.relative_to(self.root): + raise OSError("recovery disk full") + original(path, data) + with patch.object(studio, "durable", side_effect=failure): + result = self.apply() + self.assertIn("error", result["journals"][0]) + self.assertFalse(self.source.exists()) + self.assertEqual(self.after, self.target.read_bytes()) + self.assertTrue(self.journal.exists()) + + def test_legacy_mapping_round_trip_preserves_raw_nulls_and_fixed_authority(self): + self.journal.unlink() + self.target.unlink() + self.source = self.root / "projects" / self.account / (self.key + ".yml") + self.source.parent.mkdir(parents=True) + original = dict(self.original) + del original["owner-account"], original["owner-character"] + before = yaml.safe_dump(original).encode() + self.source.write_bytes(before) + output = dict(original, **{"owner-account": self.account, "owner-character": self.new}) + after = yaml.safe_dump(output).encode() + self.target.write_bytes(after) + self.journal = self.root / "ownership/migration" / (self.account + ".xml") + self.journal.parent.mkdir() + fields = dict(self.fields, kind="migration", **{"from-account": self.account, "to-account": self.account, "to-character": self.new, self.key + ".to-account": self.account, self.key + ".source": str(self.source.relative_to(self.root))}) + self.payload(fields, "before", original) + self.payload(fields, "after", output) + self.write(fields) + self.assertEqual("safe offline rollback", studio.inspect(self.root, "migration")["journals"][0]["records"][0]["result"]) + result = self.apply("migration") + self.assertIn("archived", result["journals"][0]) + self.assertEqual(before, self.source.read_bytes()) + self.assertEqual(after, (self.recovery / self.target.relative_to(self.root)).read_bytes()) + self.assertFalse(self.target.exists()) + def test_unknown_scalar_types_cannot_change_through_python_numeric_equality(self): + for value in (True, 1.0): + fields = dict(self.fields) + output = copy.deepcopy(self.output) + output["future"]["nested"][1] = value + self.payload(fields, "after", output) + self.write(fields) + self.assertIn("changes project content", studio.inspect(self.root, "estates")["journals"][0]["error"]) + self.assertFalse(self.source.exists()) + self.assertEqual(self.after, self.target.read_bytes()) + + if __name__ == "__main__": unittest.main() From d90e5aac2d9424a328b0e559b11edc84aa9a6aed Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:29:44 +0000 Subject: [PATCH 3/7] fix: pin compatibility dependency through version property --- .github/compatibility/tlibs-2.3.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/compatibility/tlibs-2.3.xml b/.github/compatibility/tlibs-2.3.xml index 2851e42..47e0161 100644 --- a/.github/compatibility/tlibs-2.3.xml +++ b/.github/compatibility/tlibs-2.3.xml @@ -9,12 +9,13 @@ 21 UTF-8 + 2.3.0 me.plugins tlibs - 2.3.0 + ${tlibs.version} provided From 05b7cd2819c71921c1e92c2066dcf8ad90f3efb7 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:44:07 +0000 Subject: [PATCH 4/7] fix(ci): record pinned dependencies in release metadata --- .github/workflows/maven-release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 00e5ad6..1a96dff 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -88,7 +88,8 @@ jobs: run: | python3 .github/scripts/plugin-artifact.py \ --jar "$ARTIFACT_PATH" --version "${TAG#v}" \ - --stage "$RUNNER_TEMP/plugin-release" + --stage "$RUNNER_TEMP/plugin-release" \ + --dependencies .build/plugin-dependencies.json - uses: actions/upload-artifact@v7 with: From eb3197c6324f7f123b2f3aa8a4ccd3605f9aa9ff Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:12:22 +0000 Subject: [PATCH 5/7] fix: preserve unchanged studio estate journals --- .../studio/StudioOwnership.java | 11 ++-- .../studio/StudioOwnershipTest.java | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java index 3767206..a0c2317 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java @@ -108,6 +108,7 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro long revision = validateDecision(exactDecision, death, from, to); Path file = root.resolve("ownership/estates").resolve(death + ".xml"); Properties journal = read(file); + boolean changed = journal.isEmpty(); if (journal.isEmpty()) { journal.setProperty("format", "1"); journal.setProperty("kind", "estate"); @@ -124,7 +125,7 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro if (!"planned".equals(journal.getProperty(id + ".state")) || Files.exists(path(journal.getProperty(id + ".target")))) throw new Conflict("Stale partial transfer needs staff recovery: " + id); - removeEntry(journal, id); + removeEntry(journal, id); changed = true; } } if (!guard.getAsBoolean()) throw new IOException("Estate decision no longer holds"); @@ -145,8 +146,9 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro journal.setProperty(id + ".from-character", from.character().toString()); journal.setProperty(id + ".decision", exactDecision); journal.setProperty(id + ".revision", Long.toString(revision)); + changed = true; } - write(file, journal); + if (changed) write(file, journal); for (String id : ids(journal)) { if (!"moved".equals(journal.getProperty(id + ".state"))) apply(file, journal, id, guard, true); } @@ -156,6 +158,7 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro List retainedReceipts(UUID death, String exactDecision, StudioIdentity.Owner from) throws IOException { Path file = root.resolve("ownership/estates").resolve(death + ".xml"); Properties journal = read(file); + boolean changed = false; if (!journal.isEmpty() && (!death.toString().equals(journal.getProperty("death")) || !from.equals(owner(journal, "from")))) throw new Conflict("Estate journal identity differs from this death"); for (Path source : projects(store.directory(from.character()))) { @@ -168,10 +171,10 @@ List retainedReceipts(UUID death, String exactDecision, StudioIdentity.O if (!exactDecision.equals(journal.getProperty(id + ".decision"))) { if (!"planned".equals(journal.getProperty(id + ".state")) || Files.exists(path(journal.getProperty(id + ".target")))) throw new Conflict("Stale partial transfer needs staff recovery: " + id); - removeEntry(journal, id); + removeEntry(journal, id); changed = true; } } - if (!journal.isEmpty()) write(file, journal); + if (changed) write(file, journal); return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList(); } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java index a0e4856..1e00052 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipTest.java @@ -151,6 +151,58 @@ List retained(StudioOwnership ownership, UUID death, String label, Studi assertEquals(from.character().toString(), journal.getProperty(project.song().id() + ".from-character")); } + @Test void completedEstateReconciliationPreservesJournalBytesAndChainOrder() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + StudioIdentity.Owner last = new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + List moved = transfer(ownership, first, "one", from, to, () -> true); + Path firstJournal = root.resolve("ownership/estates/" + first + ".xml"); + var firstTime = java.nio.file.attribute.FileTime.fromMillis(1_000_000); + Files.setLastModifiedTime(firstJournal, firstTime); + byte[] firstBytes = Files.readAllBytes(firstJournal); + assertEquals(moved, transfer(ownership, second, "one", to, last, () -> true)); + Path secondJournal = root.resolve("ownership/estates/" + second + ".xml"); + var secondTime = java.nio.file.attribute.FileTime.fromMillis(2_000_000); + Files.setLastModifiedTime(secondJournal, secondTime); + byte[] secondBytes = Files.readAllBytes(secondJournal); + byte[] projectBytes = Files.readAllBytes(store.projectFile(last.character(), project.song().id())); + + assertEquals(moved, transfer(new StudioOwnership(store()), first, "one", from, to, () -> true)); + assertArrayEquals(firstBytes, Files.readAllBytes(firstJournal)); + assertEquals(firstTime, Files.getLastModifiedTime(firstJournal)); + assertEquals(moved, retained(ownership, first, "two frozen", from)); + assertArrayEquals(firstBytes, Files.readAllBytes(firstJournal)); + assertEquals(firstTime, Files.getLastModifiedTime(firstJournal)); + assertArrayEquals(secondBytes, Files.readAllBytes(secondJournal)); + assertEquals(secondTime, Files.getLastModifiedTime(secondJournal)); + assertTrue(Files.getLastModifiedTime(secondJournal).compareTo(Files.getLastModifiedTime(firstJournal)) > 0); + assertArrayEquals(projectBytes, Files.readAllBytes(store.projectFile(last.character(), project.song().id()))); + assertFalse(Files.exists(store.projectFile(from.character(), project.song().id()))); + assertFalse(Files.exists(store.projectFile(to.character(), project.song().id()))); + } + + @Test void retainedPlanWritesOnlyWhenSupersessionRemovesAnEntry() throws Exception { + StudioStore store = store(); Project project = project(); store.saveOwned(from, project); + StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); + AtomicInteger checks = new AtomicInteger(); + assertThrows(IOException.class, () -> transfer(ownership, death, "one", from, to, () -> checks.incrementAndGet() == 1)); + Path file = root.resolve("ownership/estates/" + death + ".xml"); + var unchanged = java.nio.file.attribute.FileTime.fromMillis(1_000_000); + Files.setLastModifiedTime(file, unchanged); byte[] before = Files.readAllBytes(file); + assertTrue(retained(ownership, death, "one", from).isEmpty()); + assertArrayEquals(before, Files.readAllBytes(file)); + assertEquals(unchanged, Files.getLastModifiedTime(file)); + assertTrue(retained(ownership, death, "two frozen", from).isEmpty()); + assertFalse(java.util.Arrays.equals(before, Files.readAllBytes(file))); + assertFalse(StudioOwnership.read(file).containsKey(project.song().id() + ".state")); + byte[] after = Files.readAllBytes(file); Files.setLastModifiedTime(file, unchanged); + assertTrue(retained(ownership, death, "two frozen", from).isEmpty()); + assertArrayEquals(after, Files.readAllBytes(file)); + assertEquals(unchanged, Files.getLastModifiedTime(file)); + assertTrue(Files.exists(store.projectFile(from.character(), project.song().id()))); + assertFalse(Files.exists(store.projectFile(to.character(), project.song().id()))); + } + @Test void decisionGuardsPrecedeEveryPropertyPublicationAndSourceRemoval() throws Exception { StudioStore store = store(); Project project = project(); store.saveOwned(from, project); StudioOwnership ownership = new StudioOwnership(store); UUID death = UUID.randomUUID(); From 871574fb069dbc7af4117bfa2a79bdbe911226d9 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:55:32 +0000 Subject: [PATCH 6/7] fix(studio): preserve takes and reserve unfinished transfers --- .../studio/StudioOwnership.java | 191 +++++++---- .../studio/StudioService.java | 21 +- .../studio/StudioOwnershipReviewTest.java | 309 ++++++++++++++++++ tools/test_studio_ownership.py | 42 +++ 4 files changed, 488 insertions(+), 75 deletions(-) create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipReviewTest.java diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java index a0c2317..de5b07d 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java @@ -25,8 +25,7 @@ static final class Conflict extends IOException { private final StudioStore store; private final Path root; - private record Stamp(long size, java.nio.file.attribute.FileTime modified, Object key) {} - private record Locks(Stamp stamp, java.util.Set characters) {} + private record Locks(String digest, java.util.Set characters, java.util.Set projects) {} private final java.util.Map lockCache = new java.util.HashMap<>(); StudioOwnership(StudioStore store) { this.store = store; this.root = store.root(); } @@ -34,56 +33,63 @@ void migrate(StudioIdentity identity) throws IOException { if (!identity.heirsReady()) return; Path legacy = root.resolve("projects"); if (!Files.isDirectory(legacy)) return; + IOException failure = null; for (Path folder : files(legacy)) { - if (!Files.isDirectory(folder)) { holdUnrecognized(folder); continue; } - UUID account; - try { account = UUID.fromString(folder.getFileName().toString()); } - catch (IllegalArgumentException ex) { holdUnrecognized(folder); continue; } - Path journalFile = root.resolve("ownership/migration").resolve(account + ".xml"); - Properties journal = read(journalFile); - StudioIdentity.Owner to; - if (journal.isEmpty()) { - to = identity.heir(account).orElse(null); - if (to == null) continue; - journal.setProperty("format", "1"); - journal.setProperty("kind", "migration"); - journal.setProperty("from-account", account.toString()); - journal.setProperty("to-account", to.account().toString()); - journal.setProperty("to-character", to.character().toString()); - for (Path source : projects(folder)) { - String id = source.getFileName().toString().replace(".yml", ""); - byte[] bytes = Files.readAllBytes(source); - try { - UUID.fromString(id); - var yaml = yaml(bytes); - if (!Integer.valueOf(StudioStore.FORMAT).equals(yaml.get("format")) || !account.toString().equals(yaml.get("owner")) - || !id.equals(yaml.get("id"))) throw new Conflict("Legacy project identity mismatch"); - plan(journal, id, source, store.projectFile(to.character(), UUID.fromString(id)), bytes, to); - } catch (IOException | IllegalArgumentException ex) { - journal.setProperty("held." + id, ex.getMessage()); - journal.setProperty("held-source." + id, Base64.getEncoder().encodeToString(bytes)); - } + try { migrate(identity, folder); } + catch (IOException ex) { if (failure == null) failure = ex; else failure.addSuppressed(ex); } + } + if (failure != null) throw failure; + } + + private void migrate(StudioIdentity identity, Path folder) throws IOException { + if (!Files.isDirectory(folder)) { holdUnrecognized(folder); return; } + UUID account; + try { account = UUID.fromString(folder.getFileName().toString()); } + catch (IllegalArgumentException ex) { holdUnrecognized(folder); return; } + Path journalFile = root.resolve("ownership/migration").resolve(account + ".xml"); + Properties journal = read(journalFile); + StudioIdentity.Owner to; + if (journal.isEmpty()) { + to = identity.heir(account).orElse(null); + if (to == null) return; + journal.setProperty("format", "1"); + journal.setProperty("kind", "migration"); + journal.setProperty("from-account", account.toString()); + journal.setProperty("to-account", to.account().toString()); + journal.setProperty("to-character", to.character().toString()); + for (Path source : projects(folder)) { + String id = source.getFileName().toString().replace(".yml", ""); + byte[] bytes = Files.readAllBytes(source); + try { + UUID.fromString(id); + var yaml = yaml(bytes); + if (!Integer.valueOf(StudioStore.FORMAT).equals(yaml.get("format")) || !account.toString().equals(yaml.get("owner")) + || !id.equals(yaml.get("id"))) throw new Conflict("Legacy project identity mismatch"); + plan(journal, id, source, store.projectFile(to.character(), UUID.fromString(id)), bytes, to); + } catch (IOException | IllegalArgumentException ex) { + journal.setProperty("held." + id, ex.getMessage()); + journal.setProperty("held-source." + id, Base64.getEncoder().encodeToString(bytes)); } - Path selected = folder.resolve(StudioStore.SELECTED); - if (Files.exists(selected)) journal.setProperty("selected", Base64.getEncoder().encodeToString(Files.readAllBytes(selected))); - write(journalFile, journal); // Entire inventory/backup precedes the first publication. - } else { - to = owner(journal, "to"); } - for (String id : ids(journal)) apply(journalFile, journal, id, () -> true, false); - if (!"true".equals(journal.getProperty("complete"))) { - String selected = journal.getProperty("selected"); - Path target = store.directory(to.character()).resolve(StudioStore.SELECTED); - if (selected != null && !Files.exists(target)) { - byte[] pointer = Base64.getDecoder().decode(selected); - try { - UUID id = UUID.fromString(new String(pointer, StandardCharsets.UTF_8).strip()); - if (Files.exists(store.projectFile(to.character(), id))) DurableFiles.write(target, pointer, false); - } catch (IllegalArgumentException ex) { journal.setProperty("held.selected", "Invalid project selection"); } - } - journal.setProperty("complete", "true"); - write(journalFile, journal); + Path selected = folder.resolve(StudioStore.SELECTED); + if (Files.exists(selected)) journal.setProperty("selected", Base64.getEncoder().encodeToString(Files.readAllBytes(selected))); + write(journalFile, journal); // Entire inventory/backup precedes the first publication. + } else { + to = owner(journal, "to"); + } + for (String id : ids(journal)) apply(journalFile, journal, id, () -> true, false); + if (!"true".equals(journal.getProperty("complete"))) { + String selected = journal.getProperty("selected"); + Path target = store.directory(to.character()).resolve(StudioStore.SELECTED); + if (selected != null && !Files.exists(target)) { + byte[] pointer = Base64.getDecoder().decode(selected); + try { + UUID id = UUID.fromString(new String(pointer, StandardCharsets.UTF_8).strip()); + if (Files.exists(store.projectFile(to.character(), id))) DurableFiles.write(target, pointer, false); + } catch (IllegalArgumentException ex) { journal.setProperty("held.selected", "Invalid project selection"); } } + journal.setProperty("complete", "true"); + write(journalFile, journal); } } @@ -116,8 +122,9 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro journal.setProperty("from-account", from.account().toString()); journal.setProperty("from-character", from.character().toString()); } - if (!death.toString().equals(journal.getProperty("death")) || !from.equals(owner(journal, "from"))) + if (!from.equals(owner(journal, "from"))) throw new Conflict("Estate journal identity differs from this death"); + requireUnreserved(file, journal, store.directory(from.character())); for (String id : ids(journal)) { if ("moved".equals(journal.getProperty(id + ".state"))) continue; if (!exactDecision.equals(journal.getProperty(id + ".decision"))) { @@ -159,8 +166,9 @@ List retainedReceipts(UUID death, String exactDecision, StudioIdentity.O Path file = root.resolve("ownership/estates").resolve(death + ".xml"); Properties journal = read(file); boolean changed = false; - if (!journal.isEmpty() && (!death.toString().equals(journal.getProperty("death")) || !from.equals(owner(journal, "from")))) + if (!journal.isEmpty() && !from.equals(owner(journal, "from"))) throw new Conflict("Estate journal identity differs from this death"); + requireUnreserved(file, journal, store.directory(from.character())); for (Path source : projects(store.directory(from.character()))) { String id = source.getFileName().toString().replace(".yml", ""); if ("moved".equals(journal.getProperty(id + ".state"))) @@ -219,6 +227,8 @@ private void apply(Path file, Properties journal, String id, BooleanSupplier gua if (!Files.exists(source) && "planned".equals(journal.getProperty(id + ".state"))) throw new Conflict("Planned source project disappeared: " + source); if (!Files.exists(source) && !Files.exists(target)) throw new Conflict("Both published project copies disappeared"); + if ("published".equals(journal.getProperty(id + ".state")) && !Files.exists(target)) + throw new Conflict("Published project target disappeared; staff recovery required: " + target); if (Files.exists(target)) { if (!hash(Files.readAllBytes(target)).equals(journal.getProperty(id + ".after-hash"))) throw new Conflict("Recipient project conflicts: " + target); @@ -237,22 +247,40 @@ private void apply(Path file, Properties journal, String id, BooleanSupplier gua } void requireWritable(UUID character) throws IOException { + for (String kind : List.of("migration", "estates")) for (Path file : files(root.resolve("ownership").resolve(kind))) + if (locks(file).characters().contains(character)) throw new Conflict("Studio ownership publication needs recovery: " + file); + } + + // Copies at either end of an unfinished publication still belong to that operation. + private void requireUnreserved(Path current, Properties journal, Path folder) throws IOException { + java.util.Set wanted = new java.util.HashSet<>(); + for (String id : ids(journal)) if (!"moved".equals(journal.getProperty(id + ".state"))) wanted.add(id); + for (Path source : projects(folder)) wanted.add(source.getFileName().toString().replace(".yml", "")); for (String kind : List.of("migration", "estates")) for (Path file : files(root.resolve("ownership").resolve(kind))) { - var attributes = Files.readAttributes(file, java.nio.file.attribute.BasicFileAttributes.class); - Stamp stamp = new Stamp(attributes.size(), attributes.lastModifiedTime(), attributes.fileKey()); - Locks cached = lockCache.get(file); - if (cached == null || !stamp.equals(cached.stamp())) { - Properties journal = read(file); - java.util.Set pending = new java.util.HashSet<>(); - for (String id : ids(journal)) if (!"moved".equals(journal.getProperty(id + ".state"))) { - pending.add(owner(journal, id + ".to").character()); - if ("estate".equals(journal.getProperty("kind"))) pending.add(owner(journal, "from").character()); - } - cached = new Locks(stamp, java.util.Set.copyOf(pending)); - lockCache.put(file, cached); + Locks pending = locks(file); + if (file.equals(current)) continue; + for (String id : pending.projects()) + if (wanted.contains(id)) throw new Conflict("Project is still pending in another ownership journal: " + id); + } + } + + private Locks locks(Path file) throws IOException { + byte[] bytes = Files.readAllBytes(file); + String digest = hash(bytes); + Locks cached = lockCache.get(file); + if (cached == null || !digest.equals(cached.digest())) { + Properties journal = read(file, bytes); + java.util.Set pending = new java.util.HashSet<>(); + java.util.Set projects = new java.util.HashSet<>(); + for (String id : ids(journal)) if (!"moved".equals(journal.getProperty(id + ".state"))) { + projects.add(id); + pending.add(owner(journal, id + ".to").character()); + if ("estate".equals(journal.getProperty("kind"))) pending.add(owner(journal, "from").character()); } - if (cached.characters().contains(character)) throw new Conflict("Studio ownership publication needs recovery: " + file); + cached = new Locks(digest, java.util.Set.copyOf(pending), java.util.Set.copyOf(projects)); + lockCache.put(file, cached); } + return cached; } List holdings(UUID character) throws IOException { @@ -265,9 +293,21 @@ void requireEstateReady(StudioIdentity.Owner from) throws IOException { Properties journal = read(root.resolve("ownership/migration").resolve(from.account() + ".xml")); if (journal.isEmpty() || !"true".equals(journal.getProperty("complete"))) throw new IOException("The account's reviewed legacy project migration is not complete"); - if (from.character().toString().equals(journal.getProperty("to-character")) - && journal.stringPropertyNames().stream().anyMatch(key -> key.startsWith("held."))) - throw new Conflict("Legacy projects for this character remain held for staff"); + if (from.character().toString().equals(journal.getProperty("to-character"))) + for (String key : journal.stringPropertyNames()) if (key.startsWith("held.") + && !(key.equals("held.selected") && invalidSelectionOnly(journal, legacy))) + throw new Conflict("Legacy projects for this character remain held for staff"); + } + + private boolean invalidSelectionOnly(Properties journal, Path legacy) throws IOException { + if (!"Invalid project selection".equals(journal.getProperty("held.selected")) || journal.getProperty("selected") == null) return false; + byte[] pointer; + try { pointer = Base64.getDecoder().decode(journal.getProperty("selected")); } + catch (IllegalArgumentException malformed) { return false; } + Path source = legacy.resolve(StudioStore.SELECTED); + if (!Files.isRegularFile(source) || !java.util.Arrays.equals(pointer, Files.readAllBytes(source))) return false; + try { UUID.fromString(new String(pointer, StandardCharsets.UTF_8).strip()); return false; } + catch (IllegalArgumentException invalid) { return true; } } private Path path(String relative) { return root.resolve(relative).normalize(); } @@ -278,7 +318,9 @@ private static StudioIdentity.Owner owner(Properties values, String prefix) thro catch (IllegalArgumentException | NullPointerException ex) { throw new Conflict("Invalid journal owner"); } } - static Properties read(Path file) throws IOException { + static Properties read(Path file) throws IOException { return read(file, Files.exists(file) ? Files.readAllBytes(file) : null); } + + private static Properties read(Path file, byte[] journalBytes) throws IOException { class Journal extends Properties { boolean loading; @Override public synchronized Object put(Object key, Object value) { @@ -287,7 +329,7 @@ class Journal extends Properties { } } Journal result = new Journal(); - if (Files.exists(file)) try (var input = Files.newInputStream(file)) { + if (journalBytes != null) try (var input = new ByteArrayInputStream(journalBytes)) { result.loading = true; try { result.loadFromXML(input); } catch (java.util.InvalidPropertiesFormatException | IllegalArgumentException ex) { throw new Conflict("Unreadable ownership journal"); } @@ -296,6 +338,19 @@ class Journal extends Properties { String kind = result.getProperty("kind"); if (!"migration".equals(kind) && !"estate".equals(kind)) throw new Conflict("Unknown ownership journal kind"); try { + if (!file.getParent().getFileName().toString().equals("estate".equals(kind) ? "estates" : "migration")) + throw new Conflict("Ownership journal kind differs from its path"); + if ("estate".equals(kind)) { + owner(result, "from"); + if (!file.getFileName().toString().equals(UUID.fromString(result.getProperty("death")) + ".xml")) + throw new Conflict("Estate death differs from its journal path"); + } else if (result.getProperty("from-account") != null) { + UUID account = UUID.fromString(result.getProperty("from-account")); + if (!file.getFileName().toString().equals(account + ".xml") || !account.equals(owner(result, "to").account())) + throw new Conflict("Legacy account differs from its journal path or reviewed heir"); + } else if (!file.getFileName().toString().matches("unrecognized-[0-9a-f]{64}\\.xml") || !ids(result).isEmpty()) { + throw new Conflict("Unattributed ownership journal needs staff review"); + } for (String id : ids(result)) { UUID.fromString(id); if (!List.of("planned", "published", "moved").contains(result.getProperty(id + ".state"))) diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java index 3ed5fbb..56d7eb1 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java @@ -138,11 +138,8 @@ public StudioIdentity.Token token(Player player) { () -> new IllegalArgumentException("Choose an active character before using the recording studio")); if (!current(token, player)) throw new IllegalArgumentException("Your character session changed"); owners.put(token.owner().character(), token.owner()); + if (!migrationAttempted) migrateOwnership(); try { - if (!migrationAttempted && identity.heirsReady()) { - ownership.migrate(identity); - migrationAttempted = true; - } ownership.requireWritable(token.owner().character()); } catch (IOException ex) { throw new IllegalArgumentException("Studio ownership needs recovery: " + ex.getMessage(), ex); } @@ -157,13 +154,23 @@ public boolean current(StudioIdentity.Token token, Player player) { public void startOwnership(StudioMenu menu) { plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { - try { ownership.migrate(identity); migrationAttempted = identity.heirsReady(); } - catch (IOException ex) { plugin.getLogger().warning("Studio migration held: " + ex.getMessage()); } + migrateOwnership(); }, 1L, 72_000L); if (identity instanceof CharacterStudioBridge) CharacterStudioBridge.start(plugin, this, menu); StudioEstate.startIfAvailable(plugin, this, ownership); } + private void migrateOwnership() { + if (!identity.heirsReady()) return; + migrationAttempted = true; + try { ownership.migrate(identity); } + catch (IOException ex) { + var causes = new java.util.StringJoiner("; "); causes.add(ex.getMessage()); + for (Throwable other : ex.getSuppressed()) causes.add(other.getMessage()); + plugin.getLogger().warning("Studio migration held: " + causes); + } + } + boolean quiescent(UUID character) { completeSaves(); return !pendingSaves.containsKey(character) && !unsaved.contains(character) @@ -307,7 +314,7 @@ private void persist(Player player) throws IOException { } public void requireIdle(Player player) { - if (recordings.containsKey(owner(player))) { + if (recordings.containsKey(player.getUniqueId())) { throw new IllegalArgumentException("Stop the recording with /music stop first"); } CompletableFuture pending = pendingSaves.get(owner(player)); diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipReviewTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipReviewTest.java new file mode 100644 index 0000000..aedaef6 --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipReviewTest.java @@ -0,0 +1,309 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.musicalinstruments.InstrumentPlugin; +import net.tfminecraft.musicalinstruments.managers.InstrumentManager; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Reviewed recording identity and independent ownership publication regressions. */ +class StudioOwnershipReviewTest { + @TempDir Path root; + + record Rig(StudioService studio, Player player, Runnable tick, ExecutorService writer, StudioStore store, StudioIdentity.Token token, java.util.logging.Logger logger, InstrumentPlugin plugin, Server server) {} + + Rig rig(UUID account, UUID character, TestIdentity identity) throws IOException { + var plugin = mock(InstrumentPlugin.class); var server = mock(Server.class); var scheduler = mock(BukkitScheduler.class); + var logger=mock(java.util.logging.Logger.class); + when(plugin.getServer()).thenReturn(server); when(plugin.getLogger()).thenReturn(logger); + when(server.getScheduler()).thenReturn(scheduler); + var tick = new AtomicReference(); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenAnswer(call -> { tick.set(call.getArgument(1)); return mock(BukkitTask.class); }); + var player = mock(Player.class); var inventory = mock(PlayerInventory.class); var world = mock(World.class); + when(player.getUniqueId()).thenReturn(account); when(player.getName()).thenReturn("Account name"); + when(player.getInventory()).thenReturn(inventory); when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(server.getPlayer(account)).thenReturn(player); + var instruments = mock(InstrumentManager.class); when(instruments.getInstrument(any())).thenReturn("lute"); + var token = new StudioIdentity.Token(new StudioIdentity.Owner(account, character), 1); + identity.sessions.put(account, token); + var store = new StudioStore(root); var writer = Executors.newSingleThreadExecutor(); + var studio = new StudioService(plugin, instruments, store, new StudioSettings(4, 40, 20, 60, 8, 32, 2, 0, 2, 0, 0), + StudioStation.none(), mock(DiscItems.class), writer, identity); + return new Rig(studio, player, () -> tick.get().run(), writer, store, token, logger, plugin, server); + } + + // F1: recordings are keyed by account, requireIdle looks them up by character. + @Test void desiredSecondRecordIsRefusedWhileRecordingWithADistinctCharacter() throws Exception { + UUID account = UUID.randomUUID(); + Rig rig = rig(account, UUID.randomUUID(), new TestIdentity()); + try { + rig.studio().create(rig.player(), "First", false); + Project first=rig.studio().requireProject(rig.player()); + rig.studio().record(rig.player(), 1); + rig.tick().run();rig.tick().run();rig.tick().run();rig.studio().capture(rig.player(),"lute","kept_first_take",1,1); + var error = assertThrows(IllegalArgumentException.class, () -> rig.studio().record(rig.player(), 2)); + assertEquals("Stop the recording with /music stop first", error.getMessage()); + assertThrows(IllegalArgumentException.class,()->rig.studio().deleteProject(rig.player(),first)); + assertEquals(1,rig.studio().activity(rig.player()).track());assertEquals(1,rig.studio().activity(rig.player()).notes()); + rig.studio().stop(rig.player());rig.writer().submit(()->{}).get(3,TimeUnit.SECONDS);rig.tick().run(); + assertEquals("kept_first_take",rig.store().project(rig.token().owner().character(),first.song().id()).pending().notes().getFirst().sound()); + } finally { rig.studio().close(); } + } + + @Test void controlSecondRecordIsRefusedWhenCharacterEqualsAccount() throws Exception { + UUID account = UUID.randomUUID(); + Rig rig = rig(account, account, new TestIdentity()); + try { + rig.studio().create(rig.player(), "First", false); + rig.studio().record(rig.player(), 1); + assertThrows(IllegalArgumentException.class, () -> rig.studio().record(rig.player(), 2)); + } finally { rig.studio().close(); } + } + + + // F2: an incomplete earlier estate is not a reservation against a later estate of its recipient. + StudioIdentity.Owner owner() { return new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); } + + UUID interruptedFirstEstate(StudioStore store, StudioOwnership ownership, UUID d1, StudioIdentity.Owner a, StudioIdentity.Owner b) throws Exception { + Project project = new Project(new Song(UUID.randomUUID(), a.account(), "Author", "Chain", List.of()), null, 100, false); + store.saveOwned(a, project); + UUID id = project.song().id(); + Path sourceA = store.projectFile(a.character(), id); + byte[] sourceBytes = Files.readAllBytes(sourceA); + try (var disk = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) { + // Stubbing a CALLS_REAL_METHODS static runs the real delete once; restore the source as the suite does. + disk.when(() -> DurableFiles.delete(sourceA)).thenThrow(new IOException("temporary disk failure")); + Files.write(sourceA, sourceBytes); + assertThrows(IOException.class, () -> ownership.transfer(d1, StudioOwnershipTest.decision(d1, "one", a, b), a, b, () -> true)); + } + assertEquals("published", StudioOwnership.read(root.resolve("ownership/estates").resolve(d1 + ".xml")).getProperty(id + ".state")); + assertTrue(Files.exists(sourceA)); assertTrue(Files.exists(store.projectFile(b.character(), id))); + return id; + } + + + @Test void desiredLaterEstateIsBlockedWhileAnEarlierEstateStillOwnsTheProject() throws Exception { + StudioStore store = new StudioStore(root); StudioOwnership ownership = new StudioOwnership(store); + var a = owner(); var b = owner(); var c = owner(); UUID d1 = UUID.randomUUID(); UUID d2 = UUID.randomUUID(); + UUID id = interruptedFirstEstate(store, ownership, d1, a, b); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(d2, StudioOwnershipTest.decision(d2, "one", b, c), b, c, () -> true)); + assertFalse(Files.exists(store.projectFile(c.character(), id))); + // d1 resumes, then the genuine chain completes. + assertEquals(List.of(id.toString()), ownership.transfer(d1, StudioOwnershipTest.decision(d1, "one", a, b), a, b, () -> true)); + assertEquals(List.of(id.toString()), ownership.transfer(d2, StudioOwnershipTest.decision(d2, "one", b, c), b, c, () -> true)); + assertFalse(Files.exists(store.projectFile(b.character(), id))); + assertTrue(Files.exists(store.projectFile(c.character(), id))); + } + + // F3: one account's held migration conflict blocks every player's studio. + + @Test void desiredDeleteIsRefusedWhileRecordingWithADistinctCharacter() throws Exception { + UUID account = UUID.randomUUID(); + Rig rig = rig(account, UUID.randomUUID(), new TestIdentity()); + try { + rig.studio().create(rig.player(), "First", false); + Project first = rig.studio().requireProject(rig.player()); + rig.studio().record(rig.player(), 1); + assertThrows(IllegalArgumentException.class, () -> rig.studio().deleteProject(rig.player(), first)); + assertThrows(IllegalArgumentException.class, () -> rig.studio().selectProject(rig.player(), UUID.randomUUID())); + } finally { rig.studio().close(); } + } + + @Test void desiredRetainedReceiptsAlsoRespectAnotherEstatesReservation() throws Exception { + StudioStore store = new StudioStore(root); StudioOwnership ownership = new StudioOwnership(store); + var a = owner(); var b = owner(); var c = owner(); UUID d1 = UUID.randomUUID(); UUID d2 = UUID.randomUUID(); + UUID id = interruptedFirstEstate(store, ownership, d1, a, b); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.retainedReceipts(d2, StudioOwnershipTest.decision(d2, "one waiting", b, c), b)); + assertTrue(Files.exists(store.projectFile(b.character(), id))); + } + + @Test void desiredMigrationConflictStaysWithItsAccount() throws Exception { + List sorted = new java.util.ArrayList<>(List.of(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID())); + sorted.sort(java.util.Comparator.comparing(UUID::toString)); + final UUID x = sorted.get(0), z = sorted.get(1), y = sorted.get(2); + StudioStore store = new StudioStore(root); + Project legacyX = new Project(new Song(UUID.randomUUID(), x, "X", "Legacy X", List.of()), null, 100, false); + Project legacyZ = new Project(new Song(UUID.randomUUID(), z, "Z", "Legacy Z", List.of()), null, 100, false); + Project legacyY = new Project(new Song(UUID.randomUUID(), y, "Y", "Legacy Y", List.of()), null, 100, false); + for (Project project : List.of(legacyX, legacyZ, legacyY)) { + UUID account = project.song().owner(); + store.save(project); + Path legacy = root.resolve("projects").resolve(account.toString()); + Files.createDirectories(legacy.getParent()); Files.move(store.directory(account), legacy); + } + store.saveOwned(new StudioIdentity.Owner(x, x), new Project(new Song(legacyX.song().id(), x, "X", "Edited after migration", List.of()), null, 100, false)); + store.saveOwned(new StudioIdentity.Owner(z, z), new Project(new Song(legacyZ.song().id(), z, "Z", "Edited after migration", List.of()), null, 100, false)); + var identity = new TestIdentity(); identity.defaultHeirs = false; + identity.heirs.put(x, new StudioIdentity.Owner(x, x)); identity.heirs.put(z, new StudioIdentity.Owner(z, z)); + UUID yCharacter = UUID.randomUUID(); identity.heirs.put(y, new StudioIdentity.Owner(y, yCharacter)); + var ownership = new StudioOwnership(store); + var error = assertThrows(StudioOwnership.Conflict.class, () -> ownership.migrate(identity)); + assertEquals(1, error.getSuppressed().length); + assertTrue(Files.exists(store.projectFile(yCharacter, legacyY.song().id())), "later account still migrates"); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireWritable(x)); + ownership.requireWritable(yCharacter); + Rig rig = rig(y, yCharacter, identity); + try { + assertEquals(List.of(legacyY.song().id()), rig.studio().listProjects(rig.player()).stream().map(ProjectSummary::id).toList()); + rig.studio().create(rig.player(), "Unrelated", false); + rig.studio().token(rig.player());rig.studio().listProjects(rig.player()); + verify(rig.logger(),times(1)).warning(argThat((String text)->text.contains(x.toString())&&text.contains(z.toString()))); + } finally { rig.studio().close(); } + } + + @Test void desiredUnrelatedIncompleteEstateDoesNotBlockAnotherTransfer() throws Exception { + StudioStore store = new StudioStore(root); StudioOwnership ownership = new StudioOwnership(store); + var a = owner(); var b = owner(); var d = owner(); var e = owner(); UUID d1 = UUID.randomUUID(); UUID d3 = UUID.randomUUID(); + interruptedFirstEstate(store, ownership, d1, a, b); + Project other = new Project(new Song(UUID.randomUUID(), d.account(), "Other", "Unrelated", List.of()), null, 100, false); + store.saveOwned(d, other); + assertEquals(List.of(other.song().id().toString()), ownership.transfer(d3, StudioOwnershipTest.decision(d3, "one", d, e), d, e, () -> true)); + } + + @Test void desiredUnrecognizedSourceFileStillBlocksWithItsOwnReason() throws Exception { + StudioStore store = new StudioStore(root); StudioOwnership ownership = new StudioOwnership(store); + var a = owner(); var b = owner(); UUID death = UUID.randomUUID(); + Files.createDirectories(store.directory(a.character())); + Files.writeString(store.directory(a.character()).resolve("0-not-a-uuid.yml"), "unreadable identity"); + var error = assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(death, StudioOwnershipTest.decision(death, "one", a, b), a, b, () -> true)); + assertTrue(error.getMessage().startsWith("Unrecognized project file"), error.getMessage()); + } + + java.util.Map snapshot() throws IOException { + var result=new java.util.TreeMap();try(var files=Files.walk(root)) { + for(Path file:files.filter(Files::isRegularFile).toList())result.put(root.relativize(file),java.util.Base64.getEncoder().encodeToString(Files.readAllBytes(file))); + }return result; + } + @Test void publishedMissingTargetNeverRepublishesButUntouchedPlannedMissingTargetIsSafe() throws Exception { + var store=new StudioStore(root);var ownership=new StudioOwnership(store);var a=owner();var b=owner();UUID death=UUID.randomUUID(); + UUID id=interruptedFirstEstate(store,ownership,death,a,b);Files.delete(store.projectFile(b.character(),id));var before=snapshot(); + assertThrows(StudioOwnership.Conflict.class,()->ownership.transfer(death,StudioOwnershipTest.decision(death,"one",a,b),a,b,()->true));assertEquals(before,snapshot()); + var c=owner();var d=owner();UUID untouched=UUID.randomUUID();var project=new Project(new Song(UUID.randomUUID(),c.account(),"Author","Untouched",List.of()),null,100,false);store.saveOwned(c,project); + int[] checks={0};assertThrows(IOException.class,()->ownership.transfer(untouched,StudioOwnershipTest.decision(untouched,"one",c,d),c,d,()->++checks[0]==1)); + assertFalse(Files.exists(store.projectFile(d.character(),project.song().id()))); + assertEquals(List.of(project.song().id().toString()),ownership.transfer(untouched,StudioOwnershipTest.decision(untouched,"one",c,d),c,d,()->true)); + } + @Test void pendingMigrationAlsoReservesAProjectsEstateAndMissingPublishedTargetStaysHeld() throws Exception { + var store=new StudioStore(root);var ownership=new StudioOwnership(store);var a=owner();var b=owner();UUID id=UUID.randomUUID(); + var project=new Project(new Song(id,a.account(),"Author","Legacy",List.of()),null,100,false);store.save(project); + Path folder=root.resolve("projects/"+a.account());Files.createDirectories(folder.getParent());Files.move(store.directory(a.account()),folder); + var identity=new TestIdentity();identity.defaultHeirs=false;identity.heirs.put(a.account(),a); + Path journal=root.resolve("ownership/migration/"+a.account()+".xml"); + try(var disk=mockStatic(DurableFiles.class,CALLS_REAL_METHODS)) { + disk.when(()->DurableFiles.write(eq(journal),any(byte[].class),eq(true))).thenAnswer(call->{call.callRealMethod();if(new String((byte[])call.getArgument(1),java.nio.charset.StandardCharsets.UTF_8).contains(">published<"))throw new IOException("after durable migration publication");return null;}); + assertThrows(IOException.class,()->ownership.migrate(identity)); + } + var before=snapshot();UUID death=UUID.randomUUID(); + assertThrows(StudioOwnership.Conflict.class,()->ownership.transfer(death,StudioOwnershipTest.decision(death,"one",a,b),a,b,()->true)); + assertThrows(StudioOwnership.Conflict.class,()->ownership.retainedReceipts(death,StudioOwnershipTest.decision(death,"one waiting",a,b),a));assertEquals(before,snapshot()); + Files.delete(store.projectFile(a.character(),id));before=snapshot(); + assertThrows(StudioOwnership.Conflict.class,()->ownership.migrate(identity));assertEquals(before,snapshot()); + } + @Test void journalLocksValidateCurrentBytesEvenWhenSizeMtimeAndFileKeyAreUnchanged() throws Exception { + var store=new StudioStore(root);var ownership=new StudioOwnership(store);var a=owner();var b=owner();var c=owner();UUID death=UUID.randomUUID(); + var project=new Project(new Song(UUID.randomUUID(),a.account(),"Author","History",List.of()),null,100,false);store.saveOwned(a,project); + ownership.transfer(death,StudioOwnershipTest.decision(death,"one",a,b),a,b,()->true); + Path file=root.resolve("ownership/estates/"+death+".xml");byte[] completed=Files.readAllBytes(file);var attributes=Files.readAttributes(file,java.nio.file.attribute.BasicFileAttributes.class); + ownership.requireWritable(a.character());ownership.requireWritable(b.character()); + byte[] reopened=new String(completed,java.nio.charset.StandardCharsets.UTF_8).replace(">moved<",">published<").replace("Studio ownership format 1","Studio ownership fmt1").getBytes(java.nio.charset.StandardCharsets.UTF_8); + assertEquals(completed.length,reopened.length);Files.write(file,reopened);Files.setLastModifiedTime(file,attributes.lastModifiedTime()); + assertEquals(attributes.fileKey(),Files.readAttributes(file,java.nio.file.attribute.BasicFileAttributes.class).fileKey());var before=snapshot(); + assertThrows(StudioOwnership.Conflict.class,()->ownership.requireWritable(a.character()));assertThrows(StudioOwnership.Conflict.class,()->ownership.requireWritable(b.character())); + UUID next=UUID.randomUUID();assertThrows(StudioOwnership.Conflict.class,()->ownership.transfer(next,StudioOwnershipTest.decision(next,"one",b,c),b,c,()->true));assertEquals(before,snapshot()); + byte[] invalid=new String(reopened,java.nio.charset.StandardCharsets.UTF_8).replace("1","9").getBytes(java.nio.charset.StandardCharsets.UTF_8); + assertEquals(reopened.length,invalid.length);Files.write(file,invalid);Files.setLastModifiedTime(file,attributes.lastModifiedTime());before=snapshot(); + for(int attempt=0;attempt<2;attempt++)assertThrows(StudioOwnership.Conflict.class,()->ownership.requireWritable(c.character()));assertEquals(before,snapshot()); + Files.write(file,completed);Files.setLastModifiedTime(file,attributes.lastModifiedTime());ownership.requireWritable(a.character());ownership.requireWritable(b.character()); + assertEquals(List.of(project.song().id().toString()),ownership.transfer(next,StudioOwnershipTest.decision(next,"one",b,c),b,c,()->true)); + } + @Test void otherMalformedOrMisnamedJournalsHoldBeforeAnyNewBackupOrPublication() throws Exception { + var store=new StudioStore(root);var ownership=new StudioOwnership(store);var a=owner();var b=owner();UUID death=UUID.randomUUID(); + var project=new Project(new Song(UUID.randomUUID(),a.account(),"Author","Source",List.of()),null,100,false);store.saveOwned(a,project); + Path file=root.resolve("ownership/estates/"+UUID.randomUUID()+".xml");Files.createDirectories(file.getParent()); + var base=new java.util.Properties();base.setProperty("format","1");base.setProperty("kind","estate");base.setProperty("death",file.getFileName().toString().replace(".xml",""));base.setProperty("from-account",a.account().toString());base.setProperty("from-character",a.character().toString()); + for(String fault:List.of("death","from-character","kind","unreadable","wrong-folder")) { + var changed=new java.util.Properties();changed.putAll(base); + if(fault.equals("death"))changed.setProperty("death",UUID.randomUUID().toString()); + if(fault.equals("from-character"))changed.remove("from-character"); + if(fault.equals("kind"))changed.setProperty("kind","migration"); + if(fault.equals("wrong-folder")){changed.setProperty("kind","migration");changed.remove("from-account");} + StudioOwnership.write(file,changed);if(fault.equals("unreadable"))Files.writeString(file,"not XML");var before=snapshot(); + assertThrows(IOException.class,()->ownership.transfer(death,StudioOwnershipTest.decision(death,"one",a,b),a,b,()->true),fault); + assertThrows(IOException.class,()->ownership.retainedReceipts(death,StudioOwnershipTest.decision(death,"one waiting",a,b),a),fault);assertEquals(before,snapshot(),fault); + } + } + @Test void invalidLegacySelectedPointerDoesNotHoldAnAssetButUnprovenPointerAndOtherHoldsDo() throws Exception { + var store=new StudioStore(root);var ownership=new StudioOwnership(store);var a=owner();var b=owner(); + var project=new Project(new Song(UUID.randomUUID(),a.account(),"Author","Legacy",List.of()),null,100,false);store.save(project); + Path folder=root.resolve("projects/"+a.account());Files.createDirectories(folder.getParent());Files.move(store.directory(a.account()),folder);Path pointer=folder.resolve("selected");Files.writeString(pointer,"bad pointer"); + var identity=new TestIdentity();identity.defaultHeirs=false;identity.heirs.put(a.account(),a);ownership.migrate(identity);ownership.requireEstateReady(a); + Path file=root.resolve("ownership/migration/"+a.account()+".xml");var original=StudioOwnership.read(file);String bytes=java.util.Base64.getEncoder().encodeToString(Files.readAllBytes(pointer));assertEquals(bytes,original.getProperty("selected")); + for(String fault:List.of("reason","missing","base64","valid-id","source-missing","source-edited","other-hold")) { + var changed=new java.util.Properties();changed.putAll(original);Files.writeString(pointer,"bad pointer"); + switch(fault) { + case "reason"->changed.setProperty("held.selected","Unknown asset hold");case "missing"->changed.remove("selected");case "base64"->changed.setProperty("selected","invalid base64!"); + case "valid-id"->{Files.writeString(pointer,project.song().id().toString());changed.setProperty("selected",java.util.Base64.getEncoder().encodeToString(Files.readAllBytes(pointer)));} + case "source-missing"->Files.delete(pointer);case "source-edited"->Files.writeString(pointer,"different pointer");case "other-hold"->changed.setProperty("held.future","Retained opaque asset"); + } + StudioOwnership.write(file,changed);var before=snapshot();assertThrows(StudioOwnership.Conflict.class,()->ownership.requireEstateReady(a),fault);assertEquals(before,snapshot()); + } + Files.writeString(pointer,"bad pointer");StudioOwnership.write(file,original);UUID death=UUID.randomUUID(); + assertEquals(List.of(project.song().id().toString()),ownership.transfer(death,StudioOwnershipTest.decision(death,"one",a,b),a,b,()->true)); + assertEquals(bytes,StudioOwnership.read(file).getProperty("selected"));assertEquals("bad pointer",Files.readString(pointer)); + } + @Test void unattributedHeaderAndHashConsistentLegacyOwnerChangesAreNotCachedAsUnrelated() throws Exception { + var store=new StudioStore(root);var ownership=new StudioOwnership(store);var a=owner();var b=owner(); + var project=new Project(new Song(UUID.randomUUID(),a.account(),"Author","Legacy",List.of()),null,100,false);store.save(project); + Path folder=root.resolve("projects/"+a.account());Files.createDirectories(folder.getParent());Files.move(store.directory(a.account()),folder); + var identity=new TestIdentity();identity.defaultHeirs=false;identity.heirs.put(a.account(),a);ownership.migrate(identity); + Path file=root.resolve("ownership/migration/"+a.account()+".xml");var valid=StudioOwnership.read(file);String id=project.song().id().toString(); + for(boolean hasEntry:List.of(false,true)) { + var changed=new java.util.Properties();changed.putAll(valid);changed.remove("from-account"); + Path unknown=file.resolveSibling(hasEntry?"unrecognized-"+"a".repeat(64)+".xml":"unknown.xml"); + if(!hasEntry)for(String key:List.copyOf(changed.stringPropertyNames()))if(key.startsWith(id+"."))changed.remove(key); + StudioOwnership.write(unknown,changed);var before=snapshot(); + assertThrows(StudioOwnership.Conflict.class,()->ownership.requireWritable(b.character()));assertEquals(before,snapshot());Files.delete(unknown); + } + var changed=new java.util.Properties();changed.putAll(valid);String author=UUID.randomUUID().toString(); + for(String part:List.of("before","after")) { + var raw=RawYaml.read(java.util.Base64.getDecoder().decode(changed.getProperty(id+"."+part)));raw.put("owner",author);byte[] bytes=RawYaml.bytes(raw); + changed.setProperty(id+"."+part,java.util.Base64.getEncoder().encodeToString(bytes));changed.setProperty(id+"."+part+"-hash",StudioOwnership.hash(bytes)); + } + StudioOwnership.write(file,changed);var before=snapshot();assertThrows(StudioOwnership.Conflict.class,()->ownership.requireWritable(b.character()));assertEquals(before,snapshot()); + } + @Test void hourlyRetryUsesFixedHeirAfterIsolatedFailureWithoutRepeatedActionAttempts() throws Exception { + var a=owner();var b=owner();var identity=new TestIdentity();identity.defaultHeirs=false;identity.heirs.put(a.account(),a); + var store=new StudioStore(root);var project=new Project(new Song(UUID.randomUUID(),a.account(),"Author","Legacy",List.of()),null,100,false);store.save(project); + Path folder=root.resolve("projects/"+a.account());Files.createDirectories(folder.getParent());Files.move(store.directory(a.account()),folder); + var conflict=new Project(new Song(project.song().id(),a.account(),"Author","Conflicting current copy",List.of()),null,100,false);store.saveOwned(a,conflict); + var rig=rig(b.account(),b.character(),identity); + try { + rig.studio().create(rig.player(),"Independent",false);rig.studio().token(rig.player());rig.studio().listProjects(rig.player()); + verify(rig.logger(),times(1)).warning(anyString()); + try(var estate=mockStatic(StudioEstate.class)){rig.studio().startOwnership(mock(StudioMenu.class));} + var callbacks=org.mockito.ArgumentCaptor.forClass(Runnable.class);verify(rig.server().getScheduler()).runTaskTimer(eq(rig.plugin()),callbacks.capture(),eq(1L),eq(72_000L)); + callbacks.getValue().run();verify(rig.logger(),times(2)).warning(anyString()); + Files.delete(store.projectFile(a.character(),project.song().id()));identity.heirs.put(a.account(),b);callbacks.getValue().run(); + assertEquals(project,store.project(a.character(),project.song().id()));assertFalse(Files.exists(store.projectFile(b.character(),project.song().id()))); + new StudioOwnership(store).requireWritable(a.character());verify(rig.logger(),times(2)).warning(anyString()); + } finally {rig.studio().close();} + } +} diff --git a/tools/test_studio_ownership.py b/tools/test_studio_ownership.py index 55f2be7..9847d05 100644 --- a/tools/test_studio_ownership.py +++ b/tools/test_studio_ownership.py @@ -1,6 +1,7 @@ import base64 import copy import importlib.util +import os from pathlib import Path import tempfile import unittest @@ -212,6 +213,47 @@ def test_unknown_scalar_types_cannot_change_through_python_numeric_equality(self self.assertFalse(self.source.exists()) self.assertEqual(self.after, self.target.read_bytes()) + def test_chain_with_older_journal_newer_mtime_requires_checked_fresh_backup_passes(self): + first = self.journal + account, character, death = [str(uuid.uuid4()) for _ in range(3)] + last = self.root / 'character-projects' / character / (self.key + '.yml') + last.parent.mkdir(parents=True) + final = dict(self.output, **{'owner-account': account, 'owner-character': character}) + final_bytes = yaml.safe_dump(final).encode() + last.write_bytes(final_bytes) + self.target.unlink() + self.journal = first.with_name(death + '.xml') + fields = dict(self.fields, death=death, **{'from-account': self.recipient, 'from-character': self.new, + self.key + '.from-account': self.recipient, self.key + '.from-character': self.new, + self.key + '.to-account': account, self.key + '.to-character': character, + self.key + '.source': str(self.target.relative_to(self.root)), self.key + '.target': str(last.relative_to(self.root)), + self.key + '.decision': f'EstateDecision[deathId={death}, testator={studio.reference(self.recipient, self.new)}, diedAt=Optional.empty, property=Character[ref={studio.reference(account, character)}], guildLocks=Frozen[], revision=1]'}) + self.payload(fields, 'before', self.output) + self.payload(fields, 'after', final) + self.write(fields) + os.utime(first, ns=(3_000_000, 3_000_000)) + os.utime(self.journal, ns=(1_000_000, 1_000_000)) + original_first = first.read_bytes() + preview = studio.inspect(self.root, 'estates') + self.assertIn('both copies missing', preview['journals'][0]['records'][0]['result']) + self.assertEqual('safe offline rollback', preview['journals'][1]['records'][0]['result']) + result = self.apply() + self.assertNotIn('archived', result['journals'][0]) + self.assertIn('archived', result['journals'][1]) + self.assertEqual(original_first, first.read_bytes()) + self.assertEqual(self.after, self.target.read_bytes()) + self.assertFalse(last.exists()) + self.assertEqual(final_bytes, (self.recovery / last.relative_to(self.root)).read_bytes()) + again = studio.inspect(self.root, 'estates') + self.assertEqual('safe offline rollback', again['journals'][0]['records'][0]['result']) + next_backup = self.root / 'next-recovery' + final_report = studio.inspect(self.root, 'estates', True, next_backup) + self.assertIn('archived', final_report['journals'][0]) + self.assertEqual(self.before, self.source.read_bytes()) + self.assertEqual(self.after, (next_backup / self.target.relative_to(self.root)).read_bytes()) + self.assertFalse(self.target.exists()) + self.assertEqual([], studio.inspect(self.root, 'estates')['journals']) + if __name__ == "__main__": From 89f9cbfd93833311d5442ab88f89ecb9978d79c9 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:27:43 +0000 Subject: [PATCH 7/7] fix: bound studio ownership checks to admitted actions --- .../studio/CharacterStudioEstate.java | 36 ++- .../studio/StudioDialogs.java | 28 ++- .../musicalinstruments/studio/StudioMenu.java | 3 +- .../studio/StudioOwnership.java | 147 ++++++++++-- .../studio/StudioService.java | 215 ++++++++++------- .../studio/StudioDialogWorkflowTest.java | 12 + .../studio/StudioOwnershipCostTest.java | 143 +++++++++++ .../studio/StudioOwnershipPassTest.java | 223 ++++++++++++++++++ .../studio/StudioShutdownHoldProbeTest.java | 36 +++ 9 files changed, 711 insertions(+), 132 deletions(-) create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipCostTest.java create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipPassTest.java create mode 100644 src/test/java/net/tfminecraft/musicalinstruments/studio/StudioShutdownHoldProbeTest.java diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java index 4cd92ab..7d305b5 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/CharacterStudioEstate.java @@ -33,20 +33,27 @@ static void start(Plugin plugin, StudioService studio, StudioOwnership ownership } public String id() { return ID; } - public Outcome settle(EstateDecision decision) { + public Outcome settle(EstateDecision decision) { return settle(decision, false); } + + private Outcome settle(EstateDecision decision, boolean inPass) { if (!Estates.guardHolds(decision)) return Outcome.retry("The current estate decision does not hold"); UUID source = decision.testator().characterId(); if (!studio.quiescent(source)) return Outcome.retry("The original character's studio saves are still pending"); try { ownership.requireEstateReady(owner(decision.testator())); if (decision.property().character().isEmpty()) { - var previous = ownership.retainedReceipts(decision.deathId(), decision.toString(), owner(decision.testator())); + var previous = inPass + ? ownership.retainedReceiptsDuringPass(decision.deathId(), decision.toString(), owner(decision.testator())) + : ownership.retainedReceipts(decision.deathId(), decision.toString(), owner(decision.testator())); return Outcome.done(Map.of("projects", previous.size()), previous); } CharacterRef recipient = decision.property().character().orElseThrow(); if (!studio.quiescent(recipient.characterId())) return Outcome.retry("The recipient's studio saves are still pending"); - var moved = ownership.transfer(decision.deathId(), decision.toString(), owner(decision.testator()), owner(recipient), - () -> Estates.guardHolds(decision)); + var moved = inPass + ? ownership.transferDuringPass(decision.deathId(), decision.toString(), owner(decision.testator()), owner(recipient), + () -> Estates.guardHolds(decision)) + : ownership.transfer(decision.deathId(), decision.toString(), owner(decision.testator()), owner(recipient), + () -> Estates.guardHolds(decision)); studio.transferred(source); studio.transferred(recipient.characterId()); return Outcome.done(Map.of("projects", moved.size()), moved); @@ -67,15 +74,18 @@ public Holdings describe(UUID character) { } void reconcile() { - for (EstateDecision decision : Estates.decided()) { - try { - if (!Estates.guardHolds(decision)) continue; - Outcome outcome = settle(decision); - Map.Entry result = Map.entry(decision.revision(), outcome); - if (!result.equals(reported.get(decision.deathId())) - && Estates.report(decision.deathId(), ID, decision.revision(), outcome)) reported.put(decision.deathId(), result); - } catch (RuntimeException ex) { plugin.getLogger().warning("Studio estate failed: " + decision.deathId() + " " + ex); } - } + ownership.beginPass(); + try { + for (EstateDecision decision : Estates.decided()) { + try { + if (!Estates.guardHolds(decision)) continue; + Outcome outcome = settle(decision, true); + Map.Entry result = Map.entry(decision.revision(), outcome); + if (!result.equals(reported.get(decision.deathId())) + && Estates.report(decision.deathId(), ID, decision.revision(), outcome)) reported.put(decision.deathId(), result); + } catch (RuntimeException ex) { plugin.getLogger().warning("Studio estate failed: " + decision.deathId() + " " + ex); } + } + } finally { ownership.endPass(); } } private static StudioIdentity.Owner owner(CharacterRef ref) { return new StudioIdentity.Owner(ref.ownerId(), ref.characterId()); } diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java index 3b46d36..5bad946 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioDialogs.java @@ -47,6 +47,7 @@ private interface Response { void apply(Player player, DialogResponseView view) public void create(Player player, Location station) throws IOException { studio.requireIdle(player); + StudioIdentity.Token token = studio.token(player); studio.requireProjectSpace(player); AtomicBoolean used = new AtomicBoolean(); Dialog dialog = Dialog.create(builder -> builder.empty() @@ -57,29 +58,30 @@ public void create(Player player, Location station) throws IOException { .canCloseWithEscape(true).build()) .type(DialogType.confirmation( ActionButton.create(Component.text("Create", NamedTextColor.GREEN), null, 150, - action(player, station, used, (target, view) -> { + action(player, token, station, used, (target, view) -> { String title = view.getText("title"); if (title == null) throw new IllegalArgumentException("Enter a song title"); studio.create(target, title.strip(), false); })), ActionButton.create(Component.text("Back", NamedTextColor.GRAY), null, 150, - action(player, station, used, (target, view) -> {}, library))))); + action(player, token, station, used, (target, view) -> {}, library))))); player.closeInventory(); player.showDialog(dialog); } public void rename(Player player, Location station) throws IOException { studio.requireIdle(player); + StudioIdentity.Token token = studio.token(player); Project project = studio.requireProject(player); AtomicBoolean used = new AtomicBoolean(); UUID id = project.song().id(); - var accept = action(player, station, used, (target, view) -> { + var accept = action(player, token, station, used, (target, view) -> { String title = view.getText("title"); if (title == null) throw new IllegalArgumentException("Please enter a title"); Project current = studio.requireProject(target); applySettings(target, id, title, current.bpm(), current.metronome()); }); - var cancel = action(player, station, used, (target, view) -> {}); + var cancel = action(player, token, station, used, (target, view) -> {}); Dialog dialog = Dialog.create(builder -> builder.empty() .base(DialogBase.builder(Component.text("Rename project", NamedTextColor.GOLD)) .inputs(List.of(DialogInput.text("title", Component.text("Song title")) @@ -94,6 +96,7 @@ public void rename(Player player, Location station) throws IOException { public void reset(Player player, Location station) throws IOException { studio.requireIdle(player); + StudioIdentity.Token token = studio.token(player); Project project = studio.requireProject(player); AtomicBoolean used = new AtomicBoolean(); Dialog dialog = Dialog.create(builder -> builder.empty() @@ -103,20 +106,21 @@ public void reset(Player player, Location station) throws IOException { + "'. Published discs keep their music.")))).canCloseWithEscape(true).build()) .type(DialogType.confirmation( ActionButton.create(Component.text("Clear project", NamedTextColor.RED), null, 150, - action(player, station, used, (target, view) -> { + action(player, token, station, used, (target, view) -> { if (!studio.requireProject(target).equals(project)) { throw new IllegalArgumentException("The project changed. Open the confirmation again"); } studio.create(target, "Untitled", true); })), ActionButton.create(Component.text("Keep my song", NamedTextColor.GREEN), null, 150, - action(player, station, used, (target, view) -> {}))))); + action(player, token, station, used, (target, view) -> {}))))); player.closeInventory(); player.showDialog(dialog); } public void delete(Player player, Location station, UUID id) throws IOException { studio.requireIdle(player); + StudioIdentity.Token token = studio.token(player); Project project = studio.project(player, id); AtomicBoolean used = new AtomicBoolean(); Dialog dialog = Dialog.create(builder -> builder.empty() @@ -126,9 +130,9 @@ public void delete(Player player, Location station, UUID id) throws IOException .canCloseWithEscape(true).build()) .type(DialogType.confirmation( ActionButton.create(Component.text("Delete song", NamedTextColor.RED), null, 150, - action(player, station, used, (target, view) -> studio.deleteProject(target, project), library)), + action(player, token, station, used, (target, view) -> studio.deleteProject(target, project), library)), ActionButton.create(Component.text("Keep song", NamedTextColor.GREEN), null, 150, - action(player, station, used, (target, view) -> {}, library))))); + action(player, token, station, used, (target, view) -> {}, library))))); player.closeInventory(); player.showDialog(dialog); } @@ -143,13 +147,13 @@ void applySettings(Player player, UUID projectId, String title, int bpm, boolean project.pending(), bpm, metronome)); } - private DialogAction action(Player owner, Location station, AtomicBoolean used, Response response) { - return action(owner, station, used, response, reopen); + private DialogAction action(Player owner, StudioIdentity.Token token, Location station, AtomicBoolean used, Response response) { + return action(owner, token, station, used, response, reopen); } - private DialogAction action(Player owner, Location station, AtomicBoolean used, Response response, Reopen destination) { + // The token only binds the click to this session; each response checks ownership again before changing anything. + private DialogAction action(Player owner, StudioIdentity.Token token, Location station, AtomicBoolean used, Response response, Reopen destination) { UUID ownerId = owner.getUniqueId(); - StudioIdentity.Token token = studio.token(owner); return DialogAction.customClick((view, audience) -> { if (!(audience instanceof Player target) || !target.getUniqueId().equals(ownerId) || !used.compareAndSet(false, true) || !plugin.isEnabled()) return; diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java index 80c7d7d..3c707e6 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioMenu.java @@ -52,8 +52,9 @@ public StudioMenu(InstrumentPlugin plugin, StudioService studio) { private void open(Player player, Block station, Screen screen, int page) throws IOException { studio.station().require(player, station); + StudioIdentity.Token token = studio.token(player); UUID projectId = screen == Screen.LIBRARY ? null : studio.requireProject(player).song().id(); - Holder holder = new Holder(studio.token(player), station == null ? null : station.getLocation(), screen, page, projectId); + Holder holder = new Holder(token, station == null ? null : station.getLocation(), screen, page, projectId); holder.inventory = plugin.getServer().createInventory(holder, screen == Screen.SETTINGS ? 27 : 54, Component.text(screen == Screen.LIBRARY ? "\u266b Your song projects" : screen == Screen.SETTINGS ? "\u266b Song settings" : "\u266b Recording Studio", NamedTextColor.DARK_GRAY)); diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java index de5b07d..7a7de28 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioOwnership.java @@ -27,6 +27,13 @@ static final class Conflict extends IOException { private final Path root; private record Locks(String digest, java.util.Set characters, java.util.Set projects) {} private final java.util.Map lockCache = new java.util.HashMap<>(); + // A reconciliation snapshot only identifies operations with no effects. Own journal writes are reread, + // and an operation checks all current journals before its first backup, write or publication. + private boolean pass; + private java.util.Map passLocks; + private final java.util.Set passWrites = new java.util.HashSet<>(); + private record Readiness(String digest, boolean complete, String toCharacter, boolean otherHeld, String heldSelected, String selected) {} + private final java.util.Map readiness = new java.util.HashMap<>(); StudioOwnership(StudioStore store) { this.store = store; this.root = store.root(); } void migrate(StudioIdentity identity) throws IOException { @@ -73,7 +80,7 @@ private void migrate(StudioIdentity identity, Path folder) throws IOException { } Path selected = folder.resolve(StudioStore.SELECTED); if (Files.exists(selected)) journal.setProperty("selected", Base64.getEncoder().encodeToString(Files.readAllBytes(selected))); - write(journalFile, journal); // Entire inventory/backup precedes the first publication. + journal(journalFile, journal); // Entire inventory/backup precedes the first publication. } else { to = owner(journal, "to"); } @@ -89,7 +96,7 @@ private void migrate(StudioIdentity identity, Path folder) throws IOException { } catch (IllegalArgumentException ex) { journal.setProperty("held.selected", "Invalid project selection"); } } journal.setProperty("complete", "true"); - write(journalFile, journal); + journal(journalFile, journal); } } @@ -105,11 +112,21 @@ private void holdUnrecognized(Path source) throws IOException { journal.setProperty("held." + key, "Unrecognized legacy account path"); journal.setProperty("held-source." + key, Base64.getEncoder().encodeToString(bytes)); } - write(file, journal); + journal(file, journal); } List transfer(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to, BooleanSupplier guard) throws IOException { + return transfer(death, exactDecision, from, to, guard, false); + } + + List transferDuringPass(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to, + BooleanSupplier guard) throws IOException { + return transfer(death, exactDecision, from, to, guard, true); + } + + private List transfer(UUID death, String exactDecision, StudioIdentity.Owner from, StudioIdentity.Owner to, + BooleanSupplier guard, boolean usePass) throws IOException { if (from.equals(to)) throw new Conflict("Estate recipient is the testator"); long revision = validateDecision(exactDecision, death, from, to); Path file = root.resolve("ownership/estates").resolve(death + ".xml"); @@ -124,7 +141,7 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro } if (!from.equals(owner(journal, "from"))) throw new Conflict("Estate journal identity differs from this death"); - requireUnreserved(file, journal, store.directory(from.character())); + Reservation reservation = requireUnreserved(file, journal, store.directory(from.character()), usePass); for (String id : ids(journal)) { if ("moved".equals(journal.getProperty(id + ".state"))) continue; if (!exactDecision.equals(journal.getProperty(id + ".decision"))) { @@ -148,6 +165,7 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro if (!id.equals(yaml.get("id")) || !from.account().toString().equals(yaml.get("owner-account")) || !from.character().toString().equals(yaml.get("owner-character"))) throw new Conflict("Unresolved project authority: " + id); + reservation.beforeEffect(); plan(journal, id, source, store.projectFile(to.character(), projectId), before, to); journal.setProperty(id + ".from-account", from.account().toString()); journal.setProperty(id + ".from-character", from.character().toString()); @@ -155,20 +173,28 @@ List transfer(UUID death, String exactDecision, StudioIdentity.Owner fro journal.setProperty(id + ".revision", Long.toString(revision)); changed = true; } - if (changed) write(file, journal); + if (changed) { reservation.beforeEffect(); journal(file, journal); } for (String id : ids(journal)) { - if (!"moved".equals(journal.getProperty(id + ".state"))) apply(file, journal, id, guard, true); + if (!"moved".equals(journal.getProperty(id + ".state"))) { reservation.beforeEffect(); apply(file, journal, id, guard, true); } } return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList(); } List retainedReceipts(UUID death, String exactDecision, StudioIdentity.Owner from) throws IOException { + return retainedReceipts(death, exactDecision, from, false); + } + + List retainedReceiptsDuringPass(UUID death, String exactDecision, StudioIdentity.Owner from) throws IOException { + return retainedReceipts(death, exactDecision, from, true); + } + + private List retainedReceipts(UUID death, String exactDecision, StudioIdentity.Owner from, boolean usePass) throws IOException { Path file = root.resolve("ownership/estates").resolve(death + ".xml"); Properties journal = read(file); boolean changed = false; if (!journal.isEmpty() && !from.equals(owner(journal, "from"))) throw new Conflict("Estate journal identity differs from this death"); - requireUnreserved(file, journal, store.directory(from.character())); + Reservation reservation = requireUnreserved(file, journal, store.directory(from.character()), usePass); for (Path source : projects(store.directory(from.character()))) { String id = source.getFileName().toString().replace(".yml", ""); if ("moved".equals(journal.getProperty(id + ".state"))) @@ -182,7 +208,7 @@ List retainedReceipts(UUID death, String exactDecision, StudioIdentity.O removeEntry(journal, id); changed = true; } } - if (changed) write(file, journal); + if (changed) { reservation.beforeEffect(); journal(file, journal); } return ids(journal).stream().filter(id -> "moved".equals(journal.getProperty(id + ".state"))).toList(); } @@ -237,13 +263,13 @@ private void apply(Path file, Properties journal, String id, BooleanSupplier gua DurableFiles.write(target, Base64.getDecoder().decode(journal.getProperty(id + ".after")), false); } journal.setProperty(id + ".state", "published"); - write(file, journal); + journal(file, journal); if (removeSource) { if (!guard.getAsBoolean()) throw new IOException("Estate decision changed before source removal"); DurableFiles.delete(source); } journal.setProperty(id + ".state", "moved"); - write(file, journal); + journal(file, journal); } void requireWritable(UUID character) throws IOException { @@ -252,18 +278,75 @@ void requireWritable(UUID character) throws IOException { } // Copies at either end of an unfinished publication still belong to that operation. - private void requireUnreserved(Path current, Properties journal, Path folder) throws IOException { + private Reservation requireUnreserved(Path current, Properties journal, Path folder, boolean usePass) throws IOException { java.util.Set wanted = new java.util.HashSet<>(); for (String id : ids(journal)) if (!"moved".equals(journal.getProperty(id + ".state"))) wanted.add(id); for (Path source : projects(folder)) wanted.add(source.getFileName().toString().replace(".yml", "")); - for (String kind : List.of("migration", "estates")) for (Path file : files(root.resolve("ownership").resolve(kind))) { - Locks pending = locks(file); - if (file.equals(current)) continue; - for (String id : pending.projects()) - if (wanted.contains(id)) throw new Conflict("Project is still pending in another ownership journal: " + id); + boolean snapshot = usePass && pass; + Reservation reservation = new Reservation(current, wanted, !snapshot); + reservation.check(snapshot); + return reservation; + } + + // A pass may decide that an operation has nothing to do from its snapshot. Before the operation's + // first backup, journal write or publication, every journal is read again without the snapshot. + private final class Reservation { + private final Path current; private final java.util.Set wanted; private boolean exact; + Reservation(Path current, java.util.Set wanted, boolean exact) { this.current = current; this.wanted = wanted; this.exact = exact; } + + void beforeEffect() throws IOException { + if (exact) return; + exact = true; + check(false); + } + + private void check(boolean snapshot) throws IOException { + for (Path file : snapshot ? reservations() : listed()) { + Locks pending = snapshot ? reserved(file) : locks(file); + if (file.equals(current)) continue; + for (String id : pending.projects()) + if (wanted.contains(id)) throw new Conflict("Project is still pending in another ownership journal: " + id); + } } } + private List listed() throws IOException { + List listed = new ArrayList<>(); + for (String kind : List.of("migration", "estates")) listed.addAll(files(root.resolve("ownership").resolve(kind))); + return listed; + } + + void beginPass() { pass = true; passLocks = null; passWrites.clear(); } + + void endPass() { pass = false; passLocks = null; passWrites.clear(); } + + private java.util.Collection reservations() throws IOException { + List listed = passLocks != null ? new ArrayList<>(passLocks.keySet()) : listed(); + if (passLocks == null) { + var taken = new java.util.LinkedHashMap(); + for (Path file : listed) { + try { taken.put(file, locks(file)); } + catch (IOException ex) { taken.put(file, ex); } + } + passLocks = taken; + } + var all = new java.util.LinkedHashSet<>(listed); all.addAll(passWrites); + return all; + } + + private Locks reserved(Path file) throws IOException { + if (passWrites.contains(file)) return locks(file); + Object known = passLocks.get(file); + if (known instanceof Locks found) return found; + IOException failure = (IOException) known; + throw failure instanceof Conflict ? new Conflict(failure.getMessage()) : new IOException(failure.getMessage(), failure); + } + + private void journal(Path file, Properties journal) throws IOException { + if (pass) passWrites.add(file); + write(file, journal); + } + private Locks locks(Path file) throws IOException { byte[] bytes = Files.readAllBytes(file); String digest = hash(bytes); @@ -290,19 +373,33 @@ List holdings(UUID character) throws IOException { void requireEstateReady(StudioIdentity.Owner from) throws IOException { Path legacy = root.resolve("projects").resolve(from.account().toString()); if (projects(legacy).isEmpty()) return; - Properties journal = read(root.resolve("ownership/migration").resolve(from.account() + ".xml")); - if (journal.isEmpty() || !"true".equals(journal.getProperty("complete"))) + Readiness journal = readiness(root.resolve("ownership/migration").resolve(from.account() + ".xml")); + if (journal == null || !journal.complete()) throw new IOException("The account's reviewed legacy project migration is not complete"); - if (from.character().toString().equals(journal.getProperty("to-character"))) - for (String key : journal.stringPropertyNames()) if (key.startsWith("held.") - && !(key.equals("held.selected") && invalidSelectionOnly(journal, legacy))) - throw new Conflict("Legacy projects for this character remain held for staff"); + if (from.character().toString().equals(journal.toCharacter()) + && (journal.otherHeld() || journal.heldSelected() != null && !invalidSelectionOnly(journal, legacy))) + throw new Conflict("Legacy projects for this character remain held for staff"); + } + + // Memoize validated journal fields by exact bytes; the live legacy selection is checked separately. + private Readiness readiness(Path file) throws IOException { + if (!Files.exists(file)) return null; + byte[] bytes = Files.readAllBytes(file); + String digest = hash(bytes); + Readiness cached = readiness.get(file); + if (cached != null && cached.digest().equals(digest)) return cached; + Properties journal = read(file, bytes); + boolean otherHeld = journal.stringPropertyNames().stream().anyMatch(key -> key.startsWith("held.") && !key.equals("held.selected")); + cached = new Readiness(digest, "true".equals(journal.getProperty("complete")), journal.getProperty("to-character"), otherHeld, + journal.getProperty("held.selected"), journal.getProperty("selected")); + readiness.put(file, cached); + return cached; } - private boolean invalidSelectionOnly(Properties journal, Path legacy) throws IOException { - if (!"Invalid project selection".equals(journal.getProperty("held.selected")) || journal.getProperty("selected") == null) return false; + private boolean invalidSelectionOnly(Readiness journal, Path legacy) throws IOException { + if (!"Invalid project selection".equals(journal.heldSelected()) || journal.selected() == null) return false; byte[] pointer; - try { pointer = Base64.getDecoder().decode(journal.getProperty("selected")); } + try { pointer = Base64.getDecoder().decode(journal.selected()); } catch (IllegalArgumentException malformed) { return false; } Path source = legacy.resolve(StudioStore.SELECTED); if (!Files.isRegularFile(source) || !java.util.Arrays.equals(pointer, Files.readAllBytes(source))) return false; diff --git a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java index 56d7eb1..c907ece 100644 --- a/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java +++ b/src/main/java/net/tfminecraft/musicalinstruments/studio/StudioService.java @@ -133,25 +133,36 @@ private static YamlConfiguration loadSettings(InstrumentPlugin plugin) throws IO public StudioSettings settings() { return settings; } public StudioStation station() { return station; } + /** The full ownership check for one change; callers pass the token on rather than checking again. */ public StudioIdentity.Token token(Player player) { + StudioIdentity.Token token = session(player); + try { + ownership.requireWritable(token.owner().character()); + } + catch (IOException ex) { throw new IllegalArgumentException("Studio ownership needs recovery: " + ex.getMessage(), ex); } + return token; + } + + // Reads need only the current session: every change, menu and dialog first passes token(). + private StudioIdentity.Token session(Player player) { StudioIdentity.Token token = identity.current(player).orElseThrow( () -> new IllegalArgumentException("Choose an active character before using the recording studio")); if (!current(token, player)) throw new IllegalArgumentException("Your character session changed"); owners.put(token.owner().character(), token.owner()); if (!migrationAttempted) migrateOwnership(); - try { - ownership.requireWritable(token.owner().character()); - } - catch (IOException ex) { throw new IllegalArgumentException("Studio ownership needs recovery: " + ex.getMessage(), ex); } return token; } + // Keeps the recording refusal ahead of the ownership check, as requireIdle always did. + private StudioIdentity.Token idleToken(Player player) { + requireNotRecording(player); + return token(player); + } + public boolean current(StudioIdentity.Token token, Player player) { return token != null && token.owner().account().equals(player.getUniqueId()) && identity.current(token); } - private UUID owner(Player player) { return token(player).owner().character(); } - public void startOwnership(StudioMenu menu) { plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { migrateOwnership(); @@ -193,20 +204,25 @@ public Activity activity(Player player) { capture.notes.size(), countdown, false); } - public Project project(Player player) throws IOException { - Project project = projects.get(owner(player)); + public Project project(Player player) throws IOException { return project(session(player)); } + + private Project project(StudioIdentity.Token token) throws IOException { + UUID owner = token.owner().character(); + Project project = projects.get(owner); if (project == null) { - project = store.project(owner(player)).orElse(null); + project = store.project(owner).orElse(null); if (project != null) { - requireOwner(player, project); - projects.put(owner(player), project); + requireOwner(token, project); + projects.put(owner, project); } } return project; } - public Project requireProject(Player player) throws IOException { - Project project = project(player); + public Project requireProject(Player player) throws IOException { return requireProject(session(player)); } + + private Project requireProject(StudioIdentity.Token token) throws IOException { + Project project = project(token); if (project == null) { throw new IllegalArgumentException("Create a project with /music new first"); } @@ -214,79 +230,94 @@ public Project requireProject(Player player) throws IOException { } public void create(Player player, String title, boolean reset) throws IOException { - requireSaved(player); - Project previous = project(player); + StudioIdentity.Token token = idleToken(player); + requireSaved(player, token); + Project previous = project(token); // Without an active project to replace, a reset creates another project. - if (!reset || previous == null) requireProjectSpace(player); - persist(player); // A project whose last write failed is written before another replaces it. + if (!reset || previous == null) requireProjectSpace(token); + persist(player, token); // A project whose last write failed is written before another replaces it. UUID id = reset && previous != null ? previous.song().id() : UUID.randomUUID(); - save(player, new Project(new Song(id, player.getUniqueId(), player.getName(), title, List.of()), + save(player, token, new Project(new Song(id, player.getUniqueId(), player.getName(), title, List.of()), null, 100, true)); stopPreview(player); } - public void requireProjectSpace(Player player) throws IOException { - if (listProjects(player).size() >= MAX_PROJECTS) { + public void requireProjectSpace(Player player) throws IOException { requireProjectSpace(session(player)); } + + private void requireProjectSpace(StudioIdentity.Token token) throws IOException { + if (listProjects(token).size() >= MAX_PROJECTS) { throw new IllegalArgumentException("Your studio is full (36 projects). Delete an old project first: right-click its song in the library"); } } /** Loads only the requested project; a cached version may contain changes not yet written. */ - public Project project(Player player, UUID id) throws IOException { - Project current = projects.get(owner(player)); + public Project project(Player player, UUID id) throws IOException { return project(session(player), id); } + + private Project project(StudioIdentity.Token token, UUID id) throws IOException { + UUID owner = token.owner().character(); + Project current = projects.get(owner); if (current != null && current.song().id().equals(id)) return current; - Project loaded = store.project(owner(player), id); - requireOwner(player, loaded); + Project loaded = store.project(owner, id); + requireOwner(token, loaded); return loaded; } /** Deletes the project only if it still matches what the player confirmed. */ public void deleteProject(Player player, Project expected) throws IOException { - requireSaved(player); // A queued write would bring the deleted project back. - requireOwner(player, expected); + StudioIdentity.Token token = idleToken(player); + UUID owner = token.owner().character(); + requireSaved(player, token); // A queued write would bring the deleted project back. + requireOwner(token, expected); UUID id = expected.song().id(); // Summaries omit notes, mix and settings, so they cannot protect a stale confirmation. - if (!project(player, id).equals(expected)) { + if (!project(token, id).equals(expected)) { throw new IllegalArgumentException("The project changed. Open the delete confirmation again"); } - store.delete(owner(player), id); - Project current = projects.get(owner(player)); + store.delete(owner, id); + Project current = projects.get(owner); if (current != null && current.song().id().equals(id)) { - projects.remove(owner(player)); - unsaved.remove(owner(player)); + projects.remove(owner); + unsaved.remove(owner); stopPreview(player); } player.sendMessage("Deleted '" + expected.song().title() + "'. Published discs keep their music."); } - public List<ProjectSummary> listProjects(Player player) throws IOException { + public List<ProjectSummary> listProjects(Player player) throws IOException { return listProjects(session(player)); } + + private List<ProjectSummary> listProjects(StudioIdentity.Token token) throws IOException { + UUID owner = token.owner().character(); Map<UUID, ProjectSummary> all = new LinkedHashMap<>(); - for (ProjectSummary summary : store.summaries(owner(player))) all.put(summary.id(), summary); + for (ProjectSummary summary : store.summaries(owner)) all.put(summary.id(), summary); // An uncached project matches its files; a cached one may be newer than them. - Project active = projects.get(owner(player)); + Project active = projects.get(owner); if (active != null) all.put(active.song().id(), ProjectSummary.of(active)); return all.values().stream().sorted(ProjectSummary.ORDER).toList(); } public void selectProject(Player player, UUID id) throws IOException { - Project current = project(player); + StudioIdentity.Token token = token(player); + UUID owner = token.owner().character(); + Project current = project(token); if (current != null && current.song().id().equals(id)) return; - requireSaved(player); // The next project is read from its file, which a queued write could still change. - Project next = store.project(owner(player), id); - requireOwner(player, next); - persist(player); - store.select(owner(player), id); // Both projects already match their files. - projects.put(owner(player), next); + requireSaved(player, token); // The next project is read from its file, which a queued write could still change. + Project next = store.project(owner, id); + requireOwner(token, next); + persist(player, token); + store.select(owner, id); // Both projects already match their files. + projects.put(owner, next); stopPreview(player); } /** Writes the project before it becomes the active one. Used when creating or replacing a project. */ - public void save(Player player, Project project) throws IOException { - requireOwner(player, project); - requireSaved(player); - store.saveOwned(token(player).owner(), project); // Persist successfully before replacing the in-memory version. - projects.put(owner(player), project); - unsaved.remove(owner(player)); + public void save(Player player, Project project) throws IOException { save(player, token(player), project); } + + private void save(Player player, StudioIdentity.Token token, Project project) throws IOException { + requireOwner(token, project); + requireSaved(player, token); + store.saveOwned(token.owner(), project); // Persist successfully before replacing the in-memory version. + projects.put(token.owner().character(), project); + unsaved.remove(token.owner().character()); } /** @@ -294,44 +325,61 @@ public void save(Player player, Project project) throws IOException { * earlier write. If that write fails, the change stays in memory and is written again. */ public void edit(Player player, Project project) { - requireOwner(player, project); - requireIdle(player); - projects.put(owner(player), project); - unsaved.add(owner(player)); - queueSave(token(player).owner(), project); + StudioIdentity.Token token = token(player); + UUID owner = token.owner().character(); + requireOwner(token, project); + requireIdle(player, token); + projects.put(owner, project); + unsaved.add(owner); + queueSave(token.owner(), project); } - private void requireOwner(Player player, Project project) { - StudioIdentity.Owner ref = token(player).owner(); - try { store.requireAuthority(ref, project.song().id(), project.song().owner()); } + private void requireOwner(StudioIdentity.Token token, Project project) { + try { store.requireAuthority(token.owner(), project.song().id(), project.song().owner()); } catch (IOException ex) { throw new IllegalArgumentException("Could not verify your project's owner", ex); } } // Retries a write that failed. A project with no failed write already matches its files. - private void persist(Player player) throws IOException { - Project current = projects.get(owner(player)); - if (unsaved.contains(owner(player))) save(player, current); + private void persist(Player player, StudioIdentity.Token token) throws IOException { + UUID owner = token.owner().character(); + if (unsaved.contains(owner)) save(player, token, projects.get(owner)); } public void requireIdle(Player player) { + requireNotRecording(player); + requireIdle(player, session(player)); + } + + private void requireNotRecording(Player player) { if (recordings.containsKey(player.getUniqueId())) { throw new IllegalArgumentException("Stop the recording with /music stop first"); } - CompletableFuture<Void> pending = pendingSaves.get(owner(player)); - if (pending != null && pending.isDone()) completeSave(owner(player), pending); + } + + private void requireIdle(Player player, StudioIdentity.Token token) { + requireNotRecording(player); + UUID owner = token.owner().character(); + CompletableFuture<Void> pending = pendingSaves.get(owner); + if (pending != null && pending.isDone()) completeSave(owner, pending); } /** Also requires the project's files to be current, for changes that read or remove them. */ public void requireSaved(Player player) { - requireIdle(player); - if (pendingSaves.containsKey(owner(player))) { + requireNotRecording(player); + requireSaved(player, session(player)); + } + + private void requireSaved(Player player, StudioIdentity.Token token) { + requireIdle(player, token); + if (pendingSaves.containsKey(token.owner().character())) { throw new IllegalArgumentException("Your project is still saving. Try again shortly"); } } public void record(Player player, int slot) throws IOException { - requireIdle(player); - Project project = requireProject(player); + StudioIdentity.Token token = idleToken(player); + requireIdle(player, token); + Project project = requireProject(token); if (project.pending() != null) { throw new IllegalArgumentException("Use /music keep or /music discard for the previous take first"); } @@ -345,7 +393,7 @@ public void record(Player player, int slot) throws IOException { } stopPreview(player); capacity(); - recordings.put(player.getUniqueId(), new Capture(project, slot, clock + settings.countInTicks(), token(player))); + recordings.put(player.getUniqueId(), new Capture(project, slot, clock + settings.countInTicks(), token)); player.sendMessage("Recording starts in " + settings.countInTicks() / 20 + " seconds. Play with the hotbar, " + "or right-click your instrument for the keyboard. /music stop ends the take."); } @@ -389,8 +437,9 @@ public void stop(Player player) { } public void preview(Player player, boolean pending) throws IOException { - requireIdle(player); - Project project = requireProject(player); + StudioIdentity.Token token = idleToken(player); + requireIdle(player, token); + Project project = requireProject(token); Song song = project.song(); if (pending) { Track take = project.pending(); @@ -406,26 +455,27 @@ public void preview(Player player, boolean pending) throws IOException { if (!song.audible()) { throw new IllegalArgumentException("Every saved track is muted or silent. Unmute or raise a track first"); } - previewSong(player, song); + previewSong(player, token, song); player.sendMessage(pending ? "Previewing the new take with the other tracks." : "Previewing the saved mix."); } public void previewTrack(Player player, int slot) throws IOException { - requireIdle(player); - Song song = requireProject(player).song(); + StudioIdentity.Token token = idleToken(player); + requireIdle(player, token); + Song song = requireProject(token).song(); Track track = song.track(slot); if (track == null) throw new IllegalArgumentException("Record and keep this track first"); if (track.muted()) throw new IllegalArgumentException("Unmute this track to listen to it"); Song single = new Song(song.id(), song.owner(), song.author(), song.title(), List.of(track)); if (!single.audible()) throw new IllegalArgumentException("Raise this track's volume to listen to it"); - previewSong(player, single); + previewSong(player, token, single); player.sendMessage("Listening to saved track " + slot + "."); } - private void previewSong(Player player, Song song) { + private void previewSong(Player player, StudioIdentity.Token token, Song song) { stopPreview(player); capacity(); - previews.put(player.getUniqueId(), new Preview(song, clock + 1, token(player))); + previews.put(player.getUniqueId(), new Preview(song, clock + 1, token)); } private void stopPreview(Player player) { @@ -450,26 +500,28 @@ private void requireBlank(Player player) { } public void publish(Player player) throws IOException { - requireIdle(player); + StudioIdentity.Token token = idleToken(player); + requireIdle(player, token); requireBlank(player); - Song edition = publishEdition(player); + Song edition = publishEdition(player, token); player.getInventory().setItemInMainHand(discs.disc(edition)); player.sendMessage("Published '" + edition.title() + "'. Existing discs will keep this edition."); } public ItemStack publishOnDisc(Player player, ItemStack disc) throws IOException { - requireIdle(player); + StudioIdentity.Token token = idleToken(player); + requireIdle(player, token); if (!discs.musicDisc(disc) || disc.getAmount() != 1) { throw new IllegalArgumentException("Pick up one music disc and place it on Publish full song"); } - Song edition = publishEdition(player); + Song edition = publishEdition(player, token); ItemStack result = discs.disc(edition, disc); player.sendMessage("Published '" + edition.title() + "' on your disc. Other copies keep their previous music."); return result; } - private Song publishEdition(Player player) throws IOException { - Project project = requireProject(player); + private Song publishEdition(Player player, StudioIdentity.Token token) throws IOException { + Project project = requireProject(token); if (project.pending() != null) { throw new IllegalArgumentException("Keep or discard your pending take first"); } @@ -521,9 +573,10 @@ public void prepareBlankFromInventory(Player player) { } public void publishFromInventory(Player player) throws IOException { - requireIdle(player); + StudioIdentity.Token token = idleToken(player); + requireIdle(player, token); DiscSlot slot = discSlot(player, true); // Check space before writing an edition or consuming a disc. - Song edition = publishEdition(player); + Song edition = publishEdition(player, token); slot.replace(discs.disc(edition)); player.sendMessage("Published '" + edition.title() + "' on a blank disc from your inventory."); } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioDialogWorkflowTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioDialogWorkflowTest.java index be91f00..61cc5b5 100644 --- a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioDialogWorkflowTest.java +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioDialogWorkflowTest.java @@ -239,4 +239,16 @@ public void close() { } } + @Test void eachDialogChecksOwnershipOnceWhenItOpens() throws Exception { + for (int dialog = 0; dialog < 4; dialog++) try (Harness h = new Harness()) { + clearInvocations(studio); + switch (dialog) { + case 0 -> dialogs.create(player, null); + case 1 -> dialogs.rename(player, null); + case 2 -> dialogs.reset(player, null); + default -> dialogs.delete(player, null, project.song().id()); + } + verify(studio, times(1)).token(player); + } + } } diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipCostTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipCostTest.java new file mode 100644 index 0000000..22c2ece --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipCostTest.java @@ -0,0 +1,143 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Each change reads the ownership journals once; reconciliation reads them once per pass. */ +class StudioOwnershipCostTest { + @TempDir Path root; + + private StudioOwnership spyOwnership(StudioService studio) throws ReflectiveOperationException { + var field = StudioService.class.getDeclaredField("ownership"); field.setAccessible(true); + StudioOwnership spy = spy((StudioOwnership) field.get(studio)); field.set(studio, spy); + return spy; + } + + private void drain(StudioOwnershipReviewTest.Rig rig) throws Exception { + rig.writer().submit(() -> {}).get(3, TimeUnit.SECONDS); rig.tick().run(); + } + + @Test void everyPublicChangeChecksOwnershipOnceAndReadsNeedOnlyTheSession() throws Exception { + var helper = new StudioOwnershipReviewTest(); helper.root = root; + UUID account = UUID.randomUUID(), character = UUID.randomUUID(); + var rig = helper.rig(account, character, new TestIdentity()); + var studio = rig.studio(); var player = rig.player(); + try { + var ownership = spyOwnership(studio); + studio.create(player, "First", false); + verify(ownership, times(1)).requireWritable(character); clearInvocations(ownership); + Project first = studio.requireProject(player); + studio.project(player); studio.listProjects(player); studio.project(player, first.song().id()); + studio.requireIdle(player); studio.requireSaved(player); studio.requireProjectSpace(player); + verify(ownership, never()).requireWritable(any()); + studio.edit(player, new Project(first.song(), null, 120, false)); + verify(ownership, times(1)).requireWritable(character); clearInvocations(ownership); drain(rig); + studio.create(player, "Second", false); Project second = studio.requireProject(player); + studio.selectProject(player, first.song().id()); + studio.save(player, new Project(studio.requireProject(player).song(), null, 90, false)); + studio.deleteProject(player, second); + studio.create(player, "Reset", true); + verify(ownership, times(5)).requireWritable(character); clearInvocations(ownership); + when(player.getInventory().getStorageContents()).thenReturn(new ItemStack[0]); + for (var refused : List.<org.junit.jupiter.api.function.Executable>of(() -> studio.preview(player, false), + () -> studio.previewTrack(player, 1), () -> studio.publish(player), () -> studio.publishFromInventory(player), + () -> studio.publishOnDisc(player, mock(ItemStack.class)))) assertThrows(IllegalArgumentException.class, refused); + verify(ownership, times(5)).requireWritable(character); clearInvocations(ownership); + studio.record(player, 1); verify(ownership, times(1)).requireWritable(character); + assertEquals("Stop the recording with /music stop first", + assertThrows(IllegalArgumentException.class, () -> studio.create(player, "Busy", false)).getMessage()); + verify(ownership, times(1)).requireWritable(character); + studio.stop(player); + } finally { studio.close(); } + } + + @Test void aLockStillRefusesEveryChangeBeforeItWritesWhileReadsContinue() throws Exception { + var helper = new StudioOwnershipReviewTest(); helper.root = root; + var rig = helper.rig(UUID.randomUUID(), UUID.randomUUID(), new TestIdentity()); + var studio = rig.studio(); var player = rig.player(); + try { + studio.create(player, "Kept", false); Project kept = studio.requireProject(player); + Path broken = root.resolve("ownership/estates/" + UUID.randomUUID() + ".xml"); + Files.createDirectories(broken.getParent()); Files.writeString(broken, "not XML"); + var before = helper.snapshot(); + for (var refused : List.<org.junit.jupiter.api.function.Executable>of(() -> studio.create(player, "New", false), + () -> studio.edit(player, new Project(kept.song(), null, 140, false)), () -> studio.selectProject(player, kept.song().id()), + () -> studio.save(player, kept), () -> studio.deleteProject(player, kept), () -> studio.record(player, 1), + () -> studio.preview(player, false), () -> studio.publish(player), () -> studio.token(player))) { + var error = assertThrows(IllegalArgumentException.class, refused); + assertTrue(error.getMessage().startsWith("Studio ownership needs recovery"), error.getMessage()); + } + assertEquals(before, helper.snapshot()); + assertEquals(kept, studio.requireProject(player)); + assertEquals(List.of(kept.song().id()), studio.listProjects(player).stream().map(ProjectSummary::id).toList()); + Files.delete(broken); studio.create(player, "Recovered", false); + } finally { studio.close(); } + } + + private UUID project(StudioStore store, StudioIdentity.Owner owner, String title) throws IOException { + var project = new Project(new Song(UUID.randomUUID(), owner.account(), "Author", title, List.of()), null, 100, false); + store.saveOwned(owner, project); return project.song().id(); + } + + @Test void aPassReadsOtherJournalsOnceToFindNoOpsAndRereadsThemAllBeforeAnyEffect() throws Exception { + var helper = new StudioOwnershipReviewTest(); helper.root = root; + var store = new StudioStore(root); var ownership = new StudioOwnership(store); + var a = helper.owner(); var b = helper.owner(); var c = helper.owner(); var x = helper.owner(); var y = helper.owner(); + project(store, x, "Earlier"); UUID earlier = UUID.randomUUID(); + ownership.transfer(earlier, StudioOwnershipTest.decision(earlier, "one", x, y), x, y, () -> true); + Path earlierFile = root.resolve("ownership/estates/" + earlier + ".xml"); byte[] completed = Files.readAllBytes(earlierFile); + UUID id = project(store, a, "Interrupted"); project(store, b, "Recipient's own"); + var h = helper.owner(); var i = helper.owner(); project(store, h, "Settled"); UUID settled = UUID.randomUUID(); + ownership.transfer(settled, StudioOwnershipTest.decision(settled, "one", h, i), h, i, () -> true); + ownership.beginPass(); + try { + UUID first = UUID.randomUUID(); int[] checks = {0}; + // Publication succeeds, then the decision changes before the source is removed. + assertThrows(IOException.class, () -> ownership.transferDuringPass(first, StudioOwnershipTest.decision(first, "one", a, b), a, b, () -> ++checks[0] <= 2)); + assertTrue(Files.exists(store.projectFile(b.character(), id))); + var before = helper.snapshot(); UUID second = UUID.randomUUID(); + var error = assertThrows(StudioOwnership.Conflict.class, + () -> ownership.transferDuringPass(second, StudioOwnershipTest.decision(second, "one", b, c), b, c, () -> true)); + assertTrue(error.getMessage().contains("pending in another ownership journal"), error.getMessage()); + assertEquals(before, helper.snapshot()); + // An outside edit during the pass: a settlement with nothing to do uses the snapshot, + // but the first backup, journal write or publication reads every journal again. + Files.writeString(earlierFile, "not XML"); + assertEquals(1, ownership.transferDuringPass(settled, StudioOwnershipTest.decision(settled, "one", h, i), h, i, () -> true).size()); + UUID third = UUID.randomUUID(); var d = helper.owner(); var e = helper.owner(); project(store, d, "Unrelated"); + var unrelated = helper.snapshot(); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transferDuringPass(third, StudioOwnershipTest.decision(third, "one", d, e), d, e, () -> true)); + assertEquals(unrelated, helper.snapshot()); + Files.write(earlierFile, completed); + assertEquals(1, ownership.transferDuringPass(third, StudioOwnershipTest.decision(third, "one", d, e), d, e, () -> true).size()); + Files.writeString(earlierFile, "not XML"); + } finally { ownership.endPass(); } + var f = helper.owner(); var g = helper.owner(); project(store, f, "Held"); var before = helper.snapshot(); + UUID fourth = UUID.randomUUID(); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(fourth, StudioOwnershipTest.decision(fourth, "one", f, g), f, g, () -> true)); + ownership.beginPass(); + try { + for (int attempt = 0; attempt < 2; attempt++) + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transfer(fourth, StudioOwnershipTest.decision(fourth, "one", f, g), f, g, () -> true)); + } finally { ownership.endPass(); } + assertEquals(before, helper.snapshot()); + Files.write(earlierFile, completed); Path folder = root.resolve("ownership/estates/unreadable.xml"); Files.createDirectories(folder); + ownership.beginPass(); + try { + var failure = assertThrows(IOException.class, () -> ownership.transfer(fourth, StudioOwnershipTest.decision(fourth, "one", f, g), f, g, () -> true)); + assertFalse(failure instanceof StudioOwnership.Conflict); + } finally { ownership.endPass(); } + Files.delete(folder); + assertEquals(1, ownership.transfer(fourth, StudioOwnershipTest.decision(fourth, "one", f, g), f, g, () -> true).size()); + } +} diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipPassTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipPassTest.java new file mode 100644 index 0000000..88ab7b0 --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioOwnershipPassTest.java @@ -0,0 +1,223 @@ +package net.tfminecraft.musicalinstruments.studio; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import net.tfminecraft.tlibs.character.estate.Estates; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Exact-byte readiness and lexical no-effect reconciliation boundaries. */ +class StudioOwnershipPassTest { + @TempDir Path root; + + private StudioIdentity.Owner owner() { + return new StudioIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + } + + private Path header(UUID death, StudioIdentity.Owner from) throws IOException { + var values = new Properties(); + values.setProperty("format", "1"); values.setProperty("kind", "estate"); + values.setProperty("death", death.toString()); + values.setProperty("from-account", from.account().toString()); + values.setProperty("from-character", from.character().toString()); + Path file = root.resolve("ownership/estates/" + death + ".xml"); + StudioOwnership.write(file, values); + return file; + } + + @Test void completedNoEffectPassReadsLinearBytesAndDirectCallsAndLaterPassesReadFresh() throws Exception { + var store = new StudioStore(root); var ownership = new StudioOwnership(store); + var from = owner(); var to = owner(); + List<UUID> deaths = new ArrayList<>(); + for (int index = 0; index < 12; index++) { UUID death = UUID.randomUUID(); deaths.add(death); header(death, from); } + var reads = new AtomicInteger(); + try (var disk = mockStatic(Files.class, call -> { + if (call.getMethod().getName().equals("readAllBytes")) reads.incrementAndGet(); + return call.callRealMethod(); + })) { + ownership.beginPass(); + try { + for (UUID death : deaths) assertEquals(List.of(), ownership.retainedReceiptsDuringPass(death, + StudioOwnershipTest.decision(death, "one waiting", from, to), from)); + assertEquals(24, reads.get(), "one current header per decision plus one whole reservation snapshot"); + for (UUID death : deaths) ownership.retainedReceiptsDuringPass(death, + StudioOwnershipTest.decision(death, "one waiting", from, to), from); + assertEquals(36, reads.get(), "same pass rereads each current header without another all-journal scan"); + } finally { ownership.endPass(); } + reads.set(0); + ownership.retainedReceipts(deaths.getFirst(), StudioOwnershipTest.decision(deaths.getFirst(), "one waiting", from, to), from); + assertEquals(13, reads.get(), "direct settlement never borrows the ended pass snapshot"); + reads.set(0); + ownership.retainedReceiptsDuringPass(deaths.getFirst(), StudioOwnershipTest.decision(deaths.getFirst(), "one waiting", from, to), from); + assertEquals(13, reads.get(), "a pass operation outside its lexical pass falls back to full current validation"); + reads.set(0); + ownership.beginPass(); + try { + ownership.retainedReceiptsDuringPass(deaths.getFirst(), StudioOwnershipTest.decision(deaths.getFirst(), "one waiting", from, to), from); + assertEquals(13, reads.get(), "a new pass captures fresh bytes"); + reads.set(0); ownership.requireWritable(from.character()); + assertEquals(12, reads.get(), "live actions scan even while a no-effect pass exists"); + reads.set(0); + ownership.retainedReceipts(deaths.getFirst(), StudioOwnershipTest.decision(deaths.getFirst(), "one waiting", from, to), from); + assertEquals(13, reads.get(), "direct operations also scan inside an active reconciliation pass"); + } finally { ownership.endPass(); } + } + } + + @Test void newMalformedJournalIsCheckedBeforeFirstBackupAndDirectSettlement() throws Exception { + var store = new StudioStore(root); var ownership = new StudioOwnership(store); + var from = owner(); var to = owner(); UUID existing = UUID.randomUUID(); header(existing, from); + ownership.beginPass(); + try { + ownership.retainedReceiptsDuringPass(existing, StudioOwnershipTest.decision(existing, "one waiting", from, to), from); + Path broken = root.resolve("ownership/estates/new-unattributed.xml"); Files.writeString(broken, "not XML"); + var project = new Project(new Song(UUID.randomUUID(), from.account(), "Author", "Kept", List.of()), null, 100, false); + store.saveOwned(from, project); + var helper = new StudioOwnershipReviewTest(); helper.root = root; var before = helper.snapshot(); + UUID next = UUID.randomUUID(); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.transferDuringPass(next, + StudioOwnershipTest.decision(next, "one", from, to), from, to, () -> true)); + assertEquals(before, helper.snapshot(), "refusal precedes backups, journal publication and owner changes"); + } finally { ownership.endPass(); } + var studio = mock(StudioService.class); when(studio.quiescent(org.mockito.ArgumentMatchers.any())).thenReturn(true); + var support = new StudioEstateTest(); var decision = support.decision(net.tfminecraft.tlibs.character.estate.Recipient.FROZEN, 1); + var estate = new CharacterStudioEstate(support.plugin(), studio, ownership); + try (var estates = mockStatic(Estates.class)) { + estates.when(() -> Estates.guardHolds(decision)).thenReturn(true); + assertEquals(net.tfminecraft.tlibs.character.estate.Outcome.Kind.BLOCKED, estate.settle(decision).kind()); + } + } + + @Test void passFailuresStayFreshAndKeepConflictVersusIoClassification() throws Exception { + var ownership = new StudioOwnership(new StudioStore(root)); var from = owner(); var to = owner(); + Path bad = root.resolve("ownership/estates/bad.xml"); Files.createDirectories(bad.getParent()); Files.writeString(bad, "bad XML"); + for (boolean directory : List.of(false, true)) { + if (directory) { Files.delete(bad); Files.createDirectory(bad); } + ownership.beginPass(); + try { + List<IOException> failures = new ArrayList<>(); + for (int index = 0; index < 2; index++) { + UUID death = UUID.randomUUID(); failures.add(assertThrows(IOException.class, + () -> ownership.retainedReceiptsDuringPass(death, StudioOwnershipTest.decision(death, "one waiting", from, to), from))); + } + assertNotSame(failures.getFirst(), failures.getLast()); + assertEquals(!directory, failures.getFirst() instanceof StudioOwnership.Conflict); + } finally { ownership.endPass(); } + } + Files.delete(bad); + ownership.beginPass(); + try { UUID death = UUID.randomUUID(); assertEquals(List.of(), ownership.retainedReceiptsDuringPass(death, + StudioOwnershipTest.decision(death, "one waiting", from, to), from)); } + finally { ownership.endPass(); } + } + + private record Legacy(StudioOwnership ownership, StudioIdentity.Owner owner, Path journal, Path folder) {} + + private Legacy legacy(boolean invalidSelection) throws Exception { + var store = new StudioStore(root); var owner = owner(); + var project = new Project(new Song(UUID.randomUUID(), owner.account(), "Author", "Legacy", List.of()), null, 100, false); + store.save(project); + Path folder = root.resolve("projects/" + owner.account()); Files.createDirectories(folder.getParent()); + Files.move(store.directory(owner.account()), folder); + if (invalidSelection) Files.writeString(folder.resolve("selected"), "invalid pointer"); + var identity = new TestIdentity(); identity.defaultHeirs = false; identity.heirs.put(owner.account(), owner); + var ownership = new StudioOwnership(store); ownership.migrate(identity); + return new Legacy(ownership, owner, root.resolve("ownership/migration/" + owner.account() + ".xml"), folder); + } + + @Test void readinessHashesEveryCallAndOnlyMemoizesSuccessfulExactBodyValidation() throws Exception { + var fixture = legacy(false); byte[] original = Files.readAllBytes(fixture.journal()); + var reads = new AtomicInteger(); var parses = new AtomicInteger(); + try (var disk = mockStatic(Files.class, call -> { + if (call.getMethod().getName().equals("readAllBytes") && fixture.journal().equals(call.getArgument(0))) reads.incrementAndGet(); + return call.callRealMethod(); + }); var yaml = mockStatic(RawYaml.class, call -> { + if (call.getMethod().getName().equals("read")) parses.incrementAndGet(); + return call.callRealMethod(); + })) { + fixture.ownership().requireEstateReady(fixture.owner()); assertTrue(parses.get() > 0); + parses.set(0); fixture.ownership().requireEstateReady(fixture.owner()); + assertEquals(2, reads.get()); assertEquals(0, parses.get(), "unchanged successful body avoids payload decoding"); + byte[] changed = new String(original, java.nio.charset.StandardCharsets.UTF_8) + .replace("Studio ownership format 1", "Studio ownership format 2").getBytes(java.nio.charset.StandardCharsets.UTF_8); + assertEquals(original.length, changed.length); + var metadata = Files.readAttributes(fixture.journal(), java.nio.file.attribute.BasicFileAttributes.class); + Files.write(fixture.journal(), changed); Files.setLastModifiedTime(fixture.journal(), metadata.lastModifiedTime()); + fixture.ownership().requireEstateReady(fixture.owner()); assertTrue(parses.get() > 0, "same path/size/time does not grant validation"); + var malformed = StudioOwnership.read(fixture.journal()); + String id = StudioOwnership.ids(malformed).getFirst(); malformed.setProperty(id + ".after-hash", "broken"); + StudioOwnership.write(fixture.journal(), malformed); + for (int attempt = 0; attempt < 2; attempt++) assertThrows(StudioOwnership.Conflict.class, + () -> fixture.ownership().requireEstateReady(fixture.owner())); + Files.write(fixture.journal(), original); fixture.ownership().requireEstateReady(fixture.owner()); + Path other = fixture.journal().resolveSibling(UUID.randomUUID() + ".xml"); Files.write(other, original); + var foreign = new StudioIdentity.Owner(UUID.fromString(other.getFileName().toString().replace(".xml", "")), UUID.randomUUID()); + Path foreignFolder = root.resolve("projects/" + foreign.account()); Files.createDirectories(foreignFolder); + Files.copy(fixture.folder().resolve(id + ".yml"), foreignFolder.resolve(id + ".yml")); + assertThrows(StudioOwnership.Conflict.class, () -> fixture.ownership().requireEstateReady(foreign)); + } + } + + @Test void cachedInvalidSelectionExemptionStillReadsTheLiveLegacyPointer() throws Exception { + var fixture = legacy(true); var ownership = fixture.ownership(); var owner = fixture.owner(); + ownership.requireEstateReady(owner); ownership.requireEstateReady(owner); + Path selected = fixture.folder().resolve("selected"); byte[] invalid = Files.readAllBytes(selected); + Files.writeString(selected, UUID.randomUUID().toString()); + assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireEstateReady(owner)); + Files.write(selected, invalid); ownership.requireEstateReady(owner); + Files.delete(selected); assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireEstateReady(owner)); + Files.createDirectory(selected); assertThrows(StudioOwnership.Conflict.class, () -> ownership.requireEstateReady(owner)); + ownership.requireEstateReady(new StudioIdentity.Owner(owner.account(), UUID.randomUUID())); + } + + @Test void reconcileAlwaysEndsItsPassEvenWhenTheDecisionProviderThrows() throws Exception { + var ownership = spy(new StudioOwnership(new StudioStore(root))); + var support = new StudioEstateTest(); + var estate = new CharacterStudioEstate(support.plugin(), mock(StudioService.class), ownership); + try (var estates = mockStatic(Estates.class)) { + estates.when(Estates::decided).thenThrow(new IllegalStateException("provider unavailable")); + assertThrows(IllegalStateException.class, estate::reconcile); + verify(ownership).beginPass(); verify(ownership).endPass(); + } + } + @Test void reconciliationEffectsUseThePassButReenteredPublicSettlementReadsCurrentJournals() throws Exception { + var support = new StudioEstateTest(); + var store = new StudioStore(root); var ownership = spy(new StudioOwnership(store)); + var studio = mock(StudioService.class); when(studio.quiescent(org.mockito.ArgumentMatchers.any())).thenReturn(true); + var decision = support.decision(net.tfminecraft.tlibs.character.estate.Recipient.character(support.to), 1); + var project = new Project(new Song(UUID.randomUUID(), support.from.ownerId(), "Author", "Moved", List.of()), null, 100, false); + store.saveOwned(new StudioIdentity.Owner(support.from.ownerId(), support.from.characterId()), project); + var estate = new CharacterStudioEstate(support.plugin(), studio, ownership); + var frozen = new net.tfminecraft.tlibs.character.estate.EstateDecision(decision.deathId(), decision.testator(), + decision.diedAt(), decision.decidedAt(), decision.heir(), decision.deathGuild(), decision.deathRealm(), + decision.money(), net.tfminecraft.tlibs.character.estate.Recipient.FROZEN, decision.guildLocks(), decision.letters(), 2); + try (var estates = mockStatic(Estates.class)) { + estates.when(Estates::decided).thenReturn(List.of(decision)); + estates.when(() -> Estates.guardHolds(decision)).thenReturn(true); + estates.when(() -> Estates.guardHolds(frozen)).thenReturn(true); + estates.when(() -> Estates.report(org.mockito.ArgumentMatchers.eq(decision.deathId()), + org.mockito.ArgumentMatchers.eq("studio"), org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.any())) + .thenAnswer(call -> { + Files.writeString(root.resolve("ownership/estates/unattributed.xml"), "bad XML"); + assertEquals(net.tfminecraft.tlibs.character.estate.Outcome.Kind.BLOCKED, estate.settle(frozen).kind()); + return true; + }); + estate.reconcile(); + verify(ownership).transferDuringPass(org.mockito.ArgumentMatchers.eq(decision.deathId()), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(ownership).endPass(); + assertFalse(Files.exists(store.projectFile(support.from.characterId(), project.song().id()))); + assertTrue(Files.exists(store.projectFile(support.to.characterId(), project.song().id()))); + } + } + +} diff --git a/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioShutdownHoldProbeTest.java b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioShutdownHoldProbeTest.java new file mode 100644 index 0000000..61bec5f --- /dev/null +++ b/src/test/java/net/tfminecraft/musicalinstruments/studio/StudioShutdownHoldProbeTest.java @@ -0,0 +1,36 @@ +package net.tfminecraft.musicalinstruments.studio; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** R3 probe: an edit admitted before an unrelated journal becomes unreadable, then a clean shutdown. */ +class StudioShutdownHoldProbeTest { + @TempDir java.nio.file.Path root; + + @Test void admittedEditSurvivesShutdownWhenAnUnrelatedJournalBreaksAfterAdmission() throws Exception { + var support = new StudioOwnershipReviewTest(); support.root = root; + var r = support.rig(UUID.randomUUID(), UUID.randomUUID(), new TestIdentity()); + r.studio().create(r.player(), "Before", false); + Project project = r.studio().requireProject(r.player()); + CountDownLatch gate = new CountDownLatch(1); + r.writer().submit(() -> { gate.await(5, TimeUnit.SECONDS); return null; }); + Song s = project.song(); + r.studio().edit(r.player(), new Project(new Song(s.id(), s.owner(), s.author(), "Admitted edit", s.tracks()), + project.pending(), project.bpm(), project.metronome())); + var bad = root.resolve("ownership/estates/unrelated.xml"); + Files.createDirectories(bad.getParent()); + Files.writeString(bad, "unreadable journal"); + gate.countDown(); + r.studio().close(); + String title = r.store().project(r.token().owner().character(), s.id()).song().title(); + System.out.println("R3-SHUTDOWN-PROBE persisted-title=" + title); + assertEquals("Admitted edit", title); + } +}