From 6dfcabdecf68a7d67437ef2d64b5f54faf38b46d Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:45:22 -0700 Subject: [PATCH] Add /packmule pack set for Leaf Juice, with a download check before any change (0.2.1) D189: Leaf Juice publishes each pack build at a new address on the join host and hands it to Packmule. pack set takes https only, with the SHA-1 as the file name. It downloads the file and checks the hash before writing config.yml (comments kept) and reloading. Any refusal keeps the old address. pack set and the new describe answer --json in the Signalbox format, which is shaded in and relocated. 47 unit tests pass. Not run on a server with bots, or called by Leaf Juice yet. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 2 +- README.md | 9 ++ build.gradle.kts | 16 ++- docs/decisions.md | 1 + docs/releases/v0.2.1.md | 14 +++ .../java/com/extantape/packmule/Packmule.java | 56 +++++++++++ .../extantape/packmule/command/PackSet.java | 67 +++++++++++++ .../packmule/command/PackmuleCommand.java | 74 +++++++++++++- src/main/resources/paper-plugin.yml | 3 + .../packmule/check/LinkCheckerTest.java | 10 ++ .../packmule/command/PackSetTest.java | 97 +++++++++++++++++++ 11 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 docs/releases/v0.2.1.md create mode 100644 src/main/java/com/extantape/packmule/command/PackSet.java create mode 100644 src/test/java/com/extantape/packmule/command/PackSetTest.java diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08069de..5ddfd9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,4 +35,4 @@ jobs: - name: Release env: GH_TOKEN: ${{ github.token }} - run: gh release create "$GITHUB_REF_NAME" build/libs/Packmule-*.jar --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md" + run: gh release create "$GITHUB_REF_NAME" "build/libs/Packmule-${GITHUB_REF_NAME#v}.jar" --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md" diff --git a/README.md b/README.md index 35c6767..fa712e4 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,15 @@ If `config.yml` has a mistake, Packmule says what it is. On `/packmule reload` i | `/packmule recheck` | `packmule.recheck` | Re-test every link and re-read every hash file | | `/packmule reload` | `packmule.reload` | Read `config.yml` again, then re-check | | `/packmule status [player]` | `packmule.status` | Each pack's state, each world's list, or what one player has | +| `/packmule pack set ` | `packmule.pack.set` | Point a pack at a new per-build address. Packmule downloads it first and checks its SHA-1 against the file name. Only a pass is written to `config.yml`, with its comments kept, and then the config is reloaded. Anything else leaves the old address. | +| `/packmule describe` | `packmule.status` | Lists the commands tools can use | + +`pack set` and `describe` also answer tools such as Leaf Juice. Add `--json ` at the end and the answer is one line in the [Signalbox](https://github.com/ExtantApe/Signalbox) format. A pass answers `{"ok":true,"pack":"main","sha1":"…","check":"passed"}`. A refusal answers `ok:false` with a code: +- `bad-argument` for an address that isn't https, or a file name that isn't the SHA-1; +- `not-found` for an unknown pack; +- `check-failed` when the download fails or its SHA-1 doesn't match; +- `busy` while another change is being checked; +- `config-problem` when `config.yml` can't be read or changed. `packmule.notify` (default op) shows dead-link and failed-download warnings in chat. diff --git a/build.gradle.kts b/build.gradle.kts index a33d294..4864332 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,10 @@ plugins { java + id("com.gradleup.shadow") version "9.6.1" } group = "com.extantape" -version = "0.2.0" +version = "0.2.1" java { toolchain.languageVersion.set(JavaLanguageVersion.of(25)) @@ -12,6 +13,7 @@ java { repositories { mavenCentral() maven("https://repo.papermc.io/repository/maven-public/") + maven("https://jitpack.io") } // Built against the oldest supported version, 26.1, so nothing newer slips in. Tested on 26.1, 26.2 and 26.3. @@ -19,6 +21,7 @@ val paperApi = "io.papermc.paper:paper-api:26.1.2.build.74-stable" dependencies { compileOnly(paperApi) + implementation("com.github.ExtantApe:Signalbox:v0.3.0") testImplementation(paperApi) testImplementation(platform("org.junit:junit-bom:5.11.4")) @@ -43,5 +46,16 @@ tasks.test { tasks.jar { archiveBaseName.set("Packmule") + archiveClassifier.set("plain") from("LICENSE") } + +// Signalbox, for the format-1 commands tools such as Leaf Juice use, goes inside the jar, relocated, so it can never +// clash with another plugin's copy. +tasks.shadowJar { + archiveBaseName.set("Packmule") + archiveClassifier.set("") + from("LICENSE") + relocate("com.extantape.signalbox", "com.extantape.packmule.libs.signalbox") +} +tasks.build { dependsOn(tasks.shadowJar) } diff --git a/docs/decisions.md b/docs/decisions.md index f6ca4bc..b72a7cb 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -28,3 +28,4 @@ Owner decisions that shape Butler, with the date each was made. Change these onl - **Test hygiene:** one test server at a time, deleted after its run. - **Going public:** the owner said yes to making this repo public at its first release, after the first-public-push audit passed. - **Name (2026-11-08, owner):** ResourcePackButler is renamed Packmule, one word in every place: repo, plugin name, jar, package and prose. Released as 0.2.0. Existing servers get their `plugins/ResourcePackButler/` folder copied on first start (never overwritten), and the old commands and permissions keep working for one release with a log warning. Pack IDs keep the old `resourcepackbutler:pack:` namespace for good, because clients see them. +- **pack set for Leaf Juice (2026-11-14, D189):** each pack build is published at a new unguessable path on the join host, and Leaf Juice hands Packmule the new link through `/packmule pack set --json `, as the Leaf Juice App session proposed and Helm assigned (K644). It takes https only, with the SHA-1 as the file name. Packmule downloads the file and checks the SHA-1 before writing `config.yml` (comments kept) and reloading, and a failure keeps the old address. Released as 0.2.1, so the old ResourcePackButler names stay for this 0.2.x release as promised. diff --git a/docs/releases/v0.2.1.md b/docs/releases/v0.2.1.md new file mode 100644 index 0000000..bcb6702 --- /dev/null +++ b/docs/releases/v0.2.1.md @@ -0,0 +1,14 @@ +Leaf Juice can now give Packmule each new pack build (D189). + +## Added + +- **`/packmule pack set `** points a pack at a new per-build address. The address must be https, with the pack's SHA-1 as the file name. Packmule downloads the file first and checks that its SHA-1 matches. Only then does it write the new address to `config.yml`, keeping the file's comments, and reload. A refusal leaves the old address and the config untouched. Permission `packmule.pack.set`, op by default. +- **`--json `** on `pack set` and the new `/packmule describe` gives one-line answers in the Signalbox format, for Leaf Juice. A pass answers `{"ok":true,"pack":"main","sha1":"…","check":"passed"}`. Refusals carry a code: `bad-argument`, `not-found`, `check-failed`, `busy` or `config-problem`. + +## Upgrading + +Replace the jar and restart. Nothing else changes. The ResourcePackButler names still work in 0.2.x. + +## Tested + +47 unit tests pass. They cover refusing every address that isn't an https per-build one, refusing an unknown pack, the config edit keeping comments and other packs, a file that doesn't match its name failing the check, and `describe`. Not yet run on a server with bots, or called by Leaf Juice. diff --git a/src/main/java/com/extantape/packmule/Packmule.java b/src/main/java/com/extantape/packmule/Packmule.java index 7131473..ce637d9 100644 --- a/src/main/java/com/extantape/packmule/Packmule.java +++ b/src/main/java/com/extantape/packmule/Packmule.java @@ -47,6 +47,9 @@ public final class Packmule extends JavaPlugin implements Listener { private record Checked(PackSource source, Outcome outcome) { } + /** One pack change at a time, so two callers can't race on config.yml. */ + private volatile boolean setting; + @Override public void onEnable() { String moved = Migration.copyIfMissing(getDataFolder().toPath().resolveSibling("ResourcePackButler"), getDataFolder().toPath()); @@ -125,6 +128,59 @@ public List reload() { return List.of(); } + /** + * Points a pack at a new per-build address (D189). Downloads it off the main thread and checks its SHA-1 against + * the file name. Only a pass is written to config.yml (comments kept), and then the config is reloaded as if by + * /packmule reload. Any failure leaves config.yml and the pack as they were. {@code done} gets the outcome on the + * main thread: null when it worked, or the refusal. + */ + public boolean setPack(com.extantape.packmule.command.PackSet.Plan plan, java.util.function.Consumer done) { + if (setting) return false; + setting = true; + LinkChecker checker = new LinkChecker(Duration.ofSeconds(config.checkTimeoutSeconds())); + CompletableFuture.supplyAsync(() -> checker.check(new PackSource(plan.pack(), plan.url(), plan.sha1(), null), null), checks) + .whenComplete((outcome, error) -> { + if (!isEnabled()) return; + Bukkit.getScheduler().runTask(this, () -> { + try { + if (error != null) done.accept(com.extantape.packmule.command.PackSet.refusal(com.extantape.packmule.command.PackSet.CHECK_FAILED, "The check stopped early, so nothing changed.")); + else if (!outcome.live()) done.accept(com.extantape.packmule.command.PackSet.refusal(com.extantape.packmule.command.PackSet.CHECK_FAILED, + "The new address failed its check (" + outcome.deadReason() + "), so the old one stays.")); + else done.accept(write(plan)); + } finally { + setting = false; + } + }); + }); + return true; + } + + /** Writes the checked address and reloads. On any problem config.yml goes back to what it was. */ + private com.extantape.packmule.command.PackSet.Refused write(com.extantape.packmule.command.PackSet.Plan plan) { + java.nio.file.Path file = getDataFolder().toPath().resolve("config.yml"); + String before; + try { + before = java.nio.file.Files.readString(file); + String after = com.extantape.packmule.command.PackSet.rewrite(before, plan); + java.nio.file.Path tmp = file.resolveSibling("config.yml.packmule-new"); + java.nio.file.Files.writeString(tmp, after); + java.nio.file.Files.move(tmp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING, java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (com.extantape.packmule.command.PackSet.Refused r) { + return r; + } catch (java.io.IOException e) { + getLogger().warning("Couldn't write config.yml for pack '" + plan.pack() + "': " + e.getMessage()); + return com.extantape.packmule.command.PackSet.refusal("internal", "config.yml couldn't be written, so nothing changed. The server log has the details."); + } + List errors = reload(); + if (!errors.isEmpty()) { + try { java.nio.file.Files.writeString(file, before); } catch (java.io.IOException e) { getLogger().severe("Couldn't put config.yml back: " + e.getMessage()); } + reload(); + return com.extantape.packmule.command.PackSet.refusal("config-problem", "The changed config.yml didn't load (" + String.join("; ", errors) + "), so it was put back."); + } + getLogger().info("Pack '" + plan.pack() + "' now points at its new build, " + plan.sha1() + "."); + return null; + } + /** Re-tests every link and re-reads every hash file. Players online keep what they have until they change worlds. */ public void startCheck(org.bukkit.command.CommandSender reportTo, boolean first) { PackmuleConfig snapshot = config; diff --git a/src/main/java/com/extantape/packmule/command/PackSet.java b/src/main/java/com/extantape/packmule/command/PackSet.java new file mode 100644 index 0000000..bc9e04d --- /dev/null +++ b/src/main/java/com/extantape/packmule/command/PackSet.java @@ -0,0 +1,67 @@ +package com.extantape.packmule.command; + +import com.extantape.packmule.config.PackmuleConfig; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +/** + * {@code /packmule pack set }: points a pack at a new per-build address (D189). This class holds the + * parts that need no server, so they're tested on their own: what a new address must be, and the config edit. + * + *

The address must be https, with the SHA-1 as the file name, so the hash can't be given separately and get out of + * step. The plugin downloads it and checks the SHA-1 before anything is written; a refusal leaves the old address. + */ +public final class PackSet { + private PackSet() {} + + /** A refusal with a Signalbox error code and plain words. */ + public static final class Refused extends Exception { + public final String code; + Refused(String code, String message) { super(message); this.code = code; } + } + + public static final String CHECK_FAILED = "check-failed"; + + public static Refused refusal(String code, String message) { return new Refused(code, message); } + + public record Plan(String pack, String url, String sha1) {} + + /** What the command was asked to do, or why it won't. Nothing is fetched or written here. */ + public static Plan plan(PackmuleConfig config, String pack, String url) throws Refused { + if (config == null) throw new Refused("config-problem", "config.yml has problems, so no pack can be changed. Fix it and run /packmule reload."); + if (!config.packs().containsKey(pack)) throw new Refused("not-found", "There's no pack called " + pack + " in config.yml."); + URI u; + try { u = new URI(url); } catch (URISyntaxException e) { throw new Refused("bad-argument", "That isn't a valid address."); } + if (!"https".equals(u.getScheme())) throw new Refused("bad-argument", "The address must start with https://, so the pack can't be swapped on the way."); + if (u.getHost() == null || u.getHost().isBlank()) throw new Refused("bad-argument", "The address has no host name."); + if (u.getRawUserInfo() != null || u.getRawQuery() != null || u.getRawFragment() != null) + throw new Refused("bad-argument", "The address can't carry a user name, a query or a #."); + String sha1 = PackmuleConfig.sha1FromAddress(url); + if (sha1 == null) throw new Refused("bad-argument", "The file name must be the pack's SHA-1, such as …/0123456789abcdef0123456789abcdef01234567.zip."); + return new Plan(pack, url, sha1.toLowerCase(Locale.ROOT)); + } + + /** + * config.yml with the pack pointed at the new address, as text. Its sha1 and sha1-url go, since the file name now + * carries the hash. Everything else, comments included, stays as it was. + */ + public static String rewrite(String configText, Plan plan) throws Refused { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.options().parseComments(true); + try { + yaml.loadFromString(configText); + } catch (org.bukkit.configuration.InvalidConfigurationException e) { + throw new Refused("config-problem", "config.yml can't be read as YAML, so it wasn't changed."); + } + ConfigurationSection p = yaml.getConfigurationSection("packs." + plan.pack()); + if (p == null) throw new Refused("not-found", "There's no pack called " + plan.pack() + " in config.yml."); + p.set("url", plan.url()); + p.set("sha1", null); + p.set("sha1-url", null); + return yaml.saveToString(); + } +} diff --git a/src/main/java/com/extantape/packmule/command/PackmuleCommand.java b/src/main/java/com/extantape/packmule/command/PackmuleCommand.java index 9d6625e..83904d4 100644 --- a/src/main/java/com/extantape/packmule/command/PackmuleCommand.java +++ b/src/main/java/com/extantape/packmule/command/PackmuleCommand.java @@ -3,6 +3,10 @@ import com.extantape.packmule.Packmule; import com.extantape.packmule.check.LinkChecker.Outcome; import com.extantape.packmule.config.PackmuleConfig; +import com.extantape.signalbox.Answer; +import com.extantape.signalbox.Describe; +import com.extantape.signalbox.Errors; +import com.extantape.signalbox.Request; import com.mojang.brigadier.Command; import com.mojang.brigadier.arguments.StringArgumentType; import io.papermc.paper.command.brigadier.CommandSourceStack; @@ -20,6 +24,14 @@ public final class PackmuleCommand { private static final String RECHECK = "packmule.recheck"; private static final String RELOAD = "packmule.reload"; private static final String STATUS = "packmule.status"; + static final String PACK_SET = "packmule.pack.set"; + + /** The format-1 commands (Signalbox), for tools such as Leaf Juice. Each also answers people in plain words. */ + public static java.util.List described() { + return List.of( + new Describe.Command("pack set", PACK_SET, List.of(Describe.Arg.of("pack", "string"), Describe.Arg.of("url", "string")), List.of(), false), + new Describe.Command("describe", STATUS, List.of(), List.of(), false)); + } private final Packmule plugin; @@ -36,11 +48,20 @@ public void register(Commands commands) { private com.mojang.brigadier.tree.LiteralCommandNode tree(String label, boolean old) { return Commands.literal(label) .requires(source -> has(source, RECHECK) || has(source, RELOAD) || has(source, STATUS)) - .executes(ctx -> run(old, ctx, () -> reply(ctx.getSource().getSender(), "Usage: /packmule "))) + .executes(ctx -> run(old, ctx, () -> reply(ctx.getSource().getSender(), "Usage: /packmule |describe>"))) .then(Commands.literal("recheck").requires(source -> has(source, RECHECK)) .executes(ctx -> run(old, ctx, () -> recheck(ctx.getSource().getSender())))) .then(Commands.literal("reload").requires(source -> has(source, RELOAD)) .executes(ctx -> run(old, ctx, () -> reload(ctx.getSource().getSender())))) + .then(Commands.literal("pack").requires(source -> has(source, PACK_SET)) + .then(Commands.literal("set") + .executes(ctx -> run(old, ctx, () -> packSet(ctx.getSource().getSender(), new String[0]))) + .then(Commands.argument("args", StringArgumentType.greedyString()) + .executes(ctx -> run(old, ctx, () -> packSet(ctx.getSource().getSender(), split(StringArgumentType.getString(ctx, "args")))))))) + .then(Commands.literal("describe").requires(source -> has(source, STATUS)) + .executes(ctx -> run(old, ctx, () -> describe(ctx.getSource().getSender(), new String[0]))) + .then(Commands.argument("args", StringArgumentType.greedyString()) + .executes(ctx -> run(old, ctx, () -> describe(ctx.getSource().getSender(), split(StringArgumentType.getString(ctx, "args"))))))) .then(Commands.literal("status").requires(source -> has(source, STATUS)) .executes(ctx -> run(old, ctx, () -> status(ctx.getSource().getSender()))) .then(Commands.argument("player", StringArgumentType.word()) @@ -72,6 +93,57 @@ private static int reply(CommandSender sender, String text) { return Command.SINGLE_SUCCESS; } + private static String[] split(String raw) { + String t = raw.trim(); + return t.isEmpty() ? new String[0] : t.split(" +"); + } + + /** One answer line for a tool, or plain words for a person. */ + private static int answer(CommandSender sender, Request r, String code, String message) { + if (r != null && r.json()) sender.sendMessage(Answer.error(r.id(), code, message)); + else sender.sendMessage(Component.text(message, NamedTextColor.RED)); + return 0; + } + + private int describe(CommandSender sender, String[] args) { + Request r; + try { r = Request.parse(args); } catch (Request.Bad bad) { + if (bad.id != null) sender.sendMessage(Answer.error(bad.id, bad.code, bad.getMessage())); else reply(sender, bad.getMessage()); + return 0; + } + if (r.json()) sender.sendMessage(Answer.ok(r.id(), Describe.body("Packmule", plugin.getPluginMeta().getVersion(), described()))); + else for (Describe.Command d : described()) reply(sender, "/packmule " + d.name() + (d.name().equals("pack set") ? " " : "")); + return Command.SINGLE_SUCCESS; + } + + /** + * /packmule pack set [--json id]: checks the new per-build address, and only on a pass writes it to + * config.yml and reloads. Refuses http, a file name that isn't the SHA-1, a download whose SHA-1 doesn't match it, + * and an unknown pack, leaving everything as it was. + */ + private int packSet(CommandSender sender, String[] args) { + Request r; + try { r = Request.parse(args); } catch (Request.Bad bad) { + if (bad.id != null) sender.sendMessage(Answer.error(bad.id, bad.code, bad.getMessage())); else reply(sender, bad.getMessage()); + return 0; + } + if (r.tokens().size() != 2 || r.freeText() != null) return answer(sender, r, Errors.BAD_ARGUMENT, "Use: /packmule pack set "); + PackSet.Plan plan; + try { + plan = PackSet.plan(plugin.config(), r.tokens().get(0), r.tokens().get(1)); + } catch (PackSet.Refused no) { + return answer(sender, r, no.code, no.getMessage()); + } + boolean started = plugin.setPack(plan, refused -> { + if (refused != null) { answer(sender, r, refused.code, refused.getMessage()); return; } + if (r.json()) sender.sendMessage(Answer.ok(r.id(), new java.util.LinkedHashMap<>(java.util.Map.of("pack", plan.pack(), "sha1", plan.sha1(), "check", "passed")))); + else sender.sendMessage(Component.text("Pack '" + plan.pack() + "' now points at " + plan.url() + ". It passed its check and the config was reloaded.", NamedTextColor.GREEN)); + }); + if (!started) return answer(sender, r, Errors.BUSY, "Another pack change is still being checked. Try again in a moment."); + if (!r.json()) reply(sender, "Checking the new address for pack '" + plan.pack() + "'. Nothing changes unless it passes."); + return Command.SINGLE_SUCCESS; + } + private int recheck(CommandSender sender) { if (plugin.config() == null) { sender.sendMessage(Component.text("config.yml has problems, so there's nothing to check. Fix it and run /packmule reload.", NamedTextColor.RED)); diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index 7fd67bf..b36cf16 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -14,6 +14,9 @@ permissions: packmule.status: description: Use /packmule status. default: op + packmule.pack.set: + description: Use /packmule pack set, which points a pack at a new per-build address after checking it. Leaf Juice uses it. + default: op packmule.notify: description: See warnings about dead links and failed downloads in chat. default: op diff --git a/src/test/java/com/extantape/packmule/check/LinkCheckerTest.java b/src/test/java/com/extantape/packmule/check/LinkCheckerTest.java index 621c10d..6dc2eef 100644 --- a/src/test/java/com/extantape/packmule/check/LinkCheckerTest.java +++ b/src/test/java/com/extantape/packmule/check/LinkCheckerTest.java @@ -112,6 +112,16 @@ void wrongHashIsDead() throws Exception { assertTrue(outcome.deadReason().startsWith("wrong hash"), outcome.deadReason()); } + @Test + void aPerBuildFileThatIsntWhatItsNameSaysIsDead() throws Exception { + // pack set relies on this: a file named for one SHA-1 that serves another is refused, so the old url stays. + String named = "c".repeat(40); + files.put("/p/" + named + ".zip", pack("not that build")); + var outcome = checker.check(new PackSource("a", base + "/p/" + named + ".zip", named, null), null); + assertFalse(outcome.live()); + assertTrue(outcome.deadReason().startsWith("wrong hash"), outcome.deadReason()); + } + @Test void timeoutIsDeadAndQuick() { files.put("/slow", pack("s")); diff --git a/src/test/java/com/extantape/packmule/command/PackSetTest.java b/src/test/java/com/extantape/packmule/command/PackSetTest.java new file mode 100644 index 0000000..c26ca85 --- /dev/null +++ b/src/test/java/com/extantape/packmule/command/PackSetTest.java @@ -0,0 +1,97 @@ +package com.extantape.packmule.command; + +import com.extantape.packmule.config.PackmuleConfig; +import com.extantape.signalbox.Describe; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** /packmule pack set (D189): what a new address must be, and the config edit. The download check is in LinkCheckerTest. */ +class PackSetTest { + static final String H = "0123456789abcdef0123456789abcdef01234567"; + static final String CONFIG = """ + # Packmule + # A comment the owner wrote. + packs: + # the main pack + main: + url: https://packs.example.com/old/pack.zip + sha1-url: https://packs.example.com/old/pack.zip.sha1 + extra: + url: https://example.com/extra.zip + sha1: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + worlds: + world: [main, extra] + """; + + static PackmuleConfig config() throws Exception { + var y = new YamlConfiguration(); + y.loadFromString(CONFIG); + var r = PackmuleConfig.parse(y); + assertTrue(r.ok(), r.errors().toString()); + return r.config(); + } + + @Test void aPerBuildHttpsAddressIsPlannedWithItsHash() throws Exception { + var p = PackSet.plan(config(), "main", "https://join.example.org/p/abc/" + H.toUpperCase() + ".zip"); + assertEquals("main", p.pack()); + assertEquals(H, p.sha1(), "the SHA-1 comes from the file name, lowercased"); + } + + @ParameterizedTest + @ValueSource(strings = { + "http://join.example.org/p/" + H + ".zip", + "ftp://join.example.org/p/" + H + ".zip", + "https:///p/" + H + ".zip", + "https://user" + "@" + "join.example.org/p/" + H + ".zip", + "https://join.example.org/p/" + H + ".zip?x=1", + "https://join.example.org/p/" + H + ".zip#x", + "https://join.example.org/p/pack.zip", + "https://join.example.org/p/" + H + ".zip.txt", + "not a url"}) + void anythingButAnHttpsPerBuildAddressIsRefused(String url) throws Exception { + var e = assertThrows(PackSet.Refused.class, () -> PackSet.plan(config(), "main", url)); + assertEquals("bad-argument", e.code); + } + + @Test void anUnknownPackIsRefused() throws Exception { + assertEquals("not-found", assertThrows(PackSet.Refused.class, () -> PackSet.plan(config(), "nope", "https://x.org/" + H + ".zip")).code); + } + + @Test void withABrokenConfigNothingCanBeSet() { + assertEquals("config-problem", assertThrows(PackSet.Refused.class, () -> PackSet.plan(null, "main", "https://x.org/" + H + ".zip")).code); + } + + @Test void theEditChangesOnlyThatPackAndKeepsTheComments() throws Exception { + String url = "https://join.example.org/p/abc/" + H + ".zip"; + String out = PackSet.rewrite(CONFIG, new PackSet.Plan("main", url, H)); + assertTrue(out.contains("# A comment the owner wrote."), out); + assertTrue(out.contains("# the main pack"), out); + var y = new YamlConfiguration(); + y.loadFromString(out); + assertEquals(url, y.getString("packs.main.url")); + assertNull(y.get("packs.main.sha1-url"), "the file name carries the hash now"); + assertNull(y.get("packs.main.sha1")); + assertEquals("https://example.com/extra.zip", y.getString("packs.extra.url")); + assertEquals("a".repeat(40), y.getString("packs.extra.sha1"), "other packs are untouched"); + assertEquals(java.util.List.of("main", "extra"), y.getStringList("worlds.world")); + var parsed = PackmuleConfig.parse(y); + assertTrue(parsed.ok(), parsed.errors().toString()); + assertEquals(H, parsed.config().packs().get("main").sha1()); + } + + @Test void theEditRefusesYamlItCantRead() { + assertEquals("config-problem", assertThrows(PackSet.Refused.class, + () -> PackSet.rewrite("packs: [", new PackSet.Plan("main", "https://x.org/" + H + ".zip", H))).code); + } + + @Test void describeListsPackSetForLeafJuice() { + var body = Describe.body("Packmule", "0.2.1", PackmuleCommand.described()); + assertEquals(1, body.get("format")); + assertTrue(body.toString().contains("name=pack set"), body.toString()); + assertTrue(body.toString().contains("permission=packmule.pack.set")); + } +}