Skip to content

Commit 02149b0

Browse files
committed
Prevent item loss on oversized custom-enchant NBT and add safe validation guards and support
1 parent 7202621 commit 02149b0

6 files changed

Lines changed: 168 additions & 12 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,19 @@ transfer-xp-cost-enabled: true
4040
transfer-xp-cost-percent: 50.0
4141
require-special-book: false
4242
special-book-ids: []
43+
max-enchants-per-operation: 16
44+
max-operation-item-bytes: 24576
4345
```
4446
4547
`special-book-ids` accepts namespaced IDs (for example `dingus:blah`).
4648
If `special-book-ids` is empty, any non-vanilla namespaced marker is accepted (item model, plugin PDC key, or namespaced display name).
4749

50+
`max-enchants-per-operation` is a safety cap. If a source item has more enchantments than this value, the grindstone operation is blocked to avoid oversized/unstable enchant payload handling.
51+
`max-operation-item-bytes` is an additional safety cap on serialized item size (source/result/bonus). If any item involved exceeds this size, the operation is blocked to prevent unsafe payload handling.
52+
53+
XP-cost notes:
54+
- Vanilla enchantments use their estimated enchanting-table costs.
55+
- Custom enchantments are assigned a rough vanilla-equivalent cost from their level, using the highest matching vanilla cost as the baseline.
56+
4857
## Compatibility
4958
Designed for Paper 1.21.11.

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ plugins {
33
}
44

55
group = 'com.cevapi'
6-
version = '1.0.1'
6+
version = '1.0.2'
77

88
java {
99
toolchain {

src/main/java/com/cevapi/improvedgrindstone/GrindstoneBookPlugin.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ public final class GrindstoneBookPlugin extends JavaPlugin {
2121
private static final String CONFIG_TRANSFER_XP_COST_PERCENT_KEY = "transfer-xp-cost-percent";
2222
private static final String CONFIG_REQUIRE_SPECIAL_BOOK_KEY = "require-special-book";
2323
private static final String CONFIG_SPECIAL_BOOK_IDS_KEY = "special-book-ids";
24+
private static final String CONFIG_MAX_ENCHANTS_PER_OPERATION_KEY = "max-enchants-per-operation";
25+
private static final String CONFIG_MAX_OPERATION_ITEM_BYTES_KEY = "max-operation-item-bytes";
2426

2527
private boolean featureEnabled;
2628
private boolean captureCursed;
@@ -31,6 +33,8 @@ public final class GrindstoneBookPlugin extends JavaPlugin {
3133
private double transferXpCostPercent;
3234
private boolean requireSpecialBook;
3335
private Set<String> specialBookIds = new LinkedHashSet<>();
36+
private int maxEnchantsPerOperation;
37+
private int maxOperationItemBytes;
3438

3539
@Override
3640
public void onEnable() {
@@ -74,6 +78,9 @@ private void loadConfigValues() {
7478
.map(value -> value.toLowerCase(Locale.ROOT).trim())
7579
.filter(value -> !value.isEmpty())
7680
.collect(Collectors.toCollection(LinkedHashSet::new));
81+
maxEnchantsPerOperation = clampPositiveInt(config.getInt(CONFIG_MAX_ENCHANTS_PER_OPERATION_KEY, 16), 1, 200, 16);
82+
maxOperationItemBytes = clampPositiveInt(config.getInt(CONFIG_MAX_OPERATION_ITEM_BYTES_KEY, 24576), 1024, 262144,
83+
24576);
7784

7885
config.set(CONFIG_KEY, featureEnabled);
7986
config.set(CONFIG_CAPTURE_CURSED_KEY, captureCursed);
@@ -84,6 +91,8 @@ private void loadConfigValues() {
8491
config.set(CONFIG_TRANSFER_XP_COST_PERCENT_KEY, transferXpCostPercent);
8592
config.set(CONFIG_REQUIRE_SPECIAL_BOOK_KEY, requireSpecialBook);
8693
config.set(CONFIG_SPECIAL_BOOK_IDS_KEY, specialBookIds.stream().toList());
94+
config.set(CONFIG_MAX_ENCHANTS_PER_OPERATION_KEY, maxEnchantsPerOperation);
95+
config.set(CONFIG_MAX_OPERATION_ITEM_BYTES_KEY, maxOperationItemBytes);
8796
saveConfig();
8897
}
8998

@@ -171,13 +180,28 @@ public Set<String> getSpecialBookIds() {
171180
return specialBookIds;
172181
}
173182

183+
public int getMaxEnchantsPerOperation() {
184+
return maxEnchantsPerOperation;
185+
}
186+
187+
public int getMaxOperationItemBytes() {
188+
return maxOperationItemBytes;
189+
}
190+
174191
private double clampPercent(double percent) {
175192
if (Double.isNaN(percent) || Double.isInfinite(percent)) {
176193
return 50.0d;
177194
}
178195
return Math.max(0.0d, Math.min(100.0d, percent));
179196
}
180197

198+
private int clampPositiveInt(int value, int min, int max, int fallback) {
199+
if (value < min || value > max) {
200+
return fallback;
201+
}
202+
return value;
203+
}
204+
181205
@Override
182206
public void reloadConfig() {
183207
super.reloadConfig();

src/main/java/com/cevapi/improvedgrindstone/GrindstoneListener.java

Lines changed: 131 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.cevapi.improvedgrindstone;
22

3+
import java.io.ByteArrayOutputStream;
4+
import java.io.IOException;
35
import java.lang.reflect.Method;
46
import java.util.LinkedHashMap;
57
import java.util.Locale;
@@ -24,6 +26,7 @@
2426
import org.bukkit.inventory.meta.EnchantmentStorageMeta;
2527
import org.bukkit.inventory.meta.ItemMeta;
2628
import org.bukkit.persistence.PersistentDataContainer;
29+
import org.bukkit.util.io.BukkitObjectOutputStream;
2730

2831
public final class GrindstoneListener implements Listener {
2932
private static final Pattern NAMESPACED_ID_PATTERN = Pattern.compile("^[a-z0-9_.-]+:[a-z0-9/._-]+$");
@@ -40,7 +43,13 @@ public void onPrepareGrindstone(PrepareGrindstoneEvent event) {
4043
return;
4144
}
4245

43-
OperationContext context = resolveOperation(event.getInventory());
46+
OperationResolution resolution = resolveOperation(event.getInventory());
47+
if (resolution.blockedReason() != null) {
48+
event.setResult(null);
49+
return;
50+
}
51+
52+
OperationContext context = resolution.context();
4453
if (context != null) {
4554
event.setResult(context.result());
4655
}
@@ -74,7 +83,15 @@ public void onInventoryClick(InventoryClickEvent event) {
7483
return;
7584
}
7685

77-
OperationContext context = resolveOperation(inventory);
86+
OperationResolution resolution = resolveOperation(inventory);
87+
if (resolution.blockedReason() != null) {
88+
event.setCancelled(true);
89+
player.sendMessage(color("&c" + resolution.blockedReason()));
90+
player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 0.8f, 0.9f);
91+
return;
92+
}
93+
94+
OperationContext context = resolution.context();
7895
if (context == null) {
7996
return;
8097
}
@@ -126,38 +143,51 @@ public void onInventoryDrag(InventoryDragEvent event) {
126143
event.setCursor(removeOne(cursor));
127144
}
128145

129-
private OperationContext resolveOperation(GrindstoneInventory inventory) {
146+
private OperationResolution resolveOperation(GrindstoneInventory inventory) {
130147
ItemStack source = inventory.getItem(0);
131148
ItemStack secondSlot = inventory.getItem(1);
132149

133150
if (isEmpty(source) || isEmpty(secondSlot)) {
134-
return null;
151+
return OperationResolution.none();
135152
}
136153

137154
Map<Enchantment, Integer> enchantments = getEnchantmentsToStore(source);
138155
if (enchantments.isEmpty()) {
139-
return null;
156+
return OperationResolution.none();
140157
}
141158

142159
if (isValidBookForExtraction(secondSlot)) {
143160
ItemStack result = buildDisenchantedResult(source, enchantments);
144161
ItemStack bonus = buildEnchantedBook(enchantments);
162+
if (bonus == null) {
163+
return OperationResolution.blocked("Those enchantments cannot be stored safely in a book.");
164+
}
165+
166+
String validationError = validateOperation(source, result, bonus, enchantments.size());
167+
if (validationError != null) {
168+
return OperationResolution.blocked(validationError);
169+
}
170+
145171
int xpCost = plugin.isBookXpCostEnabled()
146172
? calculateXpCost(enchantments, plugin.getBookXpCostPercent())
147173
: 0;
148-
return new OperationContext(result, bonus, xpCost, "extract enchantments into a book");
174+
return OperationResolution.ready(new OperationContext(result, bonus, xpCost, "extract enchantments into a book"));
149175
}
150176

151177
if (!plugin.isTransferEnabled() || !isTransferCandidate(source, secondSlot)) {
152-
return null;
178+
return OperationResolution.none();
153179
}
154180

155181
ItemStack result = buildTransferredItem(secondSlot, enchantments);
156182
ItemStack bonus = buildDisenchantedResult(source, enchantments);
183+
String validationError = validateOperation(source, result, bonus, enchantments.size());
184+
if (validationError != null) {
185+
return OperationResolution.blocked(validationError);
186+
}
157187
int xpCost = plugin.isTransferXpCostEnabled()
158188
? calculateXpCost(enchantments, plugin.getTransferXpCostPercent())
159189
: 0;
160-
return new OperationContext(result, bonus, xpCost, "transfer enchantments to another item");
190+
return OperationResolution.ready(new OperationContext(result, bonus, xpCost, "transfer enchantments to another item"));
161191
}
162192

163193
private boolean handleBookSlotClick(InventoryClickEvent event, GrindstoneInventory inventory) {
@@ -223,11 +253,14 @@ private ItemStack buildEnchantedBook(Map<Enchantment, Integer> enchantments) {
223253
ItemStack enchantedBook = new ItemStack(Material.ENCHANTED_BOOK);
224254
EnchantmentStorageMeta meta = (EnchantmentStorageMeta) enchantedBook.getItemMeta();
225255
if (meta == null) {
226-
return enchantedBook;
256+
return null;
227257
}
228258

229259
for (Map.Entry<Enchantment, Integer> entry : enchantments.entrySet()) {
230-
meta.addStoredEnchant(entry.getKey(), entry.getValue(), true);
260+
boolean added = meta.addStoredEnchant(entry.getKey(), entry.getValue(), true);
261+
if (!added) {
262+
return null;
263+
}
231264
}
232265

233266
enchantedBook.setItemMeta(meta);
@@ -275,6 +308,38 @@ private Map<Enchantment, Integer> getEnchantmentsToStore(ItemStack input) {
275308
return filtered;
276309
}
277310

311+
private String validateOperation(ItemStack source, ItemStack result, ItemStack bonus, int enchantCount) {
312+
if (enchantCount > plugin.getMaxEnchantsPerOperation()) {
313+
return "This item has too many enchantments (" + enchantCount + "/" + plugin.getMaxEnchantsPerOperation()
314+
+ ") to process safely.";
315+
}
316+
317+
int maxBytes = plugin.getMaxOperationItemBytes();
318+
if (estimateSerializedBytes(source) > maxBytes
319+
|| estimateSerializedBytes(result) > maxBytes
320+
|| estimateSerializedBytes(bonus) > maxBytes) {
321+
return "This item's NBT payload is too large to process safely in the grindstone.";
322+
}
323+
324+
return null;
325+
}
326+
327+
private int estimateSerializedBytes(ItemStack item) {
328+
if (isEmpty(item)) {
329+
return 0;
330+
}
331+
332+
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
333+
BukkitObjectOutputStream out = new BukkitObjectOutputStream(baos)) {
334+
out.writeObject(item);
335+
out.flush();
336+
return baos.size();
337+
} catch (IOException ignored) {
338+
// If serialization fails, treat as unsafe rather than risking item loss.
339+
return Integer.MAX_VALUE;
340+
}
341+
}
342+
278343
private int calculateXpCost(Map<Enchantment, Integer> enchantments, double percent) {
279344
if (percent <= 0.0d) {
280345
return 0;
@@ -299,6 +364,10 @@ private int estimateEnchantingTableCost(Map<Enchantment, Integer> enchantments)
299364
}
300365

301366
private int estimateEnchantingTableCost(Enchantment enchantment, int level) {
367+
if (!isVanillaEnchantment(enchantment)) {
368+
return estimateCustomEquivalentCost(level);
369+
}
370+
302371
Integer min = invokeIntWithArg(enchantment, "getMinModifiedCost", level);
303372
Integer max = invokeIntWithArg(enchantment, "getMaxModifiedCost", level);
304373

@@ -326,6 +395,44 @@ private int estimateEnchantingTableCost(Enchantment enchantment, int level) {
326395
return Math.max(1, 5 + (level * 3));
327396
}
328397

398+
private int estimateCustomEquivalentCost(int level) {
399+
int safeLevel = Math.max(1, level);
400+
int highest = 0;
401+
int highestAtMaxVanilla = 0;
402+
int highestVanillaLevel = 1;
403+
404+
for (Enchantment enchantment : Enchantment.values()) {
405+
if (!isVanillaEnchantment(enchantment)) {
406+
continue;
407+
}
408+
409+
int maxLevel = Math.max(1, enchantment.getMaxLevel());
410+
int boundedLevel = Math.min(safeLevel, maxLevel);
411+
int cost = estimateEnchantingTableCost(enchantment, boundedLevel);
412+
if (cost > highest) {
413+
highest = cost;
414+
}
415+
416+
int maxLevelCost = estimateEnchantingTableCost(enchantment, maxLevel);
417+
if (maxLevelCost > highestAtMaxVanilla) {
418+
highestAtMaxVanilla = maxLevelCost;
419+
highestVanillaLevel = maxLevel;
420+
}
421+
}
422+
423+
if (highest > 0) {
424+
return highest;
425+
}
426+
427+
// Fallback path for atypical environments: keep costs increasing for very high levels.
428+
int extrapolated = highestAtMaxVanilla + Math.max(0, safeLevel - highestVanillaLevel) * 6;
429+
return Math.max(1, extrapolated);
430+
}
431+
432+
private boolean isVanillaEnchantment(Enchantment enchantment) {
433+
return enchantment.getKey().getNamespace().equalsIgnoreCase("minecraft");
434+
}
435+
329436
private Integer invokeIntWithArg(Object target, String methodName, int arg) {
330437
try {
331438
Method method = target.getClass().getMethod(methodName, int.class);
@@ -555,4 +662,18 @@ private String color(String message) {
555662

556663
private record OperationContext(ItemStack result, ItemStack bonusItem, int xpCostLevels, String xpReason) {
557664
}
665+
666+
private record OperationResolution(OperationContext context, String blockedReason) {
667+
private static OperationResolution none() {
668+
return new OperationResolution(null, null);
669+
}
670+
671+
private static OperationResolution ready(OperationContext context) {
672+
return new OperationResolution(context, null);
673+
}
674+
675+
private static OperationResolution blocked(String blockedReason) {
676+
return new OperationResolution(null, blockedReason);
677+
}
678+
}
558679
}

src/main/resources/config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ transfer-xp-cost-enabled: true
77
transfer-xp-cost-percent: 50.0
88
require-special-book: false
99
special-book-ids: []
10+
max-enchants-per-operation: 16
11+
max-operation-item-bytes: 24576

src/main/resources/plugin.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: ImprovedGrindstone
22
main: com.cevapi.improvedgrindstone.GrindstoneBookPlugin
3-
version: 1.0.1
3+
version: 1.0.2
44
api-version: 1.21
55
description: Allows grindstone disenchantments to be stored directly into enchanted books.
66
author: CevAPI

0 commit comments

Comments
 (0)