Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package pl.skidam.automodpack_core.modpack;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import pl.skidam.automodpack_core.Constants;
import pl.skidam.automodpack_core.config.Jsons;
import pl.skidam.automodpack_core.utils.ModpackContentTools;

public class GroupManager {

private static final Pattern INVALID_CHARS = Pattern.compile("[<>:\"/\\\\|?*\\x00-\\x1F]");
private static final Set<String> RESERVED_NAMES = Set.of(
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9");
private static final int MAX_NAME_LENGTH = 100;

private GroupManager() {}

// Group ids are used directly as directory names (see groupDirectory), so they're restricted to
// what both Windows and Unix accept as a valid, unambiguous filename.
public static void validateName(String name) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("Group name cannot be empty");
if (name.length() > MAX_NAME_LENGTH) throw new IllegalArgumentException("Group name cannot be longer than " + MAX_NAME_LENGTH + " characters");
if (!name.equals(name.strip())) throw new IllegalArgumentException("Group name cannot have leading or trailing whitespace");
if (name.equals(".") || name.equals("..")) throw new IllegalArgumentException("Group name cannot be \".\" or \"..\"");
if (name.endsWith(".")) throw new IllegalArgumentException("Group name cannot end with a dot");
if (INVALID_CHARS.matcher(name).find()) throw new IllegalArgumentException("Group name cannot contain any of: < > : \" / \\ | ? * or control characters");

String baseName = name.contains(".") ? name.substring(0, name.indexOf('.')) : name;
if (RESERVED_NAMES.contains(baseName.toUpperCase(Locale.ROOT))) {
throw new IllegalArgumentException("\"" + name + "\" is a reserved name and cannot be used as a group name");
}
}

public static Path groupDirectory(String groupId) {
return Constants.hostModpackDir.resolve(groupId);
}

public static void createGroupFolders(Path groupDirectory) throws IOException {
Files.createDirectories(groupDirectory.resolve("config"));
Files.createDirectories(groupDirectory.resolve("mods"));
Files.createDirectories(groupDirectory.resolve("resourcepacks"));
Files.createDirectories(groupDirectory.resolve("shaderpacks"));
}

public static void deleteGroupFolders(Path groupDirectory) throws IOException {
if (!Files.exists(groupDirectory)) return;
try (Stream<Path> paths = Files.walk(groupDirectory)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.delete(path);
}
}
}

public record GroupCounts(int mods, int resourcepacks, int shaders) {
public static final GroupCounts EMPTY = new GroupCounts(0, 0, 0);
}

// Per-type counts only exist in the generated manifest (Jsons.GroupDeclaration itself has no file
// lists), so this returns EMPTY until the modpack has been generated at least once.
public static GroupCounts countGroup(String groupId) {
Jsons.ModpackContentFields manifest = ModpackContentTools.read(Constants.hostModpackContentFile);
Jsons.ModpackContentFields.ModpackGroupFields group = manifest == null ? null : manifest.groups.get(groupId);
if (group == null) return GroupCounts.EMPTY;

int mods = 0, resourcepacks = 0, shaders = 0;
for (Jsons.ModpackContentFields.ModpackContentItem item : manifest.list) {
if (!group.files.contains(item.file)) continue;
switch (item.type) {
case "mod" -> mods++;
case "resourcepack" -> resourcepacks++;
case "shader" -> shaders++;
default -> {
}
}
}
return new GroupCounts(mods, resourcepacks, shaders);
}

public static List<String> listGroupFiles(String groupId, String type) {
Jsons.ModpackContentFields manifest = ModpackContentTools.read(Constants.hostModpackContentFile);
Jsons.ModpackContentFields.ModpackGroupFields group = manifest == null ? null : manifest.groups.get(groupId);
if (group == null) return List.of();

List<String> files = new ArrayList<>();
for (Jsons.ModpackContentFields.ModpackContentItem item : manifest.list) {
if (group.files.contains(item.file) && item.type.equals(type)) {
files.add(item.file);
}
}
files.sort(String.CASE_INSENSITIVE_ORDER);
return files;
}

public static boolean isRequired(String groupId) {
Jsons.GroupDeclaration declaration = Constants.serverConfig.groups.get(groupId);
return declaration != null && declaration.required;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package pl.skidam.automodpack_core.modpack;

import static org.junit.jupiter.api.Assertions.*;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import pl.skidam.automodpack_core.Constants;
import pl.skidam.automodpack_core.config.Jsons;
import pl.skidam.automodpack_core.utils.ModpackContentTools;

class GroupManagerTest {

@TempDir
Path testFilesDir;
Path originalContentFile;

@BeforeEach
void setUp() {
originalContentFile = Constants.hostModpackContentFile;
Constants.hostModpackContentFile = testFilesDir.resolve("automodpack-content.json");
}

@AfterEach
void tearDown() {
Constants.hostModpackContentFile = originalContentFile;
}

@Test
void acceptsOrdinaryNames() {
assertDoesNotThrow(() -> GroupManager.validateName("extras"));
assertDoesNotThrow(() -> GroupManager.validateName("Animated Entities"));
assertDoesNotThrow(() -> GroupManager.validateName("shader-pack_2"));
}

@Test
void rejectsBlankNames() {
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(""));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(" "));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(null));
}

