~saiko/forgething

386329c286cb072088b5e304704d96d6778de0f7 — 2xsaiko 5 months ago master
Initial commit
46 files changed, 2460 insertions(+), 0 deletions(-)

A .gitignore
A build.gradle
A gradle/wrapper/gradle-wrapper.jar
A gradle/wrapper/gradle-wrapper.properties
A gradlew
A gradlew.bat
A settings.gradle
A src/main/java/net/dblsaiko/forgething/ArtifactRules.java
A src/main/java/net/dblsaiko/forgething/DatatypeUtil.java
A src/main/java/net/dblsaiko/forgething/DeferredFile.java
A src/main/java/net/dblsaiko/forgething/FileUtil.java
A src/main/java/net/dblsaiko/forgething/Main.java
A src/main/java/net/dblsaiko/forgething/MavenArtifactPath.java
A src/main/java/net/dblsaiko/forgething/Os.java
A src/main/java/net/dblsaiko/forgething/Urls.java
A src/main/java/net/dblsaiko/forgething/VerifiedFile.java
A src/main/java/net/dblsaiko/forgething/dlutil/DownloadEntry.java
A src/main/java/net/dblsaiko/forgething/dlutil/DownloadOption.java
A src/main/java/net/dblsaiko/forgething/dlutil/DownloadOptionData.java
A src/main/java/net/dblsaiko/forgething/dlutil/DownloadOptionDataImpl.java
A src/main/java/net/dblsaiko/forgething/dlutil/DownloadOptions.java
A src/main/java/net/dblsaiko/forgething/dlutil/DownloadUtil.java
A src/main/java/net/dblsaiko/forgething/dlutil/MavenVerifyDownloadOption.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/ArgTemplate.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/McpConfig.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/McpConfigHeader.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/Pipeline.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/CustomTask.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/DownloadClientTask.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/DownloadServerTask.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/GameDownloader.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/InjectTask.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/ListLibrariesTask.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/NoopTask.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/PatchTask.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/StripTask.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/Task.java
A src/main/java/net/dblsaiko/forgething/mcpconfig/task/TaskType.java
A src/main/java/net/dblsaiko/forgething/verify/ArrayChecksumType.java
A src/main/java/net/dblsaiko/forgething/verify/ChecksumProvider.java
A src/main/java/net/dblsaiko/forgething/verify/ChecksumType.java
A src/main/java/net/dblsaiko/forgething/verify/ChecksumTypes.java
A src/main/java/net/dblsaiko/forgething/verify/FileChecksumProvider.java
A src/main/java/net/dblsaiko/forgething/verify/InputStreamChecksumProvider.java
A src/main/java/net/dblsaiko/forgething/verify/MessageDigestChecksumType.java
A src/main/java/net/dblsaiko/forgething/verify/SizeChecksumType.java
A  => .gitignore +8 -0
@@ 1,8 @@
/.gradle
/.idea
/build
/out
/log.txt

/cache
/work
\ No newline at end of file

A  => build.gradle +16 -0
@@ 1,16 @@
plugins {
    id 'java'
}

group 'net.dblsaiko'
version '0.1.0'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.6'
}

A  => gradle/wrapper/gradle-wrapper.jar +0 -0

A  => gradle/wrapper/gradle-wrapper.properties +5 -0
@@ 1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.2.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

A  => gradlew +172 -0
@@ 1,172 @@
#!/usr/bin/env sh

##############################################################################
##
##  Gradle start up script for UN*X
##
##############################################################################

# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
    ls=`ls -ld "$PRG"`
    link=`expr "$ls" : '.*-> \(.*\)$'`
    if expr "$link" : '/.*' > /dev/null; then
        PRG="$link"
    else
        PRG=`dirname "$PRG"`"/$link"
    fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null

APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`

# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m"'

# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"

warn () {
    echo "$*"
}

die () {
    echo
    echo "$*"
    echo
    exit 1
}

# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
  CYGWIN* )
    cygwin=true
    ;;
  Darwin* )
    darwin=true
    ;;
  MINGW* )
    msys=true
    ;;
  NONSTOP* )
    nonstop=true
    ;;
esac

CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar

# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
        # IBM's JDK on AIX uses strange locations for the executables
        JAVACMD="$JAVA_HOME/jre/sh/java"
    else
        JAVACMD="$JAVA_HOME/bin/java"
    fi
    if [ ! -x "$JAVACMD" ] ; then
        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
    fi
else
    JAVACMD="java"
    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi

# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
    MAX_FD_LIMIT=`ulimit -H -n`
    if [ $? -eq 0 ] ; then
        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
            MAX_FD="$MAX_FD_LIMIT"
        fi
        ulimit -n $MAX_FD
        if [ $? -ne 0 ] ; then
            warn "Could not set maximum file descriptor limit: $MAX_FD"
        fi
    else
        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
    fi
fi

# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi

# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
    JAVACMD=`cygpath --unix "$JAVACMD"`

    # We build the pattern for arguments to be converted via cygpath
    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
    SEP=""
    for dir in $ROOTDIRSRAW ; do
        ROOTDIRS="$ROOTDIRS$SEP$dir"
        SEP="|"
    done
    OURCYGPATTERN="(^($ROOTDIRS))"
    # Add a user-defined pattern to the cygpath arguments
    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
    fi
    # Now convert the arguments - kludge to limit ourselves to /bin/sh
    i=0
    for arg in "$@" ; do
        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option

        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
        else
            eval `echo args$i`="\"$arg\""
        fi
        i=$((i+1))
    done
    case $i in
        (0) set -- ;;
        (1) set -- "$args0" ;;
        (2) set -- "$args0" "$args1" ;;
        (3) set -- "$args0" "$args1" "$args2" ;;
        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
    esac
fi

# Escape application args
save () {
    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
    echo " "
}
APP_ARGS=$(save "$@")

# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"

# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
  cd "$(dirname "$0")"
fi

exec "$JAVACMD" "$@"

A  => gradlew.bat +84 -0
@@ 1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem  Gradle startup script for Windows
@rem
@rem ##########################################################################

@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal

set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%

@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m"

@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome

set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init

echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.

goto fail

:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe

if exist "%JAVA_EXE%" goto init

echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.

goto fail

:init
@rem Get command-line arguments, handling Windows variants

if not "%OS%" == "Windows_NT" goto win9xME_args

:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2

:win9xME_args_slurp
if "x%~1" == "x" goto execute

set CMD_LINE_ARGS=%*

:execute
@rem Setup the command line

set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar

@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%

:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd

:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1

:mainEnd
if "%OS%"=="Windows_NT" endlocal

:omega

A  => settings.gradle +2 -0
@@ 1,2 @@
rootProject.name = 'forgething'


A  => src/main/java/net/dblsaiko/forgething/ArtifactRules.java +114 -0
@@ 1,114 @@
package net.dblsaiko.forgething;

import com.google.gson.stream.JsonReader;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class ArtifactRules {

    private static final ArtifactRules EMPTY = new ArtifactRules(Collections.singletonList(new Rule(Action.ALLOW, null)));

    private final List<Rule> rules;

    public ArtifactRules(List<Rule> rules) {
        this.rules = rules;
    }

    public Action getAction(Os os) {
        return rules.stream()
            .filter($ -> $.matches(os))
            .findFirst()
            .map(Rule::getAction)
            .orElse(Action.DISALLOW);
    }

    public static ArtifactRules empty() {
        return EMPTY;
    }

    public static ArtifactRules parse(JsonReader jr) throws IOException {
        List<Rule> list = new ArrayList<>();
        jr.beginArray();
        while (jr.hasNext()) {
            jr.beginObject();
            Action action = null;
            Os os = null;
            while (jr.hasNext()) {
                String name = jr.nextName();
                switch (name) {
                    case "action":
                        String actionId = jr.nextString();
                        switch (actionId) {
                            case "allow":
                                action = Action.ALLOW;
                                break;
                            case "disallow":
                                action = Action.DISALLOW;
                                break;
                            default:
                                throw new IllegalStateException("Unsupported action " + actionId);
                        }
                        break;
                    case "os":
                        jr.beginObject();
                        while (jr.hasNext()) {
                            String s = jr.nextName();
                            if ("name".equals(s)) {
                                String osId = jr.nextString();
                                switch (osId) {
                                    case "osx":
                                        os = Os.MAC;
                                        break;
                                    case "windows":
                                        os = Os.WINDOWS;
                                    default:
                                        throw new IllegalStateException("Unsupported OS " + osId);
                                }
                            } else {
                                throw new IllegalStateException("Unsupported OS filter " + s);
                            }
                        }
                        jr.endObject();
                        break;
                    default:
                        throw new IllegalStateException("Invalid rule param " + name);
                }
            }
            jr.endObject();
            if (action == null) throw new IllegalStateException("Action not set");
            list.add(new Rule(action, os));
        }
        jr.endArray();
        Collections.reverse(list);
        return new ArtifactRules(list);
    }

    public static class Rule {

        private final Action action;
        private final Os os;

        public Rule(Action action, Os os) {
            this.action = action;
            this.os = os;
        }

        public Action getAction() {
            return action;
        }

        public boolean matches(Os os) {
            return this.os == null || this.os == os;
        }

    }

    public enum Action {
        ALLOW,
        DISALLOW,
    }

}

A  => src/main/java/net/dblsaiko/forgething/DatatypeUtil.java +26 -0
@@ 1,26 @@
package net.dblsaiko.forgething;

public class DatatypeUtil {

    public static byte[] parse(String data) {
        if (data.length() % 2 != 0) throw new IllegalArgumentException("");
        byte[] arr = new byte[data.length() / 2];
        for (int i = 0; i < arr.length; i += 1) {
            String s = data.substring(i * 2, (i * 2) + 2);
            byte b = (byte) Integer.parseUnsignedInt(s, 16);
            arr[i] = b;
        }
        return arr;
    }

    public static String toString(byte[] data) {
        StringBuilder sb = new StringBuilder();
        for (byte b : data) {
            String str = Integer.toHexString(Byte.toUnsignedInt(b));
            if (str.length() < 2) sb.append('0');
            sb.append(str);
        }
        return sb.toString();
    }

}

A  => src/main/java/net/dblsaiko/forgething/DeferredFile.java +10 -0
@@ 1,10 @@
package net.dblsaiko.forgething;

import java.nio.file.Path;
import java.util.Optional;

public interface DeferredFile {

    Optional<Path> get();

}

A  => src/main/java/net/dblsaiko/forgething/FileUtil.java +42 -0
@@ 1,42 @@
package net.dblsaiko.forgething;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class FileUtil {

    private FileUtil() {
    }

    public static void deleteDirectories(Path dir) throws IOException {
        List<Path> files = Files.walk(dir).collect(Collectors.toList());
        Collections.reverse(files);
        for (Path file : files) {
            Files.delete(file);
        }
    }

    public static void copyAll(Path source, Path dest) throws IOException {
        copyAll(source, dest, _path -> true);
    }

    public static void copyAll(Path source, Path dest, Predicate<Path> filter) throws IOException {
        if (!Files.isDirectory(source)) {
            Files.copy(source, dest);
            return;
        }
        for (Path path : (Iterable<Path>) Files.walk(source).filter(path -> !Files.isDirectory(path))::iterator) {
            if (filter.test(path)) {
                Path destFile = dest.resolve(source.relativize(path).toString());
                Files.createDirectories(destFile.getParent());
                Files.copy(path, destFile);
            }
        }
    }

}

A  => src/main/java/net/dblsaiko/forgething/Main.java +39 -0
@@ 1,39 @@
package net.dblsaiko.forgething;

import net.dblsaiko.forgething.dlutil.DownloadOptions;
import net.dblsaiko.forgething.dlutil.DownloadUtil;
import net.dblsaiko.forgething.mcpconfig.McpConfig;

import java.io.IOException;
import java.net.URL;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {

    public static final ExecutorService executor = Executors.newWorkStealingPool(Runtime.getRuntime().availableProcessors() + 1);

    public static final String MCP_MAVEN = "https://files.minecraftforge.net/maven/";

    public static final Path LOG_FILE = Paths.get("log.txt");

    public static void main(String[] args) {
        String minecraftVersion = "1.15.2";
        Path mcpconfig = getMcpConfig(minecraftVersion);
        try (McpConfig mcpc = McpConfig.from(mcpconfig)) {
            assert mcpc != null;
            Path output = mcpc.execPipeline("joined");
            System.out.println(output);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static Path getMcpConfig(String version) {
        URL mainArtifact = MavenArtifactPath.from(String.format("de.oceanlabs.mcp:mcp_config:%s@zip", version)).getUrlIn(MCP_MAVEN);
        return DownloadUtil.INSTANCE.download(mainArtifact, DownloadOptions.mavenVerify());
    }

}

A  => src/main/java/net/dblsaiko/forgething/MavenArtifactPath.java +82 -0
@@ 1,82 @@
package net.dblsaiko.forgething;

import java.net.URL;

public class MavenArtifactPath {

    private final String group;
    private final String name;
    private final String version;
    private final String classifier;
    private final String extension;

    private MavenArtifactPath(String group, String name, String version, String classifier, String extension) {
        this.group = group;
        this.name = name;
        this.version = version;
        this.classifier = classifier;
        this.extension = extension;
    }

    public String getDir() {
        return String.format("%s/%s/%s", group.replace('.', '/'), name, version);
    }

    public String getBaseFileName() {
        if (classifier.isEmpty()) {
            return String.format("%s-%s", name, version);
        } else {
            return String.format("%s-%s-%s", name, version, classifier);
        }
    }

    public String getPom() {
        return String.format("%s/%s-%s.pom", getDir(), name, version);
    }

    public String getArtifact() {
        return String.format("%s/%s.%s", getDir(), getBaseFileName(), extension);
    }

    public URL getUrlIn(String repo) {
        String url;
        if (repo.endsWith("/")) {
            url = String.format("%s%s", repo, getArtifact());
        } else {
            url = String.format("%s/%s", repo, getArtifact());
        }
        return Urls.get(url);
    }

    public static MavenArtifactPath of(String group, String name, String version) {
        return MavenArtifactPath.of(group, name, version, "");
    }

    public static MavenArtifactPath of(String group, String name, String version, String classifier) {
        return new MavenArtifactPath(group, name, version, classifier, "jar");
    }

    public static MavenArtifactPath of(String group, String name, String version, String classifier, String extension) {
        return new MavenArtifactPath(group, name, version, classifier, extension);
    }

    public static MavenArtifactPath from(String spec) {
        String extension = "jar";
        String[] split = spec.split(":", 4);
        {
            int idx;
            String last = split[split.length - 1];
            if ((idx = last.indexOf("@")) != -1) {
                extension = last.substring(idx + 1);
                split[split.length - 1] = last.substring(0, idx);
            }
        }
        if (split.length < 3) throw new IllegalArgumentException(String.format("invalid spec: '%s'", spec));
        if (split.length == 3) {
            return MavenArtifactPath.of(split[0], split[1], split[2], "", extension);
        } else {
            return MavenArtifactPath.of(split[0], split[1], split[2], split[3], extension);
        }
    }

}

A  => src/main/java/net/dblsaiko/forgething/Os.java +32 -0
@@ 1,32 @@
package net.dblsaiko.forgething;

import java.util.Locale;

public enum Os {
    LINUX,
    MAC,
    SOLARIS,
    WINDOWS,
    OTHER;

    private static Os os = null;

    public static Os getOs() {
        if (os == null) {
            String osName = System.getProperty("os.name").toLowerCase(Locale.ROOT);
            if (osName.contains("mac") || osName.contains("darwin")) {
                os = MAC;
            } else if (osName.contains("win")) {
                os = WINDOWS;
            } else if (osName.contains("nix") || osName.contains("nux")
                || osName.contains("aix")) {
                os = LINUX;
            } else if (osName.contains("sunos")) {
                os = SOLARIS;
            } else {
                os = OTHER;
            }
        }
        return os;
    }
}

A  => src/main/java/net/dblsaiko/forgething/Urls.java +16 -0
@@ 1,16 @@
package net.dblsaiko.forgething;

import java.net.MalformedURLException;
import java.net.URL;

public class Urls {

    public static URL get(String path) {
        try {
            return new URL(path);
        } catch (MalformedURLException e) {
            throw new RuntimeException(e);
        }
    }

}

A  => src/main/java/net/dblsaiko/forgething/VerifiedFile.java +6 -0
@@ 1,6 @@
package net.dblsaiko.forgething;

public interface VerifiedFile extends DeferredFile {


}

A  => src/main/java/net/dblsaiko/forgething/dlutil/DownloadEntry.java +43 -0
@@ 1,43 @@
package net.dblsaiko.forgething.dlutil;

import java.net.URL;
import java.nio.file.Path;
import java.util.Objects;

public class DownloadEntry {

    private final URL url;
    private final Path target;

    private DownloadEntry(URL url, Path target) {
        this.url = url;
        this.target = target;
    }

    public URL getUrl() {
        return url;
    }

    public Path getTarget() {
        return target;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        DownloadEntry that = (DownloadEntry) o;
        return Objects.equals(url, that.url) &&
            Objects.equals(target, that.target);
    }

    @Override
    public int hashCode() {
        return Objects.hash(url, target);
    }

    public static DownloadEntry of(URL url, Path target) {
        return new DownloadEntry(url, target.normalize());
    }

}

A  => src/main/java/net/dblsaiko/forgething/dlutil/DownloadOption.java +24 -0
@@ 1,24 @@
package net.dblsaiko.forgething.dlutil;

import java.nio.file.Path;
import java.util.Optional;

public interface DownloadOption {

    default boolean wantsDownload(DownloadOptionData data) {
        return false;
    }

    default boolean blocksDownload(DownloadOptionData data) {
        return false;
    }

    default Optional<Path> outputDirectory(DownloadOptionData data) {
        return Optional.empty();
    }

    default Optional<String> filename(DownloadOptionData data) {
        return Optional.empty();
    }

}

A  => src/main/java/net/dblsaiko/forgething/dlutil/DownloadOptionData.java +15 -0
@@ 1,15 @@
package net.dblsaiko.forgething.dlutil;

import java.net.URL;
import java.nio.file.Path;
import java.util.List;

public interface DownloadOptionData {

    URL url();

    Path targetPath();

    List<DownloadEntry> downloadHistory();

}

A  => src/main/java/net/dblsaiko/forgething/dlutil/DownloadOptionDataImpl.java +34 -0
@@ 1,34 @@
package net.dblsaiko.forgething.dlutil;

import java.net.URL;
import java.nio.file.Path;
import java.util.List;

public class DownloadOptionDataImpl implements DownloadOptionData {

    private final URL url;
    private final Path targetPath;
    private final List<DownloadEntry> downloadHistory;

    public DownloadOptionDataImpl(URL url, Path targetPath, List<DownloadEntry> downloadHistory) {
        this.url = url;
        this.targetPath = targetPath;
        this.downloadHistory = downloadHistory;
    }

    @Override
    public URL url() {
        return url;
    }

    @Override
    public Path targetPath() {
        return targetPath;
    }

    @Override
    public List<DownloadEntry> downloadHistory() {
        return downloadHistory;
    }

}

A  => src/main/java/net/dblsaiko/forgething/dlutil/DownloadOptions.java +84 -0
@@ 1,84 @@
package net.dblsaiko.forgething.dlutil;

import net.dblsaiko.forgething.verify.ChecksumType;
import net.dblsaiko.forgething.verify.FileChecksumProvider;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;

public class DownloadOptions {

    private static DownloadOption DOWNLOAD_IF_EXISTS = new DownloadOption() {

        @Override
        public boolean wantsDownload(DownloadOptionData data) {
            return true;
        }

    };

    private static DownloadOption DOWNLOAD_ONCE = new DownloadOption() {

        @Override
        public boolean blocksDownload(DownloadOptionData data) {
            return data.downloadHistory().contains(DownloadEntry.of(data.url(), data.targetPath()));
        }

    };

    public static DownloadOption downloadIfExists() {
        return DOWNLOAD_IF_EXISTS;
    }

    public static DownloadOption downloadOnce() {
        return DOWNLOAD_ONCE;
    }

    public static <T> DownloadOption verify(ChecksumType<T> type, T reference) {
        return new DownloadOption() {

            @Override
            public boolean wantsDownload(DownloadOptionData data) {
                if (Files.notExists(data.targetPath())) return true;
                FileChecksumProvider fcp = FileChecksumProvider.of(data.targetPath());
                try {
                    T cs = fcp.getChecksum(type).get();
                    return !type.matches(cs, reference);
                } catch (IOException e) {
                    e.printStackTrace();
                    return true;
                }
            }

        };
    }

    public static DownloadOption mavenVerify() {
        return MavenVerifyDownloadOption.INSTANCE;
    }

    public static DownloadOption downloadInto(Path directory) {
        return new DownloadOption() {

            @Override
            public Optional<Path> outputDirectory(DownloadOptionData data) {
                return Optional.of(directory);
            }

        };
    }

    public static DownloadOption withFileName(String fileName) {
        return new DownloadOption() {

            @Override
            public Optional<String> filename(DownloadOptionData data) {
                return Optional.of(fileName);
            }

        };
    }

}

A  => src/main/java/net/dblsaiko/forgething/dlutil/DownloadUtil.java +104 -0
@@ 1,104 @@
package net.dblsaiko.forgething.dlutil;

import net.dblsaiko.forgething.Main;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;

public class DownloadUtil {

    public static final DownloadUtil INSTANCE = new DownloadUtil(Paths.get("cache"));

    private final Path defaultDownloadDir;

    private final List<DownloadEntry> downloadHistory = new ArrayList<>();

    public DownloadUtil(Path defaultDownloadDir) {
        this.defaultDownloadDir = defaultDownloadDir;
    }

    public Path download(URL url, DownloadOption... options) {
        Path outputFile = getOutputFile(url);
        DownloadOptionData data = new DownloadOptionDataImpl(url, outputFile, Collections.unmodifiableList(downloadHistory));
        Optional<String> filename = Optional.empty();
        Optional<Path> outDir = Optional.empty();
        for (DownloadOption option : options) {
            Optional<String> filename1 = option.filename(data);
            if (filename1.isPresent()) {
                if (filename.isPresent())
                    throw new IllegalStateException(String.format("conflicting filename overrides: '%s', '%s", filename.get(), filename1.get()));
                filename = filename1;
            }
            Optional<Path> outDir1 = option.outputDirectory(data);
            if (outDir1.isPresent()) {
                if (outDir.isPresent())
                    throw new IllegalStateException(String.format("conflicting output directory overrides: '%s', '%s", outDir.get(), outDir1.get()));
                outDir = outDir1;
            }
        }
        Path outDir1 = defaultDownloadDir.resolve(outDir.orElse(Paths.get(".")));
        String filename1 = filename.orElse(outputFile.getFileName().toString());
        outputFile = outDir1.resolve(filename1);
        DownloadOptionData data1 = new DownloadOptionDataImpl(url, outputFile, Collections.unmodifiableList(downloadHistory));
        List<CompletableFuture<Boolean>> wantsDownloadFutures = new ArrayList<>();
        List<CompletableFuture<Boolean>> blocksDownloadFutures = new ArrayList<>();
        for (DownloadOption option : options) {
            wantsDownloadFutures.add(CompletableFuture.supplyAsync(() -> option.wantsDownload(data1), Main.executor));
            blocksDownloadFutures.add(CompletableFuture.supplyAsync(() -> option.blocksDownload(data1), Main.executor));
        }
        boolean wantsDownload = wantsDownloadFutures.parallelStream().anyMatch(CompletableFuture::join);
        boolean blocksDownload = blocksDownloadFutures.parallelStream().anyMatch(CompletableFuture::join);
        boolean shouldDownload = Files.notExists(outputFile) || wantsDownload && !blocksDownload;
        if (shouldDownload) {
            download0(url, outputFile);
        }
        if (Files.notExists(outputFile))
            throw new IllegalStateException(String.format("Failed to download '%s'.", url));
        return outputFile;
    }

    private void download0(URL url, Path outputFile) {
        Path tempFile = null;
        try {
            tempFile = Files.createTempFile("filedl", null);
            try (InputStream inputStream = url.openStream()) {
                try (OutputStream os = Files.newOutputStream(tempFile)) {
                    byte[] buf = new byte[4096];
                    int len;
                    while ((len = inputStream.read(buf)) != -1) {
                        os.write(buf, 0, len);
                    }
                }
            }
            Files.createDirectories(outputFile.getParent());
            Files.move(tempFile, outputFile, StandardCopyOption.REPLACE_EXISTING);
            downloadHistory.add(DownloadEntry.of(url, outputFile));
        } catch (IOException e) {
            if (tempFile != null) {
                try {
                    Files.deleteIfExists(tempFile);
                } catch (IOException ex) {
                    ex.printStackTrace();
                }
            }
        }
    }

    private Path getOutputFile(URL url) {
        String[] pathComponents = url.getPath().split("/");
        return defaultDownloadDir.resolve(pathComponents[pathComponents.length - 1]);
    }


}

A  => src/main/java/net/dblsaiko/forgething/dlutil/MavenVerifyDownloadOption.java +57 -0
@@ 1,57 @@
package net.dblsaiko.forgething.dlutil;

import net.dblsaiko.forgething.DatatypeUtil;
import net.dblsaiko.forgething.Main;
import net.dblsaiko.forgething.verify.ChecksumType;
import net.dblsaiko.forgething.verify.ChecksumTypes;
import net.dblsaiko.forgething.verify.FileChecksumProvider;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.concurrent.CompletableFuture;

public class MavenVerifyDownloadOption implements DownloadOption {

    public static final MavenVerifyDownloadOption INSTANCE = new MavenVerifyDownloadOption();

    private MavenVerifyDownloadOption() {
    }

    @Override
    public boolean wantsDownload(DownloadOptionData data) {
        if (Files.notExists(data.targetPath())) return true;
        FileChecksumProvider fcp = FileChecksumProvider.of(data.targetPath());
        return CompletableFuture.supplyAsync(() -> failsVerify(data.url(), "md5", ChecksumTypes.MD5, fcp), Main.executor)
            .thenCombineAsync(CompletableFuture.supplyAsync(() -> failsVerify(data.url(), "sha1", ChecksumTypes.SHA1, fcp), Main.executor), (a, b) -> a || b)
            .thenCombineAsync(CompletableFuture.supplyAsync(() -> failsVerify(data.url(), "sha256", ChecksumTypes.SHA256, fcp), Main.executor), (a, b) -> a || b).join();
    }

    private boolean failsVerify(URL url, String ext, ChecksumType<byte[]> type, FileChecksumProvider fcp) {
        try {
            URL checksumUrl = new URL(String.format("%s.%s", url.toExternalForm(), ext));
            byte[] checksum = fcp.getChecksum(type).get();
            URLConnection conn = checksumUrl.openConnection();
            conn.connect();
            try (InputStream is = conn.getInputStream()) {
                byte[] buf = new byte[4096];
                int total = 0;
                int len;
                while ((len = is.read(buf, total, buf.length - total)) != -1) {
                    total += len;
                }
                byte[] reference = DatatypeUtil.parse(new String(buf, 0, total, StandardCharsets.UTF_8));
                if (reference.length != checksum.length) {
                    throw new IllegalStateException(String.format("Expected %d bytes of checksum data, got %d for %s", checksum.length, total, checksumUrl));
                }
                return !type.matches(reference, checksum);
            }
        } catch (IOException e) {
            return false;
        }
    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/ArgTemplate.java +125 -0
@@ 1,125 @@
package net.dblsaiko.forgething.mcpconfig;

import net.dblsaiko.forgething.Main;

import java.nio.file.Path;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

public interface ArgTemplate {

    default ArgTemplate resolveVariables(McpConfigHeader header, Function<String, ArgTemplate> vars) {
        return this;
    }

    default ArgTemplate resolveTaskOutput(Map<String, Path> prevTaskOutputs) {
        return this;
    }

    default ArgTemplate resolveSelfOutput(Path outputFile) {
        return this;
    }

    default Set<String> taskDependencies() {
        return Collections.emptySet();
    }

    default String unwrap() {
        throw new IllegalStateException(String.format("Can't unwrap '%s'. Used in wrong place or not resolved yet?", this));
    }

    static ArgTemplate parse(String str) {
        if ("{log}".equals(str)) {
            return LogFile.INSTANCE;
        } else if ("{output}".equals(str)) {
            return SelfOutput.INSTANCE;
        } else if (str.startsWith("{") && str.endsWith("Output}")) {
            return new TaskOutput(str.substring(1, str.length() - 7));
        } else if (str.startsWith("{") && str.endsWith("}")) {
            return new Variable(str.substring(1, str.length() - 1));
        } else {
            return new Literal(str);
        }
    }

    class Literal implements ArgTemplate {

        private final String text;

        public Literal(String text) {
            this.text = text;
        }

        @Override
        public String unwrap() {
            return text;
        }

    }

    class Variable implements ArgTemplate {

        private final String var;

        public Variable(String var) {
            this.var = var;
        }

        @Override
        public ArgTemplate resolveVariables(McpConfigHeader header, Function<String, ArgTemplate> vars) {
            ArgTemplate param = vars.apply(var);
            if (param != null) {
                return param;
            }
            if (header.getData().has(var)) {
                return new Literal(header.getData().get(var).getAsString());
            }
            throw new IllegalStateException("Could not resolve variable '" + var + "'.");
        }
    }

    class TaskOutput implements ArgTemplate {

        private final String task;

        public TaskOutput(String task) {
            this.task = task;
        }

        @Override
        public ArgTemplate resolveTaskOutput(Map<String, Path> prevTaskOutputs) {
            return new Literal(prevTaskOutputs.get(task).toString());
        }

        @Override
        public Set<String> taskDependencies() {
            return Collections.singleton(task);
        }

    }

    class SelfOutput implements ArgTemplate {

        public static final SelfOutput INSTANCE = new SelfOutput();

        @Override
        public ArgTemplate resolveSelfOutput(Path outputFile) {
            return new Literal(outputFile.toString());
        }

    }

    class LogFile implements ArgTemplate {

        public static final LogFile INSTANCE = new LogFile();

        @Override
        public String unwrap() {
            return Main.LOG_FILE.toAbsolutePath().toString();
        }

    }

}
\ No newline at end of file

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/McpConfig.java +125 -0
@@ 1,125 @@
package net.dblsaiko.forgething.mcpconfig;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.dblsaiko.forgething.FileUtil;
import net.dblsaiko.forgething.mcpconfig.task.*;

import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.*;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

public class McpConfig implements Closeable {

    public static final Path WORK_DIR = Paths.get("work");

    private final Path mcpConfigFile;
    private final Path dataDir;
    private final FileSystem zipfs;

    private final McpConfigHeader header;
    private final Map<String, TaskType<?>> tasks;
    private final Map<String, Pipeline> pipelines;

    private McpConfig(Path mcpConfigFile,
                      Path dataDir,
                      FileSystem zipfs,
                      McpConfigHeader header,
                      Map<String, TaskType<?>> tasks,
                      Map<String, Pipeline> pipelines) {
        this.mcpConfigFile = mcpConfigFile;
        this.dataDir = dataDir;
        this.zipfs = zipfs;
        this.header = header;
        this.tasks = tasks;
        this.pipelines = pipelines;
    }

    @Override
    public void close() throws IOException {
        zipfs.close();
        FileUtil.deleteDirectories(dataDir);
    }

    public static McpConfig from(Path path) {
        try {
            FileSystem zipfs = FileSystems.newFileSystem(path, null);
            Path config = zipfs.getPath("config.json");
            try (InputStream is = Files.newInputStream(config)) {
                JsonObject root = JsonParser.parseReader(new InputStreamReader(is)).getAsJsonObject();
                Path tempDirectory = Files.createTempDirectory(WORK_DIR, "mcp-config-data");
                root.add("data", extractFiles(zipfs, tempDirectory, root.getAsJsonObject("data")));
                McpConfigHeader header = McpConfigHeader.from(root);
                Map<String, CustomTask.Type> ctc = loadCustomTaskMap(root);
                Map<String, TaskType<?>> tasks = new HashMap<>(ctc);
                tasks.put("downloadManifest", NoopTask.Type.INSTANCE);
                tasks.put("downloadJson", NoopTask.Type.INSTANCE);
                tasks.put("downloadClient", DownloadClientTask.Type.INSTANCE);
                tasks.put("downloadServer", DownloadServerTask.Type.INSTANCE);
                tasks.put("strip", StripTask.Type.INSTANCE);
                tasks.put("listLibraries", ListLibrariesTask.Type.INSTANCE);
                tasks.put("inject", InjectTask.Type.INSTANCE);
                tasks.put("patch", PatchTask.Type.INSTANCE);
                Map<String, Pipeline> pipelines = loadPipelineMap(root, tasks::get, header);

                return new McpConfig(path, tempDirectory, zipfs, header, tasks, pipelines);
            }
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

    private static JsonObject extractFiles(FileSystem zipfs, Path targetDir, JsonObject obj) throws IOException {
        JsonObject newObject = new JsonObject();
        for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
            String key = entry.getKey();
            JsonElement value = entry.getValue();
            if (value.isJsonPrimitive()) {
                Path path = zipfs.getPath(value.getAsString());
                if (Files.exists(path)) {
                    Path newPath = targetDir.resolve(value.getAsString()).toAbsolutePath();
                    Files.createDirectories(newPath.getParent());
                    if (Files.notExists(newPath)) {
                        FileUtil.copyAll(path, newPath);
                    }
                    newObject.addProperty(key, newPath.toString());
                } else {
                    newObject.add(key, value);
                }
            } else if (value.isJsonObject()) {
                newObject.add(key, extractFiles(zipfs, targetDir, value.getAsJsonObject()));
            } else {
                newObject.add(key, value);
            }
        }
        return newObject;
    }

    public Pipeline getPipeline(String name) {
        return pipelines.get(name);
    }

    public Path execPipeline(String name) throws IOException {
        return getPipeline(name).exec(Paths.get("work").resolve(name));
    }

    private static Map<String, CustomTask.Type> loadCustomTaskMap(JsonObject root) {
        JsonObject functions = root.getAsJsonObject("functions");
        return functions.keySet().stream()
            .collect(Collectors.toMap($ -> $, key -> CustomTask.Type.from(functions.getAsJsonObject(key))));
    }

    private static Map<String, Pipeline> loadPipelineMap(JsonObject root, Function<String, TaskType<?>> taskProvider, McpConfigHeader header) {
        JsonObject steps = root.getAsJsonObject("steps");
        return steps.keySet().stream()
            .collect(Collectors.toMap($ -> $, key -> Pipeline.from(key, steps.getAsJsonArray(key), taskProvider, header)));
    }
}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/McpConfigHeader.java +79 -0
@@ 1,79 @@
package net.dblsaiko.forgething.mcpconfig;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;

import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

public class McpConfigHeader {

    private final String gameVersion;

    private final JsonObject data;

    private final Set<String> clientLibraries;
    private final Set<String> serverLibraries;
    private final Set<String> joinedLibraries;

    public McpConfigHeader(String gameVersion, JsonObject data, Set<String> clientLibraries, Set<String> serverLibraries, Set<String> joinedLibraries) {
        this.gameVersion = gameVersion;
        this.data = data;
        this.clientLibraries = clientLibraries;
        this.serverLibraries = serverLibraries;
        this.joinedLibraries = joinedLibraries;
    }

    public String getGameVersion() {
        return gameVersion;
    }

    public JsonObject getData() {
        return data;
    }

    public Set<String> getClientLibraries() {
        return clientLibraries;
    }

    public Set<String> getServerLibraries() {
        return serverLibraries;
    }

    public Set<String> getJoinedLibraries() {
        return joinedLibraries;
    }

    public static McpConfigHeader from(JsonObject root) {
        int spec = root.get("spec").getAsInt();
        if (spec != 1) throw new IllegalStateException("file format version must be 1");
        String gameVersion = root.get("version").getAsString();
        JsonObject data = root.getAsJsonObject("data");
        Set<String> clientLibraries = Collections.emptySet();
        Set<String> serverLibraries = Collections.emptySet();
        Set<String> joinedLibraries = Collections.emptySet();

        if (root.has("libraries")) {
            JsonObject libraries = root.getAsJsonObject("libraries");
            if (libraries.has("client")) {
                clientLibraries = StreamSupport.stream(libraries.getAsJsonArray("client").spliterator(), true)
                    .map(JsonElement::getAsString)
                    .collect(Collectors.toSet());
            }
            if (libraries.has("server")) {
                serverLibraries = StreamSupport.stream(libraries.getAsJsonArray("server").spliterator(), true)
                    .map(JsonElement::getAsString)
                    .collect(Collectors.toSet());
            }
            if (libraries.has("joined")) {
                joinedLibraries = StreamSupport.stream(libraries.getAsJsonArray("joined").spliterator(), true)
                    .map(JsonElement::getAsString)
                    .collect(Collectors.toSet());
            }
        }
        return new McpConfigHeader(gameVersion, data, clientLibraries, serverLibraries, joinedLibraries);
    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/Pipeline.java +104 -0
@@ 1,104 @@
package net.dblsaiko.forgething.mcpconfig;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.dblsaiko.forgething.FileUtil;
import net.dblsaiko.forgething.mcpconfig.task.Task;
import net.dblsaiko.forgething.mcpconfig.task.TaskType;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

public class Pipeline {

    private final List<Step<?>> steps;

    public Pipeline(List<Step<?>> steps) {
        this.steps = steps;
    }

    public static Pipeline from(String pipeline, JsonArray array, Function<String, TaskType<?>> taskProvider, McpConfigHeader header) {
        List<Step<?>> steps = StreamSupport.stream(array.spliterator(), false)
            .map(JsonElement::getAsJsonObject)
            .map(obj -> Step.from(pipeline, obj, taskProvider, header))
            .collect(Collectors.toList());

        return new Pipeline(steps);
    }

    public Path exec(Path workDir) throws IOException {
        if (Files.exists(workDir)) {
            FileUtil.deleteDirectories(workDir);
        }
        Files.createDirectories(workDir);
        Map<String, Path> prevOutputs = new HashMap<>();
        Map<String, Path> prevOutputView = Collections.unmodifiableMap(prevOutputs);
        Path output = null;
        for (Step<?> step : steps) {
            output = workDir.resolve(step.name);
            output = step.task.execute(output, prevOutputView);
            if (output == null) continue;
            if (output.startsWith(workDir))
                output = fixFileName(output);
            prevOutputs.put(step.name, output);
        }
        return output;
    }

    private static Path fixFileName(Path output) throws IOException {
        if (isZip(output)) {
            // fix for fernflower shitting itself if the input file doesn't have an extension
            Path newOutput = output.getParent().resolve(String.format("%s.zip", output.getFileName()));
            Files.move(output, newOutput);
            return newOutput;
        }
        return output;
    }

    private static boolean isZip(Path p) {
        if (Files.isDirectory(p)) return false;
        try (InputStream is = Files.newInputStream(p)) {
            byte[] header = new byte[4];
            int pos = 0;
            int len;
            while (pos < 4 && (len = is.read(header, pos, header.length - pos)) != -1) {
                pos += len;
            }
            // check if header is 50 4b 03 04
            return pos == 4 && Arrays.hashCode(header) == 0x338f1d;
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        }
    }

    public static class Step<T extends Task> {

        private final String name;

        private final T task;

        public Step(String name, T task) {
            this.name = name;
            this.task = task;
        }

        public static Step<?> from(String pipeline, JsonObject object, Function<String, TaskType<?>> taskProvider, McpConfigHeader header) {
            String typeId = object.getAsJsonPrimitive("type").getAsString();
            String name = object.has("name") ? object.getAsJsonPrimitive("name").getAsString() : typeId;
            TaskType<?> type = taskProvider.apply(typeId);
            Function<String, ArgTemplate> f = key -> object.has(key) ? ArgTemplate.parse(object.getAsJsonPrimitive(key).getAsString()) : null;
            Task task = type.create(name, pipeline, header, object, f);
            return new Step<>(name, task);
        }

    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/CustomTask.java +107 -0
@@ 1,107 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.dblsaiko.forgething.MavenArtifactPath;
import net.dblsaiko.forgething.dlutil.DownloadOptions;
import net.dblsaiko.forgething.dlutil.DownloadUtil;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

public class CustomTask implements Task {

    private final MavenArtifactPath artifact;
    private final List<ArgTemplate> args;
    private final List<String> jvmArgs;
    private final String repo;

    public CustomTask(MavenArtifactPath artifact, List<ArgTemplate> args, List<String> jvmArgs, String repo) {
        this.artifact = artifact;
        this.args = args;
        this.jvmArgs = jvmArgs;
        this.repo = repo;
    }

    @Override
    public Path execute(Path output, Map<String, Path> prevTaskOutputs) throws IOException {
        Path path = DownloadUtil.INSTANCE.download(artifact.getUrlIn(repo),
            DownloadOptions.downloadInto(Paths.get("lib")),
            DownloadOptions.mavenVerify());

        List<String> commandList = Stream.of(
            Stream.of("java"),
            jvmArgs.stream(),
            Stream.of("-jar", path.toAbsolutePath().toString()),
            args.stream().map($ -> $.resolveSelfOutput(output).resolveTaskOutput(prevTaskOutputs).unwrap())
        ).flatMap($ -> $).collect(Collectors.toList());

        Process process = new ProcessBuilder().command(commandList).inheritIO().start();
        try {
            if (process.waitFor() != 0) throw new RuntimeException("process exited with status " + process.exitValue());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        return output;
    }

    @Override
    public Set<String> getDependencies() {
        return args.stream().flatMap($ -> $.taskDependencies().stream()).collect(Collectors.toSet());
    }

    public static class Type implements TaskType<CustomTask> {

        private final MavenArtifactPath artifact;
        private final List<ArgTemplate> args;
        private final List<String> jvmArgs;
        private final String repo;

        public Type(MavenArtifactPath artifact, List<ArgTemplate> args, List<String> jvmArgs, String repo) {
            this.artifact = artifact;
            this.args = args;
            this.jvmArgs = jvmArgs;
            this.repo = repo;
        }

        @Override
        public CustomTask create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver) {
            return new CustomTask(
                artifact,
                args.stream().map($ -> $.resolveVariables(header, paramResolver)).collect(Collectors.toList()),
                jvmArgs,
                repo
            );
        }

        public static Type from(JsonObject obj) {
            MavenArtifactPath artifact = MavenArtifactPath.from(obj.get("version").getAsString());
            List<ArgTemplate> args = StreamSupport.stream(obj.getAsJsonArray("args").spliterator(), true)
                .map(JsonElement::getAsString)
                .map(ArgTemplate::parse)
                .collect(Collectors.toList());
            List<String> jvmArgs = Collections.emptyList();
            if (obj.has("jvmargs")) {
                jvmArgs = StreamSupport.stream(obj.getAsJsonArray("jvmargs").spliterator(), true)
                    .map(JsonElement::getAsString)
                    .collect(Collectors.toList());
            }
            String repo = obj.get("repo").getAsString();
            return new Type(artifact, args, jvmArgs, repo);
        }

    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/DownloadClientTask.java +39 -0
@@ 1,39 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonObject;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;

import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
import java.util.function.Function;

public class DownloadClientTask implements Task {

    private final String gameVersion;

    public DownloadClientTask(String gameVersion) {
        this.gameVersion = gameVersion;
    }

    @Override
    public Path execute(Path output, Map<String, Path> prevTaskOutputs) throws IOException {
        return GameDownloader.download("client", gameVersion);
    }

    public static class Type implements TaskType<DownloadClientTask> {

        public static final Type INSTANCE = new Type();

        private Type() {
        }

        @Override
        public DownloadClientTask create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver) {
            return new DownloadClientTask(header.getGameVersion());
        }

    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/DownloadServerTask.java +39 -0
@@ 1,39 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonObject;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;

import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
import java.util.function.Function;

public class DownloadServerTask implements Task {

    private final String gameVersion;

    public DownloadServerTask(String gameVersion) {
        this.gameVersion = gameVersion;
    }

    @Override
    public Path execute(Path output, Map<String, Path> prevTaskOutputs) throws IOException {
        return GameDownloader.download("server", gameVersion);
    }

    public static class Type implements TaskType<DownloadServerTask> {

        public static final Type INSTANCE = new Type();

        private Type() {
        }

        @Override
        public DownloadServerTask create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver) {
            return new DownloadServerTask(header.getGameVersion());
        }

    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/GameDownloader.java +150 -0
@@ 1,150 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.stream.JsonReader;
import net.dblsaiko.forgething.DatatypeUtil;
import net.dblsaiko.forgething.Urls;
import net.dblsaiko.forgething.dlutil.DownloadOptions;
import net.dblsaiko.forgething.dlutil.DownloadUtil;
import net.dblsaiko.forgething.verify.ChecksumTypes;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;

public class GameDownloader {

    public static final URL VERSION_MANIFEST = Urls.get("https://launchermeta.mojang.com/mc/game/version_manifest.json");

    private GameDownloader() {
    }

    public static Path download(String dist, String gameVersion) throws IOException {
        DownloadUtil du = DownloadUtil.INSTANCE;

        Path gameVersionsDir = Paths.get("game");
        Path target = gameVersionsDir.resolve(gameVersion);

        Path manifest = du.download(VERSION_MANIFEST,
            DownloadOptions.downloadIfExists(),
            DownloadOptions.downloadOnce(),
            DownloadOptions.downloadInto(gameVersionsDir));

        Path versionManifest = du.download(getVersionManifestUrl(manifest, gameVersion),
            DownloadOptions.downloadInto(target));

        DistDownloadData data = getDistDownloadData(versionManifest, dist);
        Path gameJar = du.download(data.url,
            DownloadOptions.downloadInto(target),
            DownloadOptions.verify(ChecksumTypes.SHA1, data.sha1),
            DownloadOptions.verify(ChecksumTypes.SIZE, data.size));

        return gameJar;
    }

    public static URL getVersionManifestUrl(Path manifest, String version) {
        try (InputStream is = Files.newInputStream(manifest)) {
            JsonReader jr = new JsonReader(new InputStreamReader(is));
            jr.beginObject();
            while (jr.hasNext() && !"versions".equals(jr.nextName())) {
                jr.skipValue();
            }
            if (!jr.hasNext()) throw new IllegalStateException("versions key not found");
            jr.beginArray();
            outer:
            while (jr.hasNext()) {
                jr.beginObject();
                String url = null;
                while (jr.hasNext()) {
                    switch (jr.nextName()) {
                        case "id":
                            if (!version.equals(jr.nextString())) {
                                while (jr.hasNext()) jr.skipValue();
                                jr.endObject();
                                continue outer;
                            }
                            break;
                        case "url":
                            url = jr.nextString();
                            break;
                        default:
                            jr.skipValue();
                            break;
                    }
                }
                return new URL(Objects.requireNonNull(url));
            }
            throw new IllegalStateException(String.format("version %s not found", version));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static DistDownloadData getDistDownloadData(Path versionManifest, String dist) {
        try (InputStream is = Files.newInputStream(versionManifest)) {
            JsonReader jr = new JsonReader(new InputStreamReader(is));
            jr.beginObject();
            while (jr.hasNext()) {
                if ("downloads".equals(jr.nextName())) {
                    jr.beginObject();
                    while (jr.hasNext()) {
                        if (dist.equals(jr.nextName())) {
                            jr.beginObject();
                            String sha1 = null;
                            long size = -1;
                            String url = null;
                            while (jr.hasNext()) {
                                switch (jr.nextName()) {
                                    case "url":
                                        url = jr.nextString();
                                        break;
                                    case "sha1":
                                        sha1 = jr.nextString();
                                        break;
                                    case "size":
                                        size = jr.nextLong();
                                        break;
                                    default:
                                        jr.skipValue();
                                }
                            }
                            if (url == null) throw new IllegalStateException("url not set");
                            if (sha1 == null) throw new IllegalStateException("sha1 not set");
                            if (size < 0) throw new IllegalStateException("size not set");
                            return new DistDownloadData(
                                new URL(url),
                                DatatypeUtil.parse(sha1),
                                size
                            );
                        } else {
                            jr.skipValue();
                        }
                    }
                    throw new IllegalStateException(String.format("download for dist %s not found", dist));
                } else {
                    jr.skipValue();
                }
            }
            throw new IllegalStateException("downloads object not found");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static class DistDownloadData {
        public final URL url;
        public final byte[] sha1;
        public final long size;

        private DistDownloadData(URL url, byte[] sha1, long size) {
            this.url = url;
            this.sha1 = sha1;
            this.size = size;
        }
    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/InjectTask.java +73 -0
@@ 1,73 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonObject;
import net.dblsaiko.forgething.FileUtil;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

public class InjectTask implements Task {

    private final ArgTemplate input;
    private final Path injected;

    public InjectTask(ArgTemplate input, Path injected) {
        this.input = input;
        this.injected = injected;
    }

    @Override
    public Path execute(Path output, Map<String, Path> prevTaskOutputs) throws IOException {
        Path input = Paths.get(this.input.resolveTaskOutput(prevTaskOutputs).unwrap());
        Files.copy(input, output);
        try (FileSystem zipfs = FileSystems.newFileSystem(output, null)) {
            Path packageInfoTemplate = injected.resolve("package-info-template.java");
            FileUtil.copyAll(injected, zipfs.getPath("/"), path -> !path.equals(packageInfoTemplate));
            List<Path> dirs = Files.walk(zipfs.getPath("/"))
                .filter(p -> Files.isDirectory(p))
                .collect(Collectors.toList());
            List<String> lines = Files.readAllLines(packageInfoTemplate, StandardCharsets.UTF_8);
            for (Path dir : dirs) {
                boolean b = Files.list(dir)
                    .anyMatch($ -> $.toString().endsWith(".java"));
                if (!b) continue;
                String pkg = dir.toString().replaceFirst("^/+", "").replace('/', '.');
                List<String> newLines = lines.stream()
                    .map($ -> $.replace("{PACKAGE}", pkg))
                    .collect(Collectors.toList());
                Files.write(dir.resolve("package-info.java"), newLines);
            }
        }
        return output;
    }

    public static class Type implements TaskType<InjectTask> {

        public static Type INSTANCE = new Type();

        private Type() {
        }

        @Override
        public InjectTask create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver) {
            ArgTemplate input = paramResolver.apply("input");
            Path path = Paths.get(header.getData().get("inject").getAsString());
            return new InjectTask(input, path);
        }

    }

    @Override
    public Set<String> getDependencies() {
        return input.taskDependencies();
    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/ListLibrariesTask.java +176 -0
@@ 1,176 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonObject;
import com.google.gson.stream.JsonReader;
import net.dblsaiko.forgething.ArtifactRules;
import net.dblsaiko.forgething.DatatypeUtil;
import net.dblsaiko.forgething.Main;
import net.dblsaiko.forgething.Os;
import net.dblsaiko.forgething.dlutil.DownloadOptions;
import net.dblsaiko.forgething.dlutil.DownloadUtil;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;
import net.dblsaiko.forgething.verify.ChecksumTypes;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import java.util.stream.Collectors;

public class ListLibrariesTask implements Task {

    private final String gameVersion;

    public ListLibrariesTask(String gameVersion) {
        this.gameVersion = gameVersion;
    }

    @Override
    public Path execute(Path output, Map<String, Path> prevTaskOutputs) throws IOException {
        DownloadUtil du = DownloadUtil.INSTANCE;

        Path gameVersionsDir = Paths.get("game");
        Path target = gameVersionsDir.resolve(gameVersion);

        Path manifest = du.download(GameDownloader.VERSION_MANIFEST,
            DownloadOptions.downloadIfExists(),
            DownloadOptions.downloadOnce(),
            DownloadOptions.downloadInto(gameVersionsDir));

        Path versionManifest = du.download(GameDownloader.getVersionManifestUrl(manifest, gameVersion),
            DownloadOptions.downloadInto(target));

        List<LibraryDownloadData> ldd = getLibraryDownloadData(versionManifest);

        List<String> fileContent = ldd.stream()
            .filter($ -> $.rules.getAction(Os.getOs()) == ArtifactRules.Action.ALLOW)
            .map(data -> CompletableFuture.supplyAsync(() -> du.download(data.url,
                DownloadOptions.downloadInto(Paths.get("lib")),
                DownloadOptions.verify(ChecksumTypes.SHA1, data.sha1),
                DownloadOptions.verify(ChecksumTypes.SIZE, data.size)), Main.executor))
            .collect(Collectors.toList()).stream()
            .map(CompletableFuture::join)
            .map(path -> String.format("-e=%s", path.toAbsolutePath()))
            .collect(Collectors.toList());

        Files.write(output, fileContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
        return output;
    }

    private static List<LibraryDownloadData> getLibraryDownloadData(Path versionManifest) {
        List<LibraryDownloadData> list = new ArrayList<>();
        try (InputStream is = Files.newInputStream(versionManifest)) {
            JsonReader jr = new JsonReader(new InputStreamReader(is));
            jr.beginObject();
            while (jr.hasNext()) {
                if ("libraries".equals(jr.nextName())) {
                    jr.beginArray();
                    while (jr.hasNext()) {
                        String sha1 = null;
                        long size = -1;
                        String url = null;
                        ArtifactRules rules = ArtifactRules.empty();
                        boolean hasArtifact = false;
                        jr.beginObject();
                        while (jr.hasNext()) {
                            switch (jr.nextName()) {
                                case "downloads":
                                    jr.beginObject();
                                    while (jr.hasNext()) {
                                        if ("artifact".equals(jr.nextName())) {
                                            hasArtifact = true;
                                            jr.beginObject();
                                            while (jr.hasNext()) {
                                                switch (jr.nextName()) {
                                                    case "url":
                                                        url = jr.nextString();
                                                        break;
                                                    case "sha1":
                                                        sha1 = jr.nextString();
                                                        break;
                                                    case "size":
                                                        size = jr.nextLong();
                                                        break;
                                                    default:
                                                        jr.skipValue();
                                                }
                                            }
                                            if (url == null) throw new IllegalStateException("url not set");
                                            if (sha1 == null) throw new IllegalStateException("sha1 not set");
                                            if (size < 0) throw new IllegalStateException("size not set");
                                            jr.endObject();
                                        } else {
                                            jr.skipValue();
                                        }
                                    }
                                    jr.endObject();
                                    break;
                                case "rules":
                                    rules = ArtifactRules.parse(jr);
                                    break;
                                default:
                                    jr.skipValue();
                                    break;
                            }
                        }
                        jr.endObject();
                        if (hasArtifact) {
                            list.add(new LibraryDownloadData(
                                new URL(url),
                                DatatypeUtil.parse(sha1),
                                size,
                                rules
                            ));
                        }
                    }
                    return list;
                } else {
                    jr.skipValue();
                }
            }
            throw new IllegalStateException("downloads object not found");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static class LibraryDownloadData {
        public final URL url;
        public final byte[] sha1;
        public final long size;
        public final ArtifactRules rules;

        private LibraryDownloadData(URL url, byte[] sha1, long size, ArtifactRules rules) {
            this.url = url;
            this.sha1 = sha1;
            this.size = size;
            this.rules = rules;
        }
    }


    public static class Type implements TaskType<ListLibrariesTask> {

        public static Type INSTANCE = new Type();

        private Type() {
        }

        @Override
        public ListLibrariesTask create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver) {
            return new ListLibrariesTask(header.getGameVersion());
        }

    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/NoopTask.java +37 -0
@@ 1,37 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonObject;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;

import java.nio.file.Path;
import java.util.Map;
import java.util.function.Function;

public class NoopTask implements Task {

    public static NoopTask INSTANCE = new NoopTask();

    private NoopTask() {
    }

    @Override
    public Path execute(Path output, Map<String, Path> prevTaskOutputs) {
        return null;
    }

    public static class Type implements TaskType<NoopTask> {

        public static Type INSTANCE = new Type();

        private Type() {
        }

        @Override
        public NoopTask create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver) {
            return NoopTask.INSTANCE;
        }

    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/PatchTask.java +85 -0
@@ 1,85 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonObject;
import net.dblsaiko.forgething.FileUtil;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.*;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

public class PatchTask implements Task {

    private final Path patchesDir;
    private final ArgTemplate input;

    public PatchTask(Path patchesDir, ArgTemplate input) {
        this.patchesDir = patchesDir;
        this.input = input;
    }

    @Override
    public Path execute(Path output, Map<String, Path> prevTaskOutputs) throws IOException {
        Path input = Paths.get(this.input.resolveTaskOutput(prevTaskOutputs).unwrap());
        Path tmpDir = Files.createTempDirectory(output.getParent(), "patch");
        try (FileSystem zipfs = FileSystems.newFileSystem(input, null)) {
            FileUtil.copyAll(zipfs.getPath("/"), tmpDir);
        }

        Files.walk(patchesDir)
            .filter(p -> !Files.isDirectory(p))
            .filter(p -> p.toString().endsWith(".patch"))
            .forEach(path -> patch(path, tmpDir));

        try (FileSystem zipfs = FileSystems.newFileSystem(new URI("jar:file:" + output.toAbsolutePath().toString()), Collections.singletonMap("create", "true"))) {
            FileUtil.copyAll(tmpDir, zipfs.getPath("/"));
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }

        FileUtil.deleteDirectories(tmpDir);

        return output;
    }

    private static boolean patch(Path patchFile, Path dir) {
        try {
            return new ProcessBuilder("patch", "-uNtlp1")
                .redirectError(ProcessBuilder.Redirect.INHERIT)
                .redirectOutput(ProcessBuilder.Redirect.INHERIT)
                .redirectInput(patchFile.toFile())
                .directory(dir.toFile())
                .start()
                .waitFor() == 0;
        } catch (InterruptedException | IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static class Type implements TaskType<PatchTask> {

        public static Type INSTANCE = new Type();

        private Type() {
        }

        @Override
        public PatchTask create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver) {
            ArgTemplate input = paramResolver.apply("input");
            return new PatchTask(Paths.get(header.getData().getAsJsonObject("patches").get(pipeline).getAsString()), input);
        }

    }

    @Override
    public Set<String> getDependencies() {
        return input.taskDependencies();
    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/StripTask.java +85 -0
@@ 1,85 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonObject;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Map;
import java.util.function.Function;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;

public class StripTask implements Task {

    private final ArgTemplate input;

    public StripTask(ArgTemplate input) {
        this.input = input;
    }

    @Override
    public Path execute(Path output, Map<String, Path> prevTaskOutputs) throws IOException {
        Path input = Paths.get(this.input.resolveTaskOutput(prevTaskOutputs).unwrap());
        try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(input))) {
            try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(output, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING))) {
                byte[] buf = new byte[4096];
                ZipEntry entry;
                while ((entry = zis.getNextEntry()) != null) {
                    if (!entry.isDirectory() && takeEntry(entry)) {
                        zos.putNextEntry(entry);
                        int len;
                        while ((len = zis.read(buf)) != -1) {
                            zos.write(buf, 0, len);
                        }
                        zos.closeEntry();
                    }
                    zis.closeEntry();
                }
            }
        } catch (IOException e) {
            try {
                Files.deleteIfExists(output);
            } catch (IOException ex) {
                ex.printStackTrace();
            }
            throw e;
        }

        return output;
    }

    private boolean takeEntry(ZipEntry ze) {
        return
            !ze.getName().startsWith("data/") &&
                !ze.getName().startsWith("assets/") &&
                (
                    !ze.getName().contains("/") ||
                        ze.getName().startsWith("com/mojang/blaze3d/") ||
                        ze.getName().startsWith("com/mojang/realmsclient/") ||
                        ze.getName().startsWith("net/minecraft/")
                ) &&
                ze.getName().endsWith(".class");
    }

    public static class Type implements TaskType<StripTask> {

        public static Type INSTANCE = new Type();

        private Type() {
        }

        @Override
        public StripTask create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver) {
            ArgTemplate input = paramResolver.apply("input");
            return new StripTask(input);
        }

    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/Task.java +17 -0
@@ 1,17 @@
package net.dblsaiko.forgething.mcpconfig.task;

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

public interface Task {

    Path execute(Path output, Map<String, Path> prevTaskOutputs) throws IOException;

    default Set<String> getDependencies() {
        return Collections.emptySet();
    }

}

A  => src/main/java/net/dblsaiko/forgething/mcpconfig/task/TaskType.java +13 -0
@@ 1,13 @@
package net.dblsaiko.forgething.mcpconfig.task;

import com.google.gson.JsonObject;
import net.dblsaiko.forgething.mcpconfig.ArgTemplate;
import net.dblsaiko.forgething.mcpconfig.McpConfigHeader;

import java.util.function.Function;

public interface TaskType<T extends Task> {

    T create(String taskName, String pipeline, McpConfigHeader header, JsonObject data, Function<String, ArgTemplate> paramResolver);

}

A  => src/main/java/net/dblsaiko/forgething/verify/ArrayChecksumType.java +23 -0
@@ 1,23 @@
package net.dblsaiko.forgething.verify;

import java.util.Arrays;

public abstract class ArrayChecksumType implements ChecksumType<byte[]> {

    public final int length;

    public ArrayChecksumType(int length) {
        this.length = length;
    }

    @Override
    public boolean isValid(byte[] checksum) {
        return checksum.length == length;
    }

    @Override
    public boolean matches(byte[] a, byte[] b) {
        return isValid(a) && isValid(b) && Arrays.equals(a, b);
    }

}

A  => src/main/java/net/dblsaiko/forgething/verify/ChecksumProvider.java +10 -0
@@ 1,10 @@
package net.dblsaiko.forgething.verify;

import java.io.IOException;
import java.util.Optional;

public interface ChecksumProvider {

    <T> Optional<T> getChecksum(ChecksumType<T> type) throws IOException;

}

A  => src/main/java/net/dblsaiko/forgething/verify/ChecksumType.java +14 -0
@@ 1,14 @@
package net.dblsaiko.forgething.verify;

import java.io.IOException;
import java.io.InputStream;

public interface ChecksumType<T> {

    boolean isValid(T checksum);

    boolean matches(T a, T b);

    T compute(InputStream stream) throws IOException;

}

A  => src/main/java/net/dblsaiko/forgething/verify/ChecksumTypes.java +11 -0
@@ 1,11 @@
package net.dblsaiko.forgething.verify;

public class ChecksumTypes {

    public static final MessageDigestChecksumType MD5 = new MessageDigestChecksumType("MD5");
    public static final MessageDigestChecksumType SHA1 = new MessageDigestChecksumType("SHA-1");
    public static final MessageDigestChecksumType SHA256 = new MessageDigestChecksumType("SHA-256");

    public static final SizeChecksumType SIZE = SizeChecksumType.INSTANCE;

}

A  => src/main/java/net/dblsaiko/forgething/verify/FileChecksumProvider.java +35 -0
@@ 1,35 @@
package net.dblsaiko.forgething.verify;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;

public class FileChecksumProvider extends InputStreamChecksumProvider {

    private final Path path;

    private FileChecksumProvider(Path path) {
        this.path = path;
    }

    @SuppressWarnings("unchecked")
    @Override
    public <T> Optional<T> getChecksum(ChecksumType<T> type) throws IOException {
        if (type == ChecksumTypes.SIZE) {
            return Optional.of((T) (Long) Files.size(path));
        }
        return super.getChecksum(type);
    }

    @Override
    protected InputStream openStream() throws IOException {
        return Files.newInputStream(path);
    }

    public static FileChecksumProvider of(Path path) {
        return new FileChecksumProvider(path);
    }

}

A  => src/main/java/net/dblsaiko/forgething/verify/InputStreamChecksumProvider.java +26 -0
@@ 1,26 @@
package net.dblsaiko.forgething.verify;

import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

public abstract class InputStreamChecksumProvider implements ChecksumProvider {

    private final Map<ChecksumType<?>, Object> checksums = new ConcurrentHashMap<>();

    protected abstract InputStream openStream() throws IOException;

    @SuppressWarnings("unchecked")
    @Override
    public <T> Optional<T> getChecksum(ChecksumType<T> type) throws IOException {
        if (!checksums.containsKey(type)) {
            try (InputStream is = openStream()) {
                checksums.put(type, type.compute(is));
            }
        }
        return Optional.of((T) checksums.get(type));
    }

}

A  => src/main/java/net/dblsaiko/forgething/verify/MessageDigestChecksumType.java +40 -0
@@ 1,40 @@
package net.dblsaiko.forgething.verify;

import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class MessageDigestChecksumType extends ArrayChecksumType {

    private final String algorithm;

    public MessageDigestChecksumType(String algorithm) {
        super(create(algorithm).getDigestLength());
        this.algorithm = algorithm;
    }

    protected MessageDigest create() {
        return create(algorithm);
    }

    @Override
    public byte[] compute(InputStream stream) throws IOException {
        MessageDigest md = create();
        byte[] buf = new byte[4096];
        int len;
        while ((len = stream.read(buf)) != -1) {
            md.update(buf, 0, len);
        }
        return md.digest();
    }

    private static MessageDigest create(String algorithm) {
        try {
            return MessageDigest.getInstance(algorithm);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

}

A  => src/main/java/net/dblsaiko/forgething/verify/SizeChecksumType.java +32 -0
@@ 1,32 @@
package net.dblsaiko.forgething.verify;

import java.io.IOException;
import java.io.InputStream;

public class SizeChecksumType implements ChecksumType<Long> {

    public static final SizeChecksumType INSTANCE = new SizeChecksumType();

    private SizeChecksumType() {
    }

    @Override
    public boolean isValid(Long checksum) {
        return checksum >= 0;
    }

    @Override
    public boolean matches(Long a, Long b) {
        return isValid(a) && isValid(b) && a.equals(b);
    }

    @Override
    public Long compute(InputStream stream) throws IOException {
        long total = 0;
        while (stream.available() > 0) {
            total += stream.skip(stream.available());
        }
        return total;
    }

}