@Test
void rejectsSurroundingWhitespaceAndDots() {
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(" extras"));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("extras "));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("extras."));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("."));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(".."));
}

@Test
void rejectsInvalidWindowsFilenameCharacters() {
for (String invalid : List.of("a<b", "a>b", "a:b", "a\"b", "a/b", "a\\b", "a|b", "a?b", "a*b")) {
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(invalid), invalid);
}
}

@Test
void rejectsReservedDeviceNamesCaseInsensitively() {
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("CON"));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("con"));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("Com3"));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("lpt9"));
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("NUL.txt"));
}

@Test
void rejectsNamesLongerThanLimit() {
assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("a".repeat(101)));
assertDoesNotThrow(() -> GroupManager.validateName("a".repeat(100)));
}

@Test
void createAndDeleteGroupFolders() throws IOException {
Path groupDir = testFilesDir.resolve("extras");
GroupManager.createGroupFolders(groupDir);

assertTrue(Files.isDirectory(groupDir.resolve("mods")));
assertTrue(Files.isDirectory(groupDir.resolve("resourcepacks")));
assertTrue(Files.isDirectory(groupDir.resolve("shaderpacks")));

Files.writeString(groupDir.resolve("mods/some-mod.jar"), "a");

GroupManager.deleteGroupFolders(groupDir);
assertFalse(Files.exists(groupDir));
}

@Test
void deleteGroupFoldersIsNoOpWhenMissing() {
assertDoesNotThrow(() -> GroupManager.deleteGroupFolders(testFilesDir.resolve("does-not-exist")));
}

@Test
void countGroupIsEmptyWithoutGeneratedManifest() {
assertEquals(GroupManager.GroupCounts.EMPTY, GroupManager.countGroup("extras"));
}

@Test
void countGroupTalliesByType() throws IOException {
writeManifest("extras", Set.of(
item("/mods/a.jar", "mod"),
item("/mods/b.jar", "mod"),
item("/resourcepacks/pack.zip", "resourcepack"),
item("/shaderpacks/shader.zip", "shader"),
item("/config/other.json", "config")),
Set.of("/mods/a.jar", "/mods/b.jar", "/resourcepacks/pack.zip", "/shaderpacks/shader.zip"));

assertEquals(new GroupManager.GroupCounts(2, 1, 1), GroupManager.countGroup("extras"));
}

@Test
void listGroupFilesFiltersByTypeAndSorts() throws IOException {
writeManifest("extras", Set.of(
item("/mods/zebra.jar", "mod"),
item("/mods/alpha.jar", "mod"),
item("/resourcepacks/pack.zip", "resourcepack")),
Set.of("/mods/zebra.jar", "/mods/alpha.jar", "/resourcepacks/pack.zip"));

assertEquals(List.of("/mods/alpha.jar", "/mods/zebra.jar"), GroupManager.listGroupFiles("extras", "mod"));
}

private static Jsons.ModpackContentFields.ModpackContentItem item(String file, String type) {
return new Jsons.ModpackContentFields.ModpackContentItem(file, "1", type, false, false, false, "sha1", null);
}

private void writeManifest(String groupId, Set<Jsons.ModpackContentFields.ModpackContentItem> items, Set<String> groupFiles) throws IOException {
Jsons.ModpackContentFields manifest = new Jsons.ModpackContentFields(items);
Jsons.ModpackContentFields.ModpackGroupFields group = new Jsons.ModpackContentFields.ModpackGroupFields();
group.files = groupFiles;
manifest.groups = Map.of(groupId, group);
ModpackContentTools.write(Constants.hostModpackContentFile, manifest);
}
}
3 changes: 3 additions & 0 deletions docs/commands/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@
- `/automodpack host fingerprint` - Get the [certificate fingerprint](../technicals/certificate) of the modpack host.
- `/automodpack host fingerprint dns <minecraft-hostname>` - Generate a DNSSEC record that lets clients authenticate the host's self-signed certificate automatically.
- `/automodpack host fingerprint share <minecraft-address>` - Generate plain and pinned copyable addresses without storing the fingerprint in Minecraft's server list.
- `/automodpack group` - List of modpack groups. `--full` for more details .
- `/automodpack group new "groupName"` - Create new group.
- `/automodpack group remove "groupName"` - Remove already existing group. `--yes` to delete immediately, without confirming.
- `/automodpack config reload` - Reload config files.
Loading