From 0eea92f40a4b7b6e7d604c602b401aced6800caf Mon Sep 17 00:00:00 2001 From: modmuss50 Date: Tue, 25 Feb 2025 19:48:34 +0000 Subject: [PATCH 01/40] Start 1.11 dev cycle. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 1c1955be..6a1fd781 100644 --- a/build.gradle +++ b/build.gradle @@ -23,7 +23,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { } group = 'net.fabricmc' -def baseVersion = '1.10' +def baseVersion = '1.11' def ENV = System.getenv() if (ENV.BUILD_NUMBER) { From 3dd090687f8e691b93af766764aecba8e528b317 Mon Sep 17 00:00:00 2001 From: modmuss Date: Tue, 25 Feb 2025 19:49:33 +0000 Subject: [PATCH 02/40] Create a real Gradle ObjectFactory for unit tests (#1266) --- .../loom/util/service/ServiceType.java | 9 ++ .../test/unit/TestServiceFactoryTest.groovy | 61 ++++++++ .../unit/service/MappingsServiceTest.groovy | 24 +-- .../service/ScopedServiceFactoryTest.groovy | 45 +++--- .../test/unit/service/ServiceTestBase.groovy | 9 +- .../service/SourceRemapperServiceTest.groovy | 29 ++-- .../loom/test/util/GradleTestUtil.groovy | 13 +- .../loom/test/util/TestServiceFactory.groovy | 146 ++++++++++++++++++ 8 files changed, 265 insertions(+), 71 deletions(-) create mode 100644 src/test/groovy/net/fabricmc/loom/test/unit/TestServiceFactoryTest.groovy create mode 100644 src/test/groovy/net/fabricmc/loom/test/util/TestServiceFactory.groovy diff --git a/src/main/java/net/fabricmc/loom/util/service/ServiceType.java b/src/main/java/net/fabricmc/loom/util/service/ServiceType.java index e8db626b..5086ef31 100644 --- a/src/main/java/net/fabricmc/loom/util/service/ServiceType.java +++ b/src/main/java/net/fabricmc/loom/util/service/ServiceType.java @@ -24,6 +24,8 @@ package net.fabricmc.loom.util.service; +import java.lang.reflect.Method; + import org.gradle.api.Action; import org.gradle.api.Project; import org.gradle.api.provider.Provider; @@ -43,6 +45,13 @@ public record ServiceType>(Class public Provider create(Project project, Action action) { return project.provider(() -> { O options = project.getObjects().newInstance(optionsClass); + + for (Method method : optionsClass.getDeclaredMethods()) { + // Gradle property values are lazily initialized, ensure that all of the values are not null + // Before we try to serialize the options as json + method.invoke(options); + } + options.getServiceClass().set(serviceClass.getName()); options.getServiceClass().finalizeValue(); action.execute(options); diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/TestServiceFactoryTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/TestServiceFactoryTest.groovy new file mode 100644 index 00000000..13f144da --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/TestServiceFactoryTest.groovy @@ -0,0 +1,61 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.provider.Property +import spock.lang.Specification + +import net.fabricmc.loom.test.util.TestServiceFactory + +class TestServiceFactoryTest extends Specification { + def "property"() { + setup: + def test = TestServiceFactory.objectFactory.newInstance(PropertyTest) + when: + test.example.set("hello") + then: + test.example.isPresent() + test.example.get() == "hello" + } + + def "file property"() { + setup: + def test = TestServiceFactory.objectFactory.newInstance(FileTest) + when: + test.example.from(new File("test")) + then: + test.example.files.size() == 1 + test.example.singleFile != null + } + + interface PropertyTest { + Property getExample() + } + + interface FileTest { + ConfigurableFileCollection getExample() + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/service/MappingsServiceTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/service/MappingsServiceTest.groovy index 93f5b6f1..c73faeed 100644 --- a/src/test/groovy/net/fabricmc/loom/test/unit/service/MappingsServiceTest.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/unit/service/MappingsServiceTest.groovy @@ -24,20 +24,17 @@ package net.fabricmc.loom.test.unit.service -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.Property - import net.fabricmc.loom.task.service.MappingsService -import net.fabricmc.loom.test.util.GradleTestUtil class MappingsServiceTest extends ServiceTestBase { def "get mapping tree"() { given: - MappingsService service = factory.get(new TestOptions( - mappingsFile: GradleTestUtil.mockRegularFileProperty(new File("src/test/resources/mappings/PosInChunk.mappings")), - from: GradleTestUtil.mockProperty("intermediary"), - to: GradleTestUtil.mockProperty("named"), - )) + def options = MappingsService.TYPE.create(project) { + it.mappingsFile.set(new File("src/test/resources/mappings/PosInChunk.mappings")) + it.from.set("intermediary") + it.to.set("named") + } + MappingsService service = factory.get(options) when: def mappingTree = service.memoryMappingTree @@ -48,13 +45,4 @@ class MappingsServiceTest extends ServiceTestBase { service.from == "intermediary" service.to == "named" } - - static class TestOptions implements MappingsService.Options { - RegularFileProperty mappingsFile - Property from - Property to - Property remapLocals = GradleTestUtil.mockProperty(false) - Property AllowNoneExistent = GradleTestUtil.mockProperty(false) - Property serviceClass = serviceClassProperty(MappingsService.TYPE) - } } \ No newline at end of file diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/service/ScopedServiceFactoryTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/service/ScopedServiceFactoryTest.groovy index 8af9bfa6..fce9f26b 100644 --- a/src/test/groovy/net/fabricmc/loom/test/unit/service/ScopedServiceFactoryTest.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/unit/service/ScopedServiceFactoryTest.groovy @@ -25,20 +25,19 @@ package net.fabricmc.loom.test.unit.service import groovy.transform.InheritConstructors -import groovy.transform.TupleConstructor import org.gradle.api.provider.Property import org.gradle.api.tasks.Input -import spock.lang.Specification -import net.fabricmc.loom.test.util.GradleTestUtil import net.fabricmc.loom.util.service.ScopedServiceFactory import net.fabricmc.loom.util.service.Service import net.fabricmc.loom.util.service.ServiceType -class ScopedServiceFactoryTest extends Specification { +class ScopedServiceFactoryTest extends ServiceTestBase { def "create service"() { given: - def options = new TestServiceOptions(GradleTestUtil.mockProperty("hello")) + def options = TestService.TYPE.create(project) { + it.example.set("hello") + } def factory = new ScopedServiceFactory() when: @@ -53,7 +52,9 @@ class ScopedServiceFactoryTest extends Specification { def "reuse service"() { given: - def options = new TestServiceOptions(GradleTestUtil.mockProperty("hello")) + def options = TestService.TYPE.create(project) { + it.example.set("hello") + } def factory = new ScopedServiceFactory() when: @@ -69,8 +70,12 @@ class ScopedServiceFactoryTest extends Specification { def "reuse service different options instance"() { given: - def options = new TestServiceOptions(GradleTestUtil.mockProperty("hello")) - def options2 = new TestServiceOptions(GradleTestUtil.mockProperty("hello")) + def options = TestService.TYPE.create(project) { + it.example.set("hello") + } + def options2 = TestService.TYPE.create(project) { + it.example.set("hello") + } def factory = new ScopedServiceFactory() when: @@ -86,8 +91,12 @@ class ScopedServiceFactoryTest extends Specification { def "Separate instances"() { given: - def options = new TestServiceOptions(GradleTestUtil.mockProperty("hello")) - def options2 = new TestServiceOptions(GradleTestUtil.mockProperty("world")) + def options = TestService.TYPE.create(project) { + it.example.set("hello") + } + def options2 = TestService.TYPE.create(project) { + it.example.set("world") + } def factory = new ScopedServiceFactory() when: @@ -105,7 +114,9 @@ class ScopedServiceFactoryTest extends Specification { def "close service"() { given: - def options = new TestServiceOptions(GradleTestUtil.mockProperty("hello")) + def options = TestService.TYPE.create(project) { + it.example.set("hello") + } def factory = new ScopedServiceFactory() when: @@ -117,10 +128,10 @@ class ScopedServiceFactoryTest extends Specification { } @InheritConstructors - static class TestService extends Service implements Closeable { - static ServiceType TYPE = new ServiceType(TestService.Options.class, TestService.class) + static class TestService extends Service implements Closeable { + static ServiceType TYPE = new ServiceType(TestOptions, TestService) - interface Options extends Service.Options { + static interface TestOptions extends Service.Options { @Input Property getExample(); } @@ -136,10 +147,4 @@ class ScopedServiceFactoryTest extends Specification { closed = true } } - - @TupleConstructor - static class TestServiceOptions implements TestService.Options { - Property example - Property serviceClass = ServiceTestBase.serviceClassProperty(TestService.TYPE) - } } diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/service/ServiceTestBase.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/service/ServiceTestBase.groovy index 5123727c..9dcd5641 100644 --- a/src/test/groovy/net/fabricmc/loom/test/unit/service/ServiceTestBase.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/unit/service/ServiceTestBase.groovy @@ -24,16 +24,15 @@ package net.fabricmc.loom.test.unit.service -import org.gradle.api.provider.Property +import org.gradle.api.Project import spock.lang.Specification import net.fabricmc.loom.test.util.GradleTestUtil import net.fabricmc.loom.util.service.ScopedServiceFactory -import net.fabricmc.loom.util.service.Service -import net.fabricmc.loom.util.service.ServiceType abstract class ServiceTestBase extends Specification { ScopedServiceFactory factory + Project project = GradleTestUtil.mockProject() def setup() { factory = new ScopedServiceFactory() @@ -43,8 +42,4 @@ abstract class ServiceTestBase extends Specification { factory.close() factory = null } - - static Property serviceClassProperty(ServiceType type) { - return GradleTestUtil.mockProperty(type.serviceClass().name) - } } diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/service/SourceRemapperServiceTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/service/SourceRemapperServiceTest.groovy index 0be082c6..ac3b063e 100644 --- a/src/test/groovy/net/fabricmc/loom/test/unit/service/SourceRemapperServiceTest.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/unit/service/SourceRemapperServiceTest.groovy @@ -27,13 +27,10 @@ package net.fabricmc.loom.test.unit.service import java.nio.file.Files import java.nio.file.Path -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.provider.Property import org.intellij.lang.annotations.Language import net.fabricmc.loom.task.service.MappingsService import net.fabricmc.loom.task.service.SourceRemapperService -import net.fabricmc.loom.test.util.GradleTestUtil import net.fabricmc.loom.util.DeletingFileVisitor class SourceRemapperServiceTest extends ServiceTestBase { @@ -49,15 +46,16 @@ class SourceRemapperServiceTest extends ServiceTestBase { Files.writeString(sourceDirectory.resolve("Source.java"), SOURCE) Files.writeString(mappings, MAPPINGS) - SourceRemapperService service = factory.get(new TestOptions( - mappings: GradleTestUtil.mockProperty( - new MappingsServiceTest.TestOptions( - mappingsFile: GradleTestUtil.mockRegularFileProperty(mappings.toFile()), - from: GradleTestUtil.mockProperty("named"), - to: GradleTestUtil.mockProperty("intermediary"), - ) - ), - )) + def options = SourceRemapperService.TYPE.create(project) { + it.mappings.set(MappingsService.TYPE.create(project) { + it.mappingsFile.set(mappings.toFile()) + it.from.set("named") + it.to.set("intermediary") + }) + it.javaCompileRelease.set(17) + } + + SourceRemapperService service = factory.get(options) when: service.remapSourcesJar(sourceDirectory, destDirectory) @@ -85,11 +83,4 @@ class SourceRemapperServiceTest extends ServiceTestBase { c Source Source m ()V println test """.trim() - - static class TestOptions implements SourceRemapperService.Options { - Property mappings - Property javaCompileRelease = GradleTestUtil.mockProperty(17) - ConfigurableFileCollection classpath = GradleTestUtil.mockConfigurableFileCollection() - Property serviceClass = serviceClassProperty(SourceRemapperService.TYPE) - } } diff --git a/src/test/groovy/net/fabricmc/loom/test/util/GradleTestUtil.groovy b/src/test/groovy/net/fabricmc/loom/test/util/GradleTestUtil.groovy index 4964064f..2700c2cd 100644 --- a/src/test/groovy/net/fabricmc/loom/test/util/GradleTestUtil.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/util/GradleTestUtil.groovy @@ -26,7 +26,6 @@ package net.fabricmc.loom.test.util import org.gradle.api.Project import org.gradle.api.artifacts.dsl.RepositoryHandler -import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty import org.gradle.api.file.SourceDirectorySet @@ -64,9 +63,15 @@ class GradleTestUtil { } static Project mockProject() { + def objectFactory = TestServiceFactory.objectFactory + def providerFactory = TestServiceFactory.providerFactory def mock = mock(Project.class) def extensions = mockExtensionContainer() when(mock.getExtensions()).thenReturn(extensions) + when(mock.getObjects()).thenReturn(objectFactory) + when(mock.provider(any())).thenAnswer { + providerFactory.provider(it.getArgument(0)) + } return mock } @@ -134,12 +139,6 @@ class GradleTestUtil { return mock } - static ConfigurableFileCollection mockConfigurableFileCollection(File... files) { - def mock = mock(ConfigurableFileCollection.class) - when(mock.getFiles()).thenReturn(Set.of(files)) - return mock - } - static RepositoryHandler mockRepositoryHandler() { def mock = mock(RepositoryHandler.class) return mock diff --git a/src/test/groovy/net/fabricmc/loom/test/util/TestServiceFactory.groovy b/src/test/groovy/net/fabricmc/loom/test/util/TestServiceFactory.groovy new file mode 100644 index 00000000..31eb7cdc --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/util/TestServiceFactory.groovy @@ -0,0 +1,146 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.util + +import groovy.transform.CompileStatic +import org.gradle.api.internal.CollectionCallbackActionDecorator +import org.gradle.api.internal.MutationGuard +import org.gradle.api.internal.MutationGuards +import org.gradle.api.internal.collections.DefaultDomainObjectCollectionFactory +import org.gradle.api.internal.collections.DomainObjectCollectionFactory +import org.gradle.api.internal.file.DefaultFileCollectionFactory +import org.gradle.api.internal.file.DefaultFileLookup +import org.gradle.api.internal.file.DefaultFilePropertyFactory +import org.gradle.api.internal.file.FileCollectionFactory +import org.gradle.api.internal.file.FilePropertyFactory +import org.gradle.api.internal.file.FileResolver +import org.gradle.api.internal.file.collections.DirectoryFileTreeFactory +import org.gradle.api.internal.model.DefaultObjectFactory +import org.gradle.api.internal.model.NamedObjectInstantiator +import org.gradle.api.internal.provider.DefaultPropertyFactory +import org.gradle.api.internal.provider.DefaultProviderFactory +import org.gradle.api.internal.provider.PropertyFactory +import org.gradle.api.internal.provider.PropertyHost +import org.gradle.api.internal.tasks.DefaultTaskDependencyFactory +import org.gradle.api.internal.tasks.properties.annotations.OutputPropertyRoleAnnotationHandler +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.util.PatternSet +import org.gradle.cache.internal.CrossBuildInMemoryCacheFactory +import org.gradle.cache.internal.DefaultCrossBuildInMemoryCacheFactory +import org.gradle.internal.Factory +import org.gradle.internal.event.ListenerManager +import org.gradle.internal.instantiation.InstantiatorFactory +import org.gradle.internal.instantiation.generator.DefaultInstantiatorFactory +import org.gradle.internal.nativeintegration.filesystem.FileSystem +import org.gradle.internal.service.DefaultServiceRegistry +import org.gradle.internal.service.Provides +import org.gradle.internal.service.ServiceRegistrationProvider +import org.gradle.internal.service.ServiceRegistry + +import static org.mockito.Mockito.mock + +/** + * Based on Gradle's own TestUtils, this class setups the Gradle DI system to be used within unit tests. + */ +@CompileStatic +class TestServiceFactory { + public static final ServiceRegistry serviceRegistry = createServiceRegistry() + public static final ObjectFactory objectFactory = serviceRegistry.get(ObjectFactory) + public static final ProviderFactory providerFactory = serviceRegistry.get(ProviderFactory) + + private static ServiceRegistry createServiceRegistry() { + def services = new DefaultServiceRegistry() + services.register { + it.add(DefaultPropertyFactory) + it.add(ProviderFactory, new DefaultProviderFactory()) + it.add(PropertyHost, PropertyHost.NO_OP) + it.add(NamedObjectInstantiator) + it.add(CollectionCallbackActionDecorator, CollectionCallbackActionDecorator.NOOP) + it.add(MutationGuard, MutationGuards.identity()) + it.add(FileCollectionFactory, fileCollectionFactory()) + it.add(DefaultDomainObjectCollectionFactory) + it.add(CrossBuildInMemoryCacheFactory, new DefaultCrossBuildInMemoryCacheFactory(mock(ListenerManager))) + //noinspection unused + it.addProvider(new ServiceRegistrationProvider() { + @Provides + InstantiatorFactory createInstantiatorFactory( + CrossBuildInMemoryCacheFactory crossBuildInMemoryCacheFactory) { + return new DefaultInstantiatorFactory( + crossBuildInMemoryCacheFactory, + [], + new OutputPropertyRoleAnnotationHandler([]) + ) + } + + @Provides + FilePropertyFactory createFilePropertyFactory( + PropertyHost propertyHost, + FileCollectionFactory fileCollectionFactory) { + return new DefaultFilePropertyFactory( + propertyHost, + fileResolver(), + fileCollectionFactory) + } + + @Provides + ObjectFactory createObjectFactory( + InstantiatorFactory instantiatorFactory, + NamedObjectInstantiator namedObjectInstantiator, + PropertyFactory propertyFactory, + FilePropertyFactory filePropertyFactory, + FileCollectionFactory fileCollectionFactory, + DomainObjectCollectionFactory domainObjectCollectionFactory) { + return new DefaultObjectFactory( + instantiatorFactory.decorate(services), + namedObjectInstantiator, + mock(DirectoryFileTreeFactory), + mock(Factory) as Factory, + propertyFactory, + filePropertyFactory, + DefaultTaskDependencyFactory.withNoAssociatedProject(), + fileCollectionFactory, + domainObjectCollectionFactory) + } + }) + } + + return services + } + + private static FileResolver fileResolver() { + return new DefaultFileLookup().getFileResolver(new File(".").absoluteFile) + } + + private static FileCollectionFactory fileCollectionFactory() { + return new DefaultFileCollectionFactory( + fileResolver(), + DefaultTaskDependencyFactory.withNoAssociatedProject(), + mock(DirectoryFileTreeFactory), + mock(Factory) as Factory, + PropertyHost.NO_OP, + mock(FileSystem)) + } +} From a608775e4e37bb225c35b2d7d5fe822f790da25c Mon Sep 17 00:00:00 2001 From: modmuss50 Date: Tue, 25 Feb 2025 19:52:59 +0000 Subject: [PATCH 03/40] Update to Gradle 8.13 --- gradle/test.libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43705 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/test.libs.versions.toml b/gradle/test.libs.versions.toml index 6a854144..df35b023 100644 --- a/gradle/test.libs.versions.toml +++ b/gradle/test.libs.versions.toml @@ -6,7 +6,7 @@ mockito = "5.14.2" java-debug = "0.52.0" mixin = "0.15.3+mixin.0.8.7" -gradle-nightly = "8.14-20250208001853+0000" +gradle-nightly = "8.14-20250225001625+0000" fabric-loader = "0.16.9" [libraries] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..9bbc975c742b298b441bfb90dbc124400a3751b9 100644 GIT binary patch delta 34744 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 delta 34727 zcmXV%Ra6`cvxO5Z$lx}3aCi6M?oM!bCpZ&qa2?#;f(LgPoZ#+m!6j&boByo)(og-+ zYgN^*s&7}fEx`25!_*O>gBqKvn~dOCN!``g&ecy%t0`n>G*p;ir0B{<{sUU9M>#WqH4lTN!~PgB@D;`rIdQ#hRw z?T|`wO^O=zovKDMVjuZHAeratT0Q-HK<95;BTTtc%A5Bo>Z{jfiz& z$W5u4#(O_eLYQDY_i&xqzVd#y&cR>MOQU@-w1GN((w{b+PM;=Y3ndBGVv|>|_=ZIC zB^E2+XVovHYl%!I#}4)Pma4)hM2Ly6E;&R5LmOnMf-Qz43>#K*j*LSWoYxxIR5Csm zuHXA8{`YgmqApC|BgY0wGwj-im6rmS^jrAbN8^PEIHj1WH#AVVuUA2HXj&Vm*QD^# zWX8+sR14XM!@6HrfzFpcC$ZXlhjA{{oq5cs&VRBUX2VwX$fdjO~`3n~1})#Bxr5Vh%KwFov=k zW;Jy5qsvC$lw>?*BsoPIo}YgJN>u)C^4Abbjx$NW@n5S8aN_T0BeAXWjz#dQ=3v*# zRQrjH1%R&krxBrfITop};aQdE=ZRgLN%n%+^y5BOs|pO6lg|I3prX{gSgQuRK%177 zlE#t+nHbT~VSO995imTaX&SCB&pgp`Izkg}-NV zI%~Z42T+^_9-gw;yOI&!oZf=H(Cot~)w4^gX&q(zg`7ekm4un&?FuaJQKIrLF$<_% zR;ok9K%L!NlTYgW8?uhX&TS?ojtu~oLm(`7iY<5Ci@V)7+gRHbb!o0OipVh)`vKW) zp9OVLDkaP@Sn!ZRa zpfwY36ct~JlEsS7_Dr%e0UL8^zRSsSv3K)+n$b@Xq9*^-p|AFj(*#}L-%5Z}D@Zl%y2gokn7l;Zr z3CK}pP8BDR1$L~R{R^BwKH~@v9m;O_$00a5MMXTe!u0FG^=2=_f-XZR!DQeQ`5S_$ zO>mOUF8Y-Wfl3P|Mk-VDsBp`X&=kMQl<>nt9$C)^A<4v@xtW>qn@`Z)`|gCedb?$A z^S(N0{?3!oy|^tx0p&<-D62OWo$gVhEodpMi;O#DM7P>i6bnTf$_=~8)PdQ+^h30pu>DfM=LQT20!&5)= zGdR6}f=YHb45NFG9?dd44$Dm~B6k3w1%E%atidmZ`Kaw4q&8yb+5=wqe`pXWH0J%);cCo710p3&(EMuAI{aKjT^Z!u)Eq~b?HpnrSE9ftF4Ibs#HFpuPR zyT$g5JIX12nSw?q!}IY^iHMikUh8V)gjx{JN@8Am6<$2Mz^mHY*_n$LNj)%w6Vs2|Kwpq;J=(VFf`y)>|;A@J@8mL zpw=k%oRd`%OdUL*1^Bd27^<|sYM9NqMxOfyc56FSDcG3u;oJKCAOsBvw)JlyBt5jT zQZ;fkKI1}9MJMtnCEG?ZUph^R-lV{%Av1S91fH#pacM-EI@93$Z)d@UUxu6ruJMHVl=>YjT8reRi0SjW8t!4qJkSw2EWvi_K%!>35@JDfw9#W$~G@9?4ubk&}M9<~>f3`r6~|Hun&D&#w^ zZ2xrK!I3O(3uNXz*JhWWdgESs3jPCOS_W_J;0ggAduavgNUuLi`PfS*0$=1$q$C-# z>ca0l=Pm+p9&+rJQNFKvb%8vn0!qW9SGnIO&tjv!kv980`FquGKanhc(YAwQTGx)(9c1fRnojjxST~<*=y|?=9V1w`t~7Ag$5h)P#FwB7FM=E`e^youj?Nh^d}|GOC7mPW z_H&16WtD5M9H)i@@=Vzo^f`%yIQZ-qGuCko?CP8h^B$X|UkaKazJe>9C00F82u$Iz zFOjPU5)>;*KBg9UezT$OL$aW(Ogut^COwjSO2!@-ZbW#lHVfb_k?7DlEGcbl^tn{p z#+go${sx^TPB3R5272wadT(x2lACj6Y4~LktAm z<+#pEqlksdo%9?Q29%rP9C+LM*WZM-N-e*wX85OOu}J7Zrt%9iGjxN358Fy5GGaNA zlr-b*b{4zqiK)A~_jjEnJhRaVOdID52{6I%oS^X6)EYS(>ZE6NKd-S?F}lIJNYkBz zX=;apb)xyAi#nMFCj#Ex($CGiR?oF|gei))16?8E-mB*}o2=$UtMDZxq+&Q?liP(n z&Ni8pBpgnCai7%!7$wG2n4{^JeW)f-h&_$4648~!d7<~p8apf5f~7e0n$lV_qbrLM zH6T|df(D0@=>WA5f5yN)2BIZFqObOK5I*vhD*2~PZSt*83>fM))aLjXIEokDF;KGw zZ_75?2$lhYW)I_!@r8QpYKr4p27lOeG~ESg#8)LE@pH;oozO*hv19;A7iT#2eow_h z8?gZtDstc~s|f{hFXH|~d~zQ~z_94FB&hp$n~Uv_DB!2y<6&VqZs>-fmUU^yuJGdJ zNCHP?2Q+FZr?J{^_M3`92rOWnrL2vymWZ&0dYxz>Kv&GXWgwxTKz)<+J43r&!q}II z1DmfLl8nu-xGa?TgsrX45d}j{QAC!m8iO1JU=|Pb8D@9FE-V0hJEA?F)srec5$GqD z8(`^KQozt$N;6ts8^+R_uiy|d8MO=#Jvd3z_#2aHXjF94XkEdq3myI_UvT|r>1&LP zU*Mm7Fk}T$qbutLyH`@m{L57Mlkq!hAMe>2-o(8*axogLh^b!!{|amH_{Hrdu!4kWol?jSB%l2>w;Jry$!mf_nbz9_B1#8bWJwL@w!No42F zZ!YAr(^WO;wuxHb`%ZD(qKIOW&)L%j)eAUf-WERo1D?D~FV`np( z5x$@RPj8}2Rbm<>mRjfuPFJ`nN>>ltyp;oE9#K9IU>+pE$;Cq!IYr!NXvc_-MDFXBXW=Z9LZM(k9}OKqEKn5 zMk4%l_POO{UM$2M+YvQV#N~$?Ycqe>LbTz9ur0(-Wp!^8a^GDh7h{U~8h980RG|9E z6RPnEU0ccY1fEIdJfnZ?3Nl4X0Ag>*m6>|oajhbexf9~a8(K`2Ys~o)z{jnuOj93V zg4L4K@x2Dewt5Bok=03M@JIhBSWy2hwxcxRv7ukj`8uYPGrMdH0q!`qHJ^xDQ_bLG ze*?ZCvMv^t`JI7rlqLPEo^WJ0b^>d@C~mI!Zv)-ljBg#u;uvw%ZXMqZsz8Mxdtvbh zbK^eGn90ynsgjzKUOl)O`l3#-uY%L?tj;+Edgz+awV132>9Z-?mj*}u ziM4~P{Pc$s;}v&zYF)Te5J7W2!$o`EH|~F3NfA2NjF&~?@K5S*f_mv2@wT};{Sj`b z%#^~iJN17>qQ6aej~{ubsrhkBAD`C(j7{y)+hU@!^SU03F0Vu6vU3+>!lN@MLR}42 zLOtGS+@f@~=id z8&aK=-2+Pz*y)te)kF3xgyS?qgp@L;G(tM1&#!4p&Z$yX2<+lj>VWT1tiO4`_h^}* zQ@WGd`H9t~sH>+NT2d{O5(~BeYjG#5=s&k0J)iACkpC8u;rFz@_E-w@s0bAs_;b>+ zeR6?5n@}4wjy}GSL@%#%!-~chg|$Q=CE38#Hj0u5P4^Y-V?j(=38#%L#%l4={T(Rq z=x*H|^!EG)+e-leqrbec5?(g)@Op(cHsVg4*>F$Xb=BheCE*5LdSmdwZ-MSJs@@i{5t){y; zxAVyon;`>Rns;YH^`c&M3QdxzNaJl(Byct8a9v38fkXaJ_<=8oe=(6%mZ}CJAQ}2r z#oHZ)q;H0pGydy~@02e)oeVW*rQaD_OLr+)29*|p(gAHd<9*JxBnu0W61lNr+cO_= zX$B`VmPwyz9?FV9j3-@v0D7Z1Z}O;#KZ!@Gm7ZeKORcLQsPN8= zAZRd8VWqow?b1Kp8!AiYk8acC$>6xHuUZWkNk~?EqKsUr2$iixV=zYwM9laPwn)(W z7b-$PlwKh6n5^&Rs$#s&98P1ch#7FGNN6yU!Nwzcesp2Ylw~C1F@G^YA!PF|a$MJ+ z{!r?468ju$sWQLL=o~SYP|CBJ7(3`;c^t;TL4ScL$Pvv>N+5iugRLdmL zaD(CzY&3J+N)7MS)Jw`U8u*IevtEAUKN4~AiL82B$4Bl5oK#No3jGEW-o4`>c%G#8 z!h<$iX*efTk1lnM-d*7Db6h_94Y@IcQg@UJ1-g76_d9@vHWB%F55WG&!4DAy{K)Xv zz~7iiiq(J#G*Jdb2F>RKFnc3y>bIwlQ_Jhzoc4h(EOVm|0C}@X1v`lf-*wuaH5_H)kg%$_&tAkc`-Mk_04t+f0A_7=y20O8`7#X)4WDMOUpG*Z~n ziH5Zevf@*c28LS>z60h(QH92FxJHOKTj&>ep>z##ag+Tm*{QU<#Sk`f3)1y<#hgNV zkGRx3`qggo)?FK!Vd`6U+lA@MVk3QlsjDj#M*^!8JsEqK;p+%l%NyiKg#EX^3GBuk zlh2;u`5~mtZgY!005*{*dmF!OsrxVg*Rpvf{ieqF1ZPV6Mm4vb&^x06M8jn4XO#a* zXJhi$qNRT@M;;!sLq`lbqmcnAsSvSakQ{XcfmP-CU5_ini_P>t3m1P+(5I3tq028F zE8xAnu-M!FQ{&(q8oC{RXMCqw5&ri5tvt$=P|_J!+#m6Iz;U2BaX7}7%E%i{`jgjM^OfP1@K6wN+iSJ-2z7%MfLBS2$+zC|(5j4tu zq@N1d5n}UyXF>Bz{_%qT2O=&{@hkb|g++>5oZPMe%j~Ee^;OCr)Y7u{V4m&Qf@%WD zEUKEu%teX>pmF5DMIP1!>pm1D);32{D-N5>U4W*9kTO|z(Tb#n-@+j!vWj-S8aRy<(xvQm zwZ-#hyB%RQf|G(r&oI7iZhf^pG13lCEWA>mk}rI8IFlm%*!~#7;2xQps>NS2$f@g2 z1EoM!1ML(HjM)=bp>Z>u=jEM5{Ir>yFJ{m8hLv-$1jxB4a{4HNUhk+Rj5-H8}G za~r&Uoh}bQzyC)f6#o3mEkwFNhaD8_~{CW03Dv2Tbl4{ zAFamTS$i&ZYWmae1aCxVNIKrj+u4g3%D96}iqw8~HBu+gFA&*oRP5Z`MikjjDgYjq zkf0&#_Xj->@bJ>!}JGl=t1|~ zGIx9!u63fRtm^?=^0z=^H2SZA43p1deVixbphteFyrqycaRq6DLy2$x4nxgB;-Dug zzoN<>vK7~UxLPDR{wE0ps6mN9MKC>dWM{~@#F)ne0*ExL**#VrA^|@km1xCtF`2N( ze{G#meS3J5(rIs2)mwi>518)j5=wQ+Q`|O{br)MyktYd}-u+5QYQmrBU2ckYE7#Z$ z>MgHjknqi-2`)(Z+pJ?ah4UMg*D%PFgHFMnKg?{GSZZ*f3V+g@129FH@79v%&$&v32_So*G$-3SIp6 zYTlLgF2}s>)U;QtdWf5P&xikI0p1eg2{G!w0+xXNuYf%n#X#fou8}EYvAw$zmrjK&OZkS!$REMr$*aG zyPPjsYd_SXp#Vt9NGI*R;-*4~Gz)&7!zq>hh7)i?8PzCAAv(pNcUGlPNf^OXS$=bx(V#ji2eMF6q{U@ z9?ldp%YEsl;)d%}_Qs81OX>!2>kyChh!-n0Xd@2C1cI2qkRk&b4)(?@KY|?%qMoYb zEi7l}n$O`v+T31;YZF(;FEwj`I8Dz*9fbKrE)8#&?joolVY~3YbZuJwfRt4-kCOM; zcm34HXKH>;a?joGLqjIBG|B??@rS`LSU(l!vxSyfKmGa^x5&S$gvrsrlVT0@Yw#bP z-3#zdbm1;n!DpT@>AnxkZ4llVa;h^fj?R3uN5?-F)SLb}a%TBE=HM5_U*{K=ddu;L7kJ## zqyyGh;WY5rpvMm)$*xZHv!CUlc{zU8huQp`KmQT*yq*ugOu_#Kt-kRa+ODx`Va(;{ zLMO*lsSV`U%+u>-R9GmwqgWulP#>jO9|V60TBE z5ONjntHY2V_MmDJHr3CyuL5X%IlQKbDRch~>EBrwAM? zvOJj&z#NzlWa*K*VEZgjP#cAQ-HRG&mC)aqyjY19GP$U zSKm`d_gXzrLE_^a!9R<~vT9n;>{y3F`!rB%M5psN(yv*%*}F{akxIj9`XBf6jg8a| z^a*Bnpt%;w7P)rXQ8ZkhEt)_RlV=QxL5Ub(IPe9H%T>phrx_UNUT(Tx_Ku09G2}!K($6 zk&bmp@^oUdf8qZpAqrEe`R@M|WEk$lzm$X=&;cRF7^D#Nd;~}a8z$(h7q%A88yb=# zVd1n3r|vPZuhe!9QR*ZtnjELX5i*NoXH%d1E1O1wmebT~HX0F~DbFxk=J^<v|BCiebRdAHYXxOo$YS#BHYecz?S6CX@AcF_k;#_IF+JIV*5|%lV=Y;Ql?=b^ zt}1qN)~qaKnz~KZRf9Aa7U5S&Opz~;SF2ojOSD3HP8WYTbvlEyYK~);#wr+UO8_Sl z$-Yx3B~JYU!uChjzf0v1TKYAtsRkH`QZeF8Q$_`7iPJ79{8V(jbX4T=-LF59vw>au zY6LS|t!~Zz>*ops1&9o5w z3lQx+lhgdg^4d0r-%q!s(A$J%XYhUx~)v|ptx_cU#?44pnz*s$G%3=wh_01 z5l7f$uM;P6oqhM8F|$4h0me5--syUE%vI)HuhLv@kL`s1eP@buw&}80Umf5QOXBlP zAY(8r9}paD1p*&Bir^3<@3Cc4Mr>EpoDHghr{U$hcD8$^OZ6bZS{UYhl_*Otp}Be} z-P^9U7tc!@aodKCp{~TV6o}?M9xG$hN$Kr>|7e~E4mJK>_yjrqF@Kk1;fHw1PP`UI z1Aoa$7yGRMrUVO0M9$rM;=Glzi>SO8!lqon9E_1^0b)CsR0%Nv-$st+be?a*qJkqI zUNaqi*6Y^E>qlHH+*M=aj?)y2r>RGkG?X;Rv!7JG6Uz=^g7B`jEKEvgUq)s3Fw|zFMdak((XwlUaSRN4hGMrH zn2xFaLH!t8txnTiQW;qUWd^m#<3zgCp(=5~i~xw9lU{R~o1qSo#Sh1_4W5(^hL%O9 zOauMH!uGL}u?hV!4V~#?F-<;)X<)4B$u1F4 zf=%}>{b#f`$Ixo^Du_42V6Wir?Muh`(!izQSV9Y3d-MCQT|9bs zIlCtJP7*;A%^1-=u(Laj97hG}uP6Hq0+DzAjB^|$CG(?e_adMTiO&^_9WwrW4H!ju zWEYrjLw<{fSyh-yiPOP{O;c|453fxkp`E;k&)d^wYK=ipbD_kG$u*Ro!kQJOppV5* zP4o#ab%r@RITbag_zHMKF5$z8fJd1L+D8G@m^`*H->XyF$E{x;d;A+T`A zR!1#O!ed)ai|TF054f1+K6 zTDH=fps}vL7=Yl3_R)o948I{CP*`f1v{E~-xX#PaLvb?#qQRElOF-pVuL>d8_�{ zSCu|?z-R)71@L#eM!y^Z6p;ZjzlW@gZzHJC3~O?Pk5QEa0q(aFy!-~pFZ%vBM{a0B zOfAZFmYc{!vg!PSF@l2U zJK`=N@CTmAO4Wuqv6k{SNl?~rs-CcW0VFIdAj^B2Wacs>M@3N&63=c06V6Rf2sR|QLucLaU zKEq5=F9zA=+3ZT|OlY$lIrFmvTV4H!iv+MxhtKJ%j}wlD3qAoT@g^}Cw`#0dsQnXX zETbS9p{IGl{fkz7ld(7^$~HEkkh7pv3NYi8<1qwOw!a|xaQ$TntGU7;01Z4?b9D8N zBh&aOYgatY!f;X<$(oO>v=8iOcEG%aUvS8Uu1du6!YK*G&VLOXlHRCKu=FF(IkNo_ z!128k!z=B?9(@872S5v{*=6WjNH3gAJAUYkC%^7Y;H4r>$kZZC%?&3E-qa#4n-YG$ z{5tlV`bCK=X~Idzr7&v8p)y!whKx;pP;V!X^4&igR1g*2j}8HyVC+>KqbPFthf}+i z5*V2^NBvmwfWIU)3;IBGEwFtYFWVWUoB2RyvL7S*E#d%FT_ytxM895Q4V_PCQh+>< zlu~L{SuQcQ?il+AeFdE87H!P8>HgIJjkGW8@`{o5wNd6uVn=dNX5$aDi14$pTSR=` z!YTmifM=Cy`Z=%xX-u&9>1bJBw3nKr0@mO&YfAp~^V^fzVJyvwMY(hM5 z=T^FaQL~&c{7fIT@FE@vI;GbS=Go0=v=3x<1AaB@b>U z;-hwvu#U||CUj!>9G3YgO6yQX+H)L6*ozXXaV=U_b`_DQWq#`f$?cZ;??y9(AcTLq zHrc9U_$w&NRKgWZ>e};_T#tf-g1TX#Ttj{JjKjCJqlf63U8$=~02ty9Nn3p2WX;CqqYS% zz5QZEArIj!d6Y0VI^JFWKudu=NFUPF=6TxRR|reQB5_2vIn)qBV}S3;MX1}04E3Mt z#5d$zK8z>OW^i7tXPB6e%UCqcK(le)>M}pUp6H17YHZ$`4urRAwERt6^`Bj>zwymc z6H+f|4zhQjlg1Gy%93Sw`uMScxrA;vQE~ta!zM?jz@&c;IxYkrPHXB+h4)S0@SIgF zdm{UTZqxJaxzBR!!`71;K*uco18U~X>AK&Pu-C&`R?B-Aj0=_$cxPzn{MlJK>ywJq zsw-Yj{^>7%vDCYw^iw(od$~o-Pz6ks8aQ}A1JFWnE@Ez_SYh@cOMFVY`?D$Y&Z~a1 zd>zg|c6+o8_xSfEUIvTsdiN&WOe=n|xS;8X;CYLvf)|=u($YtOu_6J z0tW_ukuKXj2f=f}eva;=T4k7`&zTqf{?>lGm&{Fe_;9R2b^^i}Krru0>ta|4^_A$H z7DO?PFho!p4A2C|$W~JYbWN&eW(4R;;Tmhz zkr;EbZ4D?Birca@{afZpp_|p2YAInGJ`1Fkz7A$droV0#{h=lZdX+xO4B%I?B_3ac z=7FCkf`P*_R`SaCnBPG1Jd|Abx!brVL zIt?Rv1@qnIGKpG7W-M54@Oi;BujL}Xdacfmc_9q?u&4#P2hPg`({??ZOOjRFnps_D z-f(IqU)UUW`f&U}`A@568jBEz<~CX~Yv+1et@-+dsV3RVrNTx?H9ht?VAAS0D1{G? zJbr4_B_Tqy_Ag;Xppzr)KXQ9QX}21eoMW|m_{|BBHJ*=OjhvNq(4HgLp`u-X3tw>X z9A?^?H5zIU4r9K*QM+{?cdUL9B5b=rk!&F@Nffz-w_pG9&x+7;!Am0;Llsa02xfYC z*PtggCwO@a;vLXCgarLHOaCqh;)QBGzd)|oeVtn=&wvyz)rOR3B)bLn=ZqpwZHq0G z#6YvZtco3reVEzgsfMR6A16B&XJA|n?MuIu8bp_){SA_{zu;H?8${rR&r^T3v9C(nb5F3yeC zBCfU1>1a`bLUbS{A0x;?CCtvBD58$7u3>y2A_P9vigNVLI2|Lin+b~C-EytjMOHW0NTui}pkxXdFdIJ$-J+Bm$%CN%mac~u zc65u)RMsVt!-|8Ysv6BvqDBlFKElp~B6L!lpd@XpeV9f#ZPtB*A?b!2cQ>(0KpkD3 zcX2g{WebJL!6EmdE>s!+V>?WUff2Qb1G0)SgHlNwmhKjxqoM~UZ>S=G#3}dZqbOgm zLQr$%IH~rG-VibZjQxA+wx_MOF@JC7m(z5WFp@?e-&dnA^W!f5(1q_mx7SHG&7Mjz zJ*FkzBLiO~YXM}_WN$-^LB=)#9j0}Ig(60{oTJ7L{`hY&|LX}pO&lXsa+ZJY)@FOggOhohsSKci~64T#~a*U>?#ib&8;moQD4mX2U+S(Fg|)$9R86W zITbI3PGBmng{xAMx7@wkfPyHgTBnY--U-MN(8g4;hg*?%-H-2y9+fMsROmUruu~DJ zD`y+zHt;&kEmb0pX<5f>5axt7b!mHhGZrk)cPJl8fFV}4Hof{DHc?nmlNe4OZlh%Hw~gDORC9fFH@ z(dp|iOIbEM2+*ogN5G5IIj5N6dcX2{rbl=|y=_lReUu(wdD=vfPY1!pN@X;H)!7M& zsVSTH?G;8EjqWqJgt8F#raa9{%Ig46>|d7k@)*edY9u$q-2MD_g(YtesUb(fF@ zeIca^`q$v%I*l@1*pSA^WwV15>IOc#+Fmv`%pKtg3<1=cn#Ja|#i_eqW9ZRn2w?3Zu_&o>0hrKEWdq=wCF&fL1pI33H z5NrC$5!#iQpC~h3&=-FwKV0nX1y6cWqW7`fBi39 zRr%M}*B_mXH{5;YJwIOwK9T9bU^f*OUt#~R;VnR}qpl2)y`p76Dk90bpUnmP%jt$sr^*lRURZhg{Jc|t% zzJ@`+8sVJPXQ1iJ<*|KHnVaNh6Bw9w7(H5d@A2z)pFDaQHfA+~;ft*Wl5TXgXt$X+ zw>HuHuNiPuH}l);i?tm23b}z`d*)Fc#9aSTR0**x64KPFxH=waD^aF`<3*U+;u(Jl z%Vml|ibUgNPW@Mu(3F&xqqX`Ywa;f)vz@_@ai=KchFb+T#v=)>bVeCp(|;s8%R{-yG(vI#MB|PpTf%;Q_dytxihYgUEEp*4UnBD2i zFzwhlAsbs^rvyOn1@$Y4a#xL*#mfe*-%9pKM;rMxBrQ{x6g=Z)-ac6r2QHFaIB3Cb z)MlIq>|a&HnWt;JF7aNioc_56#kOM7`*3HQOh2zj587o#jVvMmd0^Lq^}+G*kE4L@ zyr1bonUrLt{25*}164@vq#vyAHWXa=#coq+BP`G?NvJ{D6iI(?WK_#=?Sghj z1PAobWSn&T1JN2+aDKWLzLa-vkU}op+rSMu-^54o|YB$BNlXsc4)Pk+N;1Zjv_2G@*gdMul2v zus9!wq9-nM_j*C2j*4}T#EOpQH+mG;>6M45k1Bv!l)vdjfmgsSe9%ze*37SC0>9_L zi$J!Ziite+mT#sPW;8{9EdmpRcM_V2yctTOVr}V45Ya@X%iVpnLr%`<6JxcpQZJW7 z8cdPFktXB1WhRl~Hl4PUPw4E0+n*{!yDCO9mjal(#n-SeE6ATb`3BWpmcOoQtW0YC&i_4DFt9eMt#<$YtDl1dXA!$_EIQN?X#w1#3P}!YVg2_+D)GMjl zY@_EZ_ZKP?D)_w?>J6RZnB*Q7Ruv~$QHEOp7abg-XyAe)|FAORoics58~_N@dE!`8kvn*VMyv=fg8F zE;Y1gK-hU9#R`_&5n`$v&+@j=#2b-LIZsY&v=}NAOjfOB3*&2UItP}{OqgRpGh>_f zh%mJf#U&@U;;T#cyP}$M2?X^}$+%Xb$hdUMG3A`>ty6>%4yuP<(Yi8VcxH+@{t9(T zEf55zdju@GID-2&%(4Va<|Ra3khy_F5iqDnK(rPsYx`73WPueFWRJV)QFt_0MR4ew z^AAwRM+u8@ln#u7JFYkT)O+ zi#|KR&In+^((C^Qz6W~{byGrm-eEQBwWk;Gru$Vq&12PTBnehngdy#zSGdTlw| zntnZVw0Zw8@x6+gX%7C`9GLL`vpHbla6TX+B7XSrfgEy0hYHbGenBTju?E1^# zcPx@a{i?zW3ISa;V@%Kjgr2)Vx3UHv;v0j#v5i!do{bld!wDqWoiXLi;bP20NC_Q1 zWmLa5QI~_)A`d}#*aQ+SfANbQB7Qd!Ncl(>6 zheiX141UI3v(dtiSKg*zR;+|a*Uv_OU@_I@u$Sw%+tp%rqDxg~Va^*|OD%zXAYe6! z!Osuw69pNHQ-?@qEDa7bt^Ga?Xa(5g6(KJGSSDy#r$D2V;~$a?q6O+}b4^#6wsf5E zX_GK0Km%Z@vtZr~zNs08B zzlMH4(M*)#G5 zynvFiw~srA#@cLNhHk`!r@!W}8-+5UBM7C2P^oZ%kc0uzbTp>FHRO=xYa=v)0aQul z9UgNxrY#bF^%AFxsI;{sv#0ekRc8}5bc+e-tghcK-OU0FGl`O!q9lk-bQK3kz*s7? zV*U~Q9=~-fem_OJizGL{$4*=a7|@ZKwLY%#p@2?FP3Q>15nTl#b(ZW{k6q`Nx zOMonpItf;aZ4(|66znCH7E27N)R9I&GsIJ z*ClS8kTkcOvZ{S>Fv|`^GkxEX=rkW1(MQX6IyC;Za75_)p3!=|BF|6pLRsYUq@}YIj4k#cwM<(2dKCeZZpd6cJ$fz6 zXU8ca+ou~;k@S379zHDD8S5)O*BT7~{)Dj3LCoshK9dt=*UEKo$P_!yxozT=ZtBkj zev^`G~ zc4AoF3d|9i#^@>JywzuSvW7krJ{v(4IX&@ZU5})Jy)F_p647?_s=B2@mHHAWI5l=- znNFit0x5-AIV}8zv2z;Y-K9McGGqK{hU0@PjRaEJG*_X4Jo*Ua=DamQ8b7f09*Mazbhhn6LBj%&=C`Zw8uz@XoMbA z%j)N=G34Q-&zQal!IQE=*PWyC%Nzbkc?SQz^J9l> z3}_mkctbvtd6Vvr=Tx5dQ|k=lg-=zHk76OjP=g9IPH_%tWed^LXiY9Cazf??c$snr zz!4}Hl4G4@_xpkYJf2FXoKOO9-6J)oiWYVXuSJAY&Q`aFnV)5L@nU~x9O9VuEbZmm zRJHYpRyw?}bQVa47oYcRa)$0@{Whq+Eszd#|A;H146&zmxR5#?^3=Qdiij=KX-Bvd zk&plq0|^#&B~AjImXrDvvJ40$v(^a!JSp>w3$@6tFc)7&spiek=YVmKkS2(%uo;S; zqBCrWkh+zGsP=MQ_NEL>&43-zSnE7k>kbEB)jJWqRV5}k>J?*Rcn)jx=c`6*MZ~|i z%~^le&(UQK^+n_>?xxUQts<>aPR-TgOJSE6Uvk5ZUkP+>VveCD#mghIG(nOynL#Rs z2$vVgxk2{9-OsO=D`|Z%@x3w)&CjCgeKN0P_V|BE-c%IL`c-nXVk9#S-YNj3*P!-C z^7XvFA|Fc zQxCIu-q?|)UMe%sa3wKx=4brU5@->gWRLT4CltHUIy;}a|KrUJ{a?72odi_$Jtv~g zkQWC&u|Ui#HMR{#IS~nXxMkhhGSf zY@Od4)>#^qTHlZOA6ih(()g<+OnN3wb6{Q^(N3|JFQ>wk@M>uhX) zr)h?8eW=WL#|vUm?PV9~lwWnXh-FzzJ%!x>#?s)dgZwur=+ie)NL%H#f~c%;e2_O? ztRDfj%ldcOwjk(ny5_GYpz}QMZ&YY${hM|O2AyZWre5QzFI62O!>~tkqcDdtBY{-$ zuP(XeSh@3Xk*0o^Wa)qAsTKNxZe}ik_%)PtKt<$f>wWvxMo*99^R)3&;*5cJd|r=q^}Qw~=ZGkr7Dg^@4b4T-b$ zv#R2Xe!$2km%(4C))AfZ26hixuAF}-+f zZwfDSoMo+1_8Bu$7xPtlaoSMSxTLFO1~#1+>uc(Djj`l$TpKz(SF{%R8g%NC7!}{IaPsNc}&S&M`WZu4&tu*tTukwv8*!#C9^# z72CG$WMbR4ZQGgo=6>GqNB3UctM{K?)xCF}Rdo~rsc4{MqGT*X7Wi1f9D7k%cwP1a?U&RIrc`PKXV&fRKgI#_d$X(&SXS1O&!lRovJGQJQVg60S*AF9wDZ zh9=X$yV0h)E%*z&CuydVyRSQ+JH9@TQ=dpevf`7)2Bn*IUCx&ilfbHu<}m{SoElh7 z39m})DpJWpAR!Qp@x3%)%4JbzWB4LPxVLQRSboj0EXO)iCbQ->>+)1T{T~oy%}-k zZPiD;=v1*g?z+0TArLF-QXVcw-NDyEHfrSgjtgkt>ep=3P%Q6WnvrJt z+4RwtdR4Q#RUS7xS~!Qbs=E;lje z53Oy>LXWHQ$2v+95NE2^FeUsgp1y4FyvUw1VadDrg*G_B4otGbMYIlWq>so@%yJ!C zV+>DAk}AXSYO|>TXO$oecP3UZixgcI-#ccF znJq7up8Zjx1AN0)D-mL!udb@{XsbvCrCnAgur+f+WxIfw{$K!o4 zfn|*egR+@Cqfbd)SeHLedNl(erm}_}Clq=82-p7cA`8%vq@&iJlk<}*b;&T@mm@wX z}1cA((mK@yos zPW0ZW@JX#qtMNijTe@pH1gG4`^<{AR@h;s(T} z&3#(~u$Qi#%j!zW{ss#Xsm|DQOrmKNB0cK9N~^$rZJLyDEKoClR=V$R;aujtgT#1b zA`U4#ht`VKoHWuito?@~br1x@B1L^j>cuo=exM!L_g$Gz0SpZ^`C+o-yaA}LPlf0= z^n~1R7J(vVSULvS{$R8709Q#R@ZbWBjZyY(AbHaC(7|(oHtzZ@NbtoHn;_g=+H3fa zy!pe)r}Lf|tftQ|FMWp`rny9HZ;N&8jH3-LHf6@ zM&!|x^O%ZcPJiq#EK4mpID>Rd469b;u>zA+kvrUva9OQIDXPl_*T6IGn29GAYKQ0n zASA;!l#^KpqRw`sb%#}-2}Ud`ZK&<)htt;RIog2CA2(DI+sP*f^;yl%Jzz6%{0}^a#h=NyKLgPR? z+h)#g+PQn_^B*+snviZU(joHWllOKpV9D$p5IwQbsoi6pC_`)m%$bm~s>3~@oHT|MFt~;^&e$k z`!AZ@c$^%MzW3|Jt;kr?yNKC`4g;qphv-mowYqO~qxIDHG&T*1Il;sp@iK|H~; zRY8%8d5`6`s8oac%2s^AFKN^&{3cN##QttYZ`4w%O1kG)vS3r_nko@(3WSWY^hy%k zD_xZkb0hmkTBJdfu$mY-P*DN?TlRxM-eP1OB3FiJK5ogaE%S@t)Zzn*d&`8NQU6AL zC9qU0aDA(=vpOu~8PPvMOGiOGcbw0;i&OIZa_^2(khD z;&117LsI_yz=<&pOSpyG0=nv1z6nB$uqp6DxHM4~*{6ytIT39}>Z<;BowyqFU@THt z9tvb``MojCN=M7LPJs?9k>}02!$N}>-Hdf5sj+7zPsGcEpJ72v5=@DHxVbShM znTCaXY66l$r(TQRo{5JpXcn1GZ4$yFyu=I%t%@xcR3pUKP%~9_4y2j%Q(-)PkDfn} z9I;eUk*#9=IplZ{KjMiWV(J5dk%FI*g!Mq0g2h}Kb^c8wfG~@54Ml|sRB_zCI<@{6 z^>GrT2@cGf?mzHC4F8I^S9r33+|on(dnh|1Z>%)RxVYT~j~E*AoAP*jexWIP76myS zPmxHAcOLo4+KFvX7leBb75ClA;yi&nJL{!SU3@ zWMvA{qx5Pu{sRs@9^q`F3_ray9*Q&n76E5u$F_G0Tl}P{sn+HS)^78+pUqFXayKO{ zi^~-OJkHkEj&_t9g1Y0<`H^--_8B+x!zqT9=#17`5WUA@RUk-mPwZ;c+8RhB+N`=K znJs*ymvdg07$&iKn$G*Mk6>^D1*zhr9ipPUJ%R8Yk{s78rc=2jq zx?!bk{FtF%6OeF@OlMxwiOa{3JZqSunUzIK$Krxk3j28$=JhtBUVAPyC$e(tOs@2&>aIiai+vP@s~9CD!K+B*cxuJH5{ZoroEdkOb07;B!(&?FM&tYiDzMEi^#Kvu)$>mUMf_&sIXt9V z1`|{6PuR}`LE+?M@z!%&B1y|M_RaF73@U??hm`07>sJ^Y!2lLnd(8Vpp>y1ny1lr3 zl!y`Wp!J+)z{ok;P0$-LP(J+_fL&p*f0=;J+-ts3-7_(rS04#pN+)SQz)n%tOxR6_ z@iS9s7}z{TeV+AZUSI^TvB)a<)51kpw?}19ciIMhgxJi+fk$dzsUIxLVQ}Nw6>zz% zYtr38Z538+YKBWeW51rNm{Tpg2qKiX&!^s#!ve?C(NY6ft*#v{M7+r!kFvwni9Vg9 zVE>1ImnPXi@nY&lD&bwEzxTI{dNtF18pL$JC~#UVZdYp;{nAd(+?7ql2-I0p0a3h^ zdE7VU7KJ)trJ-z)KsCRt^QH%e#W!F~rPh@w4+*$@ zK4)>+_gDsG){RQP2XFWefCz@LxK4qr#%x=WmPy&Qi9cIKa_7gh__E4y=^U1@#vNfA=^ut28X2_ieyr<^WqKZ6Z-Or8MH|Ad<`?oNVuOc^D;a300H_ zM@89Pv5h{>T$*iPbD?^mIOFe&5u_Bf2CQ{5|AFdS+Fwi*XSv_QuaOXm*g$E@V6`8E zQRKWE^)Z_$Y0gO|a~q&cE+vcV=jv9uS%8|>#SnVFD4{g@06WNT*HBsw>2!tC0{d{{ z-?m)$6BB^p0Jsu~0e@^&+QoxKB>XGk((rAyZ?!zC_Y&)X*aR~{dd)P4=tBS}&bgS2 z{qy^PL8LkzJ@}LlCE)1?0?Rcsi(8&_kltfWR6M$DM zB@k7TLP~t7P?uK;Ts)*HwZe_wZDjbBZM%!6b?Jhxe7&{7sfsC;9!MX@l+!aDwGefQ z4x^TY#)Apr3tC6_!dw?x(%AL$?5VUr|4VvE0UoX+_onVuhyG zjno6xQ`GYfpa&yn`;1$$&NDY>HXLD&54al2@3A?CO|q4u_Avv9^NpXV^|y@IoDy42y31Z)~eiGpE6 zjFQWawJp?DvP0va!#N^er>_g=QN4?!$QgS^+?fbZUO$e-pB_^&i#<6xi*}@zikhr) zQ3p!O-n4OUat{Ysi^*BT_O2f8jyx#;l8S9XRMCoMZ2A)_ zX({EoS{qBU0kjhm%{)Y@gbA}dPEho2-^nP_{xyxl3R{(C!oi@~ily18z0RaLa0~`Q z-}?ov&mj*bb++L+Cn&la1{QW6ioeY&-ik0^fbt>FeFp7$E%vk?b`~WsQnvbzyglt2 z9`}pj;QLZOF2GfJW`1Ani=s|17tLg$8U+`!R+s>XANYrUg=l>KXV@4VJI=(f0lM4q zc{QF7gEfqt;%le{C3*5Z;l{WC zFSAqZwN$9H)7C|NkiQGy?ue@E(A}7Xg?|NcL2!wKV2fX9dAtshHJ||p-F=%=!ny8q z6#06TOF*fvSQIa|E4OQ!zt_m$j8YEAXLb#*=)p7dhKLDe#O1>ypGw~Mhuiss4SE&o zUCOJU9zDRJ%X0NAEI1iD47H_vlSGZkF~C$89(cGGOkm&MeNlaq=G0Z^LGoC#&+(5; zaLHJmE~eLwe)P>Soonm@y#9COv=j>${%>Y)XCS}#)W(vgsSVQX`2E(M^D$y3#n~@U zgV@DGaFc@HzP4;aOZH2b_Z$V?;5?hCMg* zn!6cCC{y}g^m+AoL?$;eAC=f(GWM_EJYNcPYf@{mDE%^ugN=T0ugCc2Ib$OHbSS~)R(7Omi zjZ9k3U(d1-{M$k<#<4`~+j1kbgN}?&yxq;C&cE~NugdUGNRR`qr}^`}2t-ziw}9Yu zND&z4NgN_teN~?NfvUpDyi>c_B^0D$$U%w_9IM8HxQLYy){J#zv$J|XC2k3T=4g!TR3r2+)_P(#EJsgpZU#ejJ820y9k*w+P@sqnB zl9o~obFSN-5jU6z9D=9cynbWie^HJCnF-Ek_hYH71W5_lcLsNLo|gKJBcNoqk5c#` ze{rg+LtS})^(X{gJxq+Am1Jg{hJ6adCBk8!+}{d>I_;u1kC3In1Oy{5Hv>zNHJZs5 znjAml*}FNZQo=Ul=BGBKuJg#6S6ZrlZyojk7hV6B@O&_H#+`Ni^H}s&=v1+EevijAm=O*FaVtKKpajjc} ztaO=b1DMn~BYxd*1Ljzw4}l3A@`qiyNuq=mV%qB(#Sat#fi05rT^EFLO~bNLgjSc> zSJeJCu>K0517vo(tmJk=ys?J>M|?&{ev!nS5H~cObS#1rSXcN(j8<2c>5`D6w2tf7 zjkvK{8I{la@AP+{l|PZ5ymZ+vIZ)x*a@lgzr?3`tKDAD@YKBNf+PeRun(}CTCE(QK$%Jyv^`vksei?l5pL8gQ{6s0E?fw#I?&W!G9 z+C)pZbxWvq8L3$`GAe}p$97nO+37R48}bxo#dEr&Qg2J#ZMnsBo=g#@IeASh%rv$3 zCyobcB()INWZIHZD`1NqVUEe;JpLx>!$#$~`lfTHjZNvIt*&KmP29<5qHD)>(a~>x zDT_5fVT~3K%Ybc3xNBC1#@T$N^+~ISZ6!Z%293?xQi>N0^`8#KfX@*0`rA@o@8FAT zsB`&GEUOCN_|)~=lHXT#bL%f2XZWAqP55N5u%n`YbLctRQH>0A*QR;vQFGqagnY+W1#k`J)!VJdJRaXokyH%~~(F{OUSN8mX&?MrQyK$stRrJN_8j?Wp zkvR4O{4Z^Vqxx%u2m=IUj^=*~`lcNV5Y9)}4C60QCd=D9OJJjRd!f6-KB(4iLqL0d z06RKXrX;z+KDpkwUBP~_lcJsC)qGnR83P3c9A(LFOs=@F++QC+{gdCcPuUTcIvlZ| z1hzapkd$@yJ+ayMyfQFU1*rdhojeGzLl{LMmVJLfqNj@w~3XBub!DJCFknUoW~z8qjLV2$^@+>HX1 zzkSZ4A3OtiiMH9G)F{x8-`pxn7O@+>p8bL7A}3@y3{7A@M8Vy*CAVFWIF!T1DH%dJu5FlvnwyLF0#cSdT1$M6# zZ18qzTQfAt9;sl^A2aK%_~@pCg>_Qp()DFxmpa6s=1SZ4*=uzdMYCjqo;X(5oMhv{ z(dB(zEBvvp#a1pisvEaXUh>{EKF)%>rO~fl_8B-_Ime(8ne*WlnsG* z=ur;WDhz}R_=p6&Me__0Dnqa)Vm(Gjshb;d)FwR&H(;EMbdzAFeKFCT-Ig4E$-4aK zGi-#-;?EInxP?iXbRq=$>IBkhmhdo$FOD!Kejf)(j0kQ2kZL;=o?Rn5)dp>0x9TTa zCPh;SH*Hd8zFU~s1yV6Aqabc3g)G)YP&0~_iN4(1;c@Mm-(~T@_R?w9F6{(DUIimi zp3cI_mO`0P?HWD-gKBwij}GDE1U1oqsx#4xf_P&!$(ge3=p}rPpg(z7QtSLwVp%wr z)b0###i4ADrG59KZ8H5jrgmQYIGWL*j+|7cc$#s65id0@KZnq(3&wC@I#!RvrVJD` zc}=SdM#lo1wY7qQ?%8r4UAkOF5s^!cBg2nM=0e+U=;dHNa8Rk z6OSdR1P^6%75kui(xcdvAns#PwNEUe)W6QKvx++Gk|I@P=%B{I!M1%mN#BD~Z&~S> z$J6!HZEokW811c=}jB3iJ%ga)vN0pvV7DdI!MQ|gk(^k^%8^T$}3nBR>8|jLy4Kc zE=NuJDc;yGJK4Q)RVO0FMbi#2d?W{tqrvP2@CjY;agYympLu+8SM^1Bm^UyXv=)A) z$BGy?QAf}MC3Q9vaj5ue2ht+%CG->!2?Xo*aAjdD>+D7_N2BVDezDXJyMf0#@!V-l zodn=f$EwhwvPjP_`FNCTC?>YxIjNyQ{JA`OmQ^H@t*Ugyq^(rOx@Jb)%18SEeuX)K#ChVAWHY=G3=!Nw39B8L}Up9V)+ma4^A&pH?m z!ZxP?A|Ow92k*S%zgJf&B;)6NY_3^}60 zB^*Tq4Y^#YePB|#FBZNY8^FhrqL)yz@kIB=2}87#%Sz7pTM@ebhNF*?h-zOlGaGfv zZQ6P7qKX#@;EeeS%nI0kqiA2Vr6}63Y&%v5y0ML^&*z*~kj@ok`vxQmDwUd}iS^e} z-?Z%5Rm&l#PM70=N&Wo!2i0KZ&gRQpo@dtJqbT)p_hI@y$KO)UOh{V+3hcj2VhIFR)|`=Pg4tx(@};;bTtOsuNyB$QXe9pmHv*L z1ben*Fi>HnWoMC*FSQmeJ=SCE7~L=5TdT2brdx>Lpwa+1d|$6We068K6Wxxe&F!baQ|&s7pR zl$NXuC6`oi3J}9TYEA17G5kP5aP5fSaDISnI#xzANK&8QAygL9p|IKcF>Js?yRHxU zXvzf=6iuHcb=PWBZ^DVxxF3fDUpU6wevU*hwgyKVtY3u>XIdUCa0x^aO19CqYHPS9 zu`dYUXsTy$uB%DR^04ViJd4h7l#|9UlYmL0#XJR0%{SPhqaVrB&z{5U&dg+Rrx@9o zO385wN^)BuxZOicKQ)$`=k7N#;9Rnz+VF@5%Y`gGshFy8Hw5qg1W|DShA!yJt9nJq z$TD$(FaiuiWu6WUWb_!WUy*ZE@V4svwd&C@-1t~Z{HSQZ`B<(gJ*A@AOX3QZPVwMQNTn>MiKs)cfbC0;XP9g$wQ(ssw*!|cIBS)~BQVg{XNM;6Q z;Z4vGuyho7&kMD)b8KPy{I)E0CA9=YS*^)sySa<+o{t^_`#Wr&9lM#6YQ7DV>6?p(hnyN`!Gj7pUlUK!ybM`VhCQNEdRJw0Ukd^J@oN^+6;{FFz;7a!3hiE!Py)C;^8Cbt>|>vA@hw*yV9$+*+F}_|C^C{ z^$4FY6yp6QXa@b-Xbg5FDP(X<&GfJpd+IZhw5H3X1pyX`UgqephJAD<7@yKcmyak{ zBe-1l&h}3?t;+`H{Z5<-0A-Ed?nmf4oZn+6q=JKLD0`|9;b#lCP+P-NR`c8`gG}~o za_Wop;jix$On;U>r}s_Z#~q-fxnlbMCTVSaw6-|ETsY)HQi$+ZohweoYG;J!#MmYU zJ-&E}<7=c5?zK`~6X1y;X3s^0gnjdu`^z8PyA=m4zB2}%OVJ>2-(KV1!c_UG5tvz;-b<-P>67PMe-{!%S$+ge-~q#h{~r!iBIm0yR$+-JIM$&8J3`IN$zZby7XCwIYN&KX**xR?3#I`P@$25sP73{J~Fr{&VSx zWjo4(!WZY0!WRLG+&5_hs+36ennIRCGszV{g{c&nVv<_CY*JB76~&P_B3|dIkxj~o zswLyq+@`s3IgBXdfGL(JNd6+zp~TOG2=b5kop^*4-kRP~>$H7FNTn$aAkWn2(`%K@ zrFm>^ze(m-JNeWHOSG8y%D)sDXEXClyF~dn{9#!|`|qY&trq!g^80r!*MCE+{w?so ziMQ>7@&6_Yxnljhy1zm7fOt$qRr3GE8*nPAj(P{1Ed#RkgKMS8Kldx-Y36B97IYsk z|9}y6IW9i}gPJn_ITCs#0(+!0^=F_B17!!Ja0Fejsus9etsKjEH{|gRobo=RabqWx z+E&({i>_*%E@=1X|NH^2N9Z7gBRCL{zZm~NrH23ixJRLXwVMH>*4=hnF@c(Vhz6L? zfp{Y5=prJH88g|6MHz78O^o71L#>V^fpA29VW_j}65@zQ*^j4uK+%Uk_aBf(U@o9> zNJyvCe618gc(S4%qX--Jg9r=UYJd}3g)VM{2sg3JVv3zB=}QO#SbJNpmK#M~YdHii zU{sg3c`hw~d2=^L3ugw$bl$tWmJOz@l-DIhqBt!HD{X}KbwYy==H+zrbaN?|>TEYr z0CKrru|C>d!2)@Ga^_fEG(5+9tE4#&&R_0^_9d@-J|c81x}VBM4}h2AIy2OFiy9l) z2iDN_TbnQHnDsiZ1q<~HtUsOfO(hHZK(R8@n&|X&-gme5v8YW}j;=D)lv_A@`oA1+ zNUKZ`vXjqpP>7Wn$t?Ru;6+8)qSGP}KP5OAm_7UIg5B&VzSzLZ|8a+!1NZ5<@uMGk zC%5@!@%x4*mY3luwenb&Jx8X{=A`6&qZX+C^T;Z}lVq*`rMsN|JN}nXopeTxk#y!Q z1;nHgX~8#Wp%Il5CkUX>H2{TkrZ7rd*OxBTr?aAamEB~ISQMB2*=}#sQIjND1HPa_ z`VzU_VYSd?wZLZglgn%4^}vuEa|9P^noEhB(MO`zY_m{qND#(h`HJd6D$kG_kme5{oszd&i( zEO$uPV&<4Nk5pW9Y~0A>hUeCvz*EBZtGT4R@XC&cP9DRNGq&SM(;Fuyixh&|s@)*| z@R`oGyCdd^huhWJ8piCIg>D{fJaRF-E(BkVkmZr9$R)jZlgrWyD^K@hc1=v&CD8pe z|GW*rcuG~5uTj?g8(^WxCdG#oo4vAFn|A@Rd|ExPvW?j!sPofTRq+M|eN6jwD!arC z+^(8p%`i9gjQ87zSIaT_w`yIkE5IZBJF{Y3?WWGaHoew93sB1j*FTe;A{Yecfk@wu zpS8McksjKqHCMF1dFHK)V52~|0NiRI9G!n8tyZOz2fMkVdBpl=JIpar9_Zchau!WviRC`DxWD%D3h_317BbUl44j1a4&^ zGs$RKV+L}b>ga6jc(uQI1uWd|5+t!4_96Io%_HvJhrg2uY)acmo&SFF&mSd9q|{jTx^fJvbGU$-P~^aGpDRPn#1$1;sIRL24$V+`egtex zE0k}VA5-#zF0nBs%l&y#BhpJ~zUqR^xco=d$&7V*PH zZ=(514Nu-@FP;;Wg?->1LF)jYHi}1_6XDz?5r0lRq0^lXaH8k<3vAvt#)oP8Jqopn zrAsa?bw*t^03OdK3HpRM0`p{7XB=%X>0D6C*+UeG(3y##xz;tUM1{^fo^F%pfTlLd z#?dCv%;ETjo#!e$C)Lv`iA+?t?z5~zU%{cd-;DX>v_MGiYDW9< zxgX|zu<79r0gb4~B!MrWUytBX=pu9m7rpvVIlw0`O1cN41Fb?v&Z6_1mp2eH4{GvQB3CrHZWyrJ;VnXLHO@%E zN}Lo;kSiq2fzh`?=X#gM-#%8;q(d{1S4eY6v`^npV%ZZaTx~x^K8$(CSiZ=xP0G{T zc0(O^50=d&>c_p$N43*lVIrBX3n(=G{Ivvw*be|0`dVQ&l^=&sB&pxb7BL=}$~X|` ztZcSIzQG9LxDz1?LIBcJ3y2zUcP~kNIxR=HnK=Z z$Wk>Vx#^8P+vXHHZAm8UFFR3!#hHtX@Y<}(s$-Omy#$v~zLk0N7ajAJ`o~JX()PFc zWrpRbuu*pK0Y{Qv34&GzdRHoS@k8)D4bmvj40_&)M`F5^D#&F=t-fRWF}}{L+uiU-6_d--48;;BRMD~TQn3cBij`+7B^`ye zsH$AndXoEoe5G+SztfZ>ycU7WwiDI7j(Hy<<)HI8pVpN-D@n?jWThZq|4u{WT}l92 zgM;60dekYz?-Rl2H}NbCJEz1jbe>FP6mCEO|JH z3_(<5pMGGP-K>)xQsP2Z@yxwywe=+~J8hr?y<61l@QJh!w3q+x(#_Sz9{Bx!pLVXL z{iT(lg=r-K!a?=*bUB9|;0w>|#mOz~OgdS&|qCbH}A(#|zMe z6uhN4%e@WH%s+CNx4`g<@yk+@jM2&i3I*YUczoxe{`UFds_i7|K$3OrDWvUK^)PS? z(^0gc@Mr-vEMRId6m`k1!K4hmkN3)Qk5^@QXnC&?+bWtOgAP#?ryk z-yqkXeE_ZvHcB`Ny#azmP1R>8^$}PRZmr+)@s90MQEgqYX4H|wG8~Ib$fDbyeKRg zCr8v{0HDv)uS^-HK1K0?s1#GqxSF3QK#JA|7|!-3K+AsTY$58G27<7Yzi!9C&IH3NshKKtMbEHyh%yHtJl3+Aey;Lh59(yqb??B4IeD zm9F)fMrB^tbIcgRMuM#3d^gvtS4S7aPR#7$h;)>PH|;*1>MMn6A&JiwkKa5Ur9(F% zL1dS_1Db1u`Yo_*JP-F_C^XB9Z1L%C4q+orHgXL8I1Qzx`W4jrt?5EU|8G;!NSzWeNG&Hjli{v-u-D zK|+c?Ehk)<>H{WSI-Kn-rf=uD{+^_AaB*JD!npc%U;;R6;)=QgB=CEuocaaljF4O^ zzh3^FZZYf2_(J=uj?=7+#$yjMqav7#SK`)IPa+SN+=qlo_e!s_>W_|fWSCEG>IbO+ z4~)$s6yV~rwtl@A73o)$Yk~A`&@)zpUu5o!>pQ^bK5JG@s%yBlD8XJoz4WyhRr{-` z?Y1%AV;Q(Y+WnWiWpoZI&hV+9#4!9`FijOI@(C?1UzJ^>n9lL#QAP-l!i{zRSv<6R z-q_H#O;B*_X_3TXT$HKUC@(K30Wj4E%Fq<+eqfFlpWALXdOM@zUE?2&^x{Qy^^Dtt z*Y?F&^c#zfut^`~ypB85(1^?KWviDYa?{pmRuWi<*D~0!==#k1&d;P@9dzR${4gPB zwpXZ4yV+KSPcXZie_65QSFS_9K!xMM7Tp>3_QvsJ%!ks=-y`(=P~s!T>LVL`=9Fn( zwrA;<@ShpH%kZK^?dCHz9;K;XWzc*$k8w!=)r;%MyJB`A{(L~!RKHz5kLw!7l}#vm zfdT(gIdpqd2PW;L{|mA*)jiC@ld6k!y~x7Vq+SD5%{FE28WGgeY&{kY))D6f*D25Q zZIKpb)^m&1>KPLxb=G4OC^kX6rCPowoo~yKCR>iMApU@GvgktHya9$ou^;6|xY1)2 z77Yy*2*QhNRl*Z61(u(lX+Cs`!LhAByn$as6T5%IiG(Yp|Eglf-rG+vBMiH zNSRL~4z>Ds_`*DKHWA$IFyjUaiNWXB=oRPVpNREz~ zJdb0>;6p5v6{Ap$$6i?8IF(M#@^o+V%BY6TpW3(m|8$-~te>WSGA)dn=IQI+0JCc+ z1Y5UG&yN3{fgyr)pIgpUQ2yMG@mf>~r-@em=hB4Fs zPb*keoJx*#qEzubR$|G;*rVNlJ}u6i+w3bM2#6>C|3n4uC`O>oe;pP>cTvtnX++y$ zFws|ab+tA7kWz5b7Keh1RemB!_9(Q5T@M&c7%-2FA?<6G&u6~%6Ya&Z<`zguZ-j1N zUEO57^4w-*X9xj--;nh%YI{#dM+)aj25BoK?+CuStuN0U+pt}!hZAcsK7(+$L-+A| zi75A`YLcPLxgP>|q589cvPj-(Q-~QFwVzNdrq#xNZy(E{6RzPeFY#v$sNQj|a;fsnxzI(QS z{VxM!EhB2fwQ1s@ODoItDdL!WmT2NhHhUwuspBfFUp5T@DIKRY>vG>{lLz)G7BuoJ zwpEerKA-82becp1o*+DJ>_L7^2=fnU_9O77RM<8@$jNktpD?X$roUS71EkVyD%j1m zi;9B(0p=z`tb2#kAf~F~b4j)G>2^Cov%uDKasoo}w8VVriKr*Tw%&Zqj7~!Sy7;1^ zYXoZCSciBN^qHn`ZBGtWsl93LukGbpBV!*@Rb@_{ngsW#*s99n=UBvfoEUa;`FK47AVK3Z(Kk(`VMK%yB0isQfAzy_3+`v+SvC`vx<*mRenZ{rYe)+FRhOGb8<>o1JfoC4lLp|Q8h!ZVWpYp z07yBY#DyLjqm#Ft%nC9?=7gD;Q5ew0z{kR7g;rohjNHvfHj3lzM9_A+B0g#t*@*@9 z{}HX0C=Zbt-1H1+v=)mJxzxka&}Zhp+WrDpM_JLG{nPm;I$-s3wqsAM49srLc&@FG zsSi5S^wPxDXRWkHj_AgJiOi0$SLF4XOF4+)uII;p@9csmNs#=Xu4Mh=zwZ!?83ZP2 zzXTmw?U#$InVqt;gQJO)TX9nQFNFeHunGU#0U(YKcfCc z84#4Am^@i|WI`3q8)xJJ+WL)Ocu)OW2EQ`trvMLoSx7zacwbm6zN#CgSZU@pQ&aCR zzPAo}yMO;2Yk{QA8Ljy|n6|eiR65#dv@I{WPE?jW&`jF2*oHy1oZ>3f(Lw{$22i%J z$ZZ{W>v0DF&zlND9Quc`Ob->B+m;Wh#&kr5&d1KptP&lKZ9ffd_z-{i1>s?(MC!Kc zlN4XC!04kblxYWJQI%0fNorJ=_(cb@oSD@zFgPu`gNv;sJ&Wo;RFc77Cbj}ZF(=}_ zh1nhC;t&HEzIbjDwXMUM;e~)lHeGv;tp?ha{OFqb#^J_IjDbO#@TZH90(P5p*I5hvP54 zxh0t^54jbYv)5d@)6zndct=vo?){V~T9*+g0?@lE_Ss9^nBNUh9nOK$dv>AWhxfFD z6#^xKpSd@D+*JeQIFJmZj}rJa8ls@5H2WI&ZSG5fxHg^_xoapOW%| zOow14uOw#3p6V1%SNXsjPT39#z4-#;Op=pZXA{=Qs?W9GHMIeh)t^7o0(woLngo8H z4+<`;3k_TF3ii8&u70}@15*aHJ6uf>^L}bt?G_vGHDOJ#Bov{K;>*h3QRG}&gQA@e z9uuwy{Gu;!pid-0$Sm*--v8_BhG$5_$izneQaowLRi9<@l0X3jTqMppT7(t&mgqZd zDr(dm2mtDIXaq9!9H6->&ZG}aZPHH0aT{I$=!SpgV87(Dkm)+bc$OZ3T-qn z!OMiD!w1mEJvir zW2aB4yS38ZKex_!?|*;5l|zc^%zwxkMacgz)ng?gr$HrASK=q_C1C*z{EtQAsZzj) zn*sykJ8fjxA4I<3d*+5lhOqoVgp!?FJjzN0Y?J=AZu#rr?qUAAdP^kq z!-%j2#;2oW!dx)?7og3^T15{9j>1Wj-ZG`KT3Kyn$y9=lHG4H9e)>KgFRGv=@ zc=wADdn#VCmndt<5**Fy^goF*{V1TuD`h;j(UT&s-&L=ek|zL~ziK8}$2jZC2=^h57nb&+Xj0;6SK0M{Not zdZz(j4-L_ilW$;OzN@|ih7mQU2i-~jJ|$tSoAseoPDM>*%W1v2)MgWKlT^6ZZHGNF z8c*EwJ6_0X#_|qDK*Y&GQL+Wb5n00*6lHD1u^afa915W- zT?Loj+aB5k@$jc%8FKd!@1QnC~E88_D_bL04aMukP?cxyVom601|3fVoQoI-RZwN7@6Q2ln#~spKR=Ry(6IxzC zF#%G+G2D|id5_3Z6hUrCG9IDR-DvGwThMI#;US{nZ6p)-TOnW1-kx0TTX2w&(1xm(aP0F71hR_K*TMY<5a+Phx^w{W=@t17gH^mSK(im&ZG=( zHY+&j8`#KC*)CXO1mRNQ2prSNvye;Fm5%5KQCx; z+dA2~9tVLR*2#}wl3kX<%G~y*mW&hYC(@b49;C3o^Z~v_7$_x*N|I|v`&i45IX|B1=4vaVd3PpNY;;~A ztC*Q@XS!v7{8;phXUsnbA-TMXmOWsCxte$qib6tBnljH_wrg(qy)J~r(YKJKiI^@L z32i1FU~UBL+>rPfVS4sWYUk4F-yrQH&d^$snQ+bh=Grrl*yp_Y6P_G42ksY7{XDy!@BpD zR7o?eFWUQz?llUyQc1AcFyYNn=wV8H2Y518w=C)>qG}Dt!QVs|`{G*hTt>yKL6|Aws-73L-7Tq6n*O^57tyDvcRy5%UYtiLUv~R9V`;&h>u37{T3v< zEBXKCudNlzz882L^h?Hd@5OHmzJA%W>qTRDqg3I?%i+B{zU6xQGfmPHm>A*ke=Wu%L&yh?jK4PyH&G0^GizJmh0C&7taf*Z*5)C+PrUhW`)J}iYwoBdLQi! zymZKrJCpl-q=9Zvghi#~YAfIYXmtHkldpVts$g2*daUr-xl%9PhOn4}vooBx z>sA*WndWYo;?1g_Qz?|5Q#tKlD@&m0iOKa%0)at}MK@K>9kr5nK3KR%deeuEts7sf z9Dg_AUd*L9mK#SdF{`(~aW#FXyi>J;`E;$gPED!!y#?=?Rxim}-+3Z4@##G+!MZhz z50xuMN%s8Om$^jdSm8%LMah3l>iHvAE_{D<+mdXX^!xL>&-kvnt+rg?s><9=mrW;J z&Qr=2>`l|(aq0Wtdz>+x-?%TZ)a{LWl(}xNs*L|lqZ_YV_D(#0Z&u%0rJSw3cc&kg zTTm!^QnsnpO-XUv+E03`riaII-*pXraqE>~$i|mBB|)aSMoyPc3anhatYF66U$rZK z@Pj%~f{}?Yf+zRPUCBB*p(;Xgvemp~mc!G9W=>u>PmIY$U~=F*naQ;RqLUx26kvti zt^R+WC=uynoD+HdCGWoQ!JlHzW4QPvi zy~J8z4dn~9WW=t+?#W_cFh)`QKm$p!HY@l>rpW?}M47_1;Syepv}BO) z$+1T4#Ch@z3~DGQ#h6Y$uviIrMFm75 z_%L*!57z*(4vNChmOzE>vXH}}85rgOPp3!q)hcU-$qx2Xliyn_gY1-rpH~bFEJqZh zgzZ5py}_#B$KL`~*`cTsa%7ln@8|(`KjI`-1_pf;RUXchA1oD}+`rUR8gbAhx`j5A z?=OvI1)s+^*>RaD(_NscOXVhOdMbiVM;w*|Je&{3bX^~yLfOd=mdVS&4_g5`R2N0j zt5C2L43-axH1|&#=Wr3=B#r3YSm5zuZm+d94eoZBHsE zKUgk1*`f-PT@V9^3=9e=25qVaDwLVLbA`MNVnm36K^{dBLpRu2{@vi5DT5dWK~EIW&pHfkaU4roNf6g>=uCr>T__Rcg`=}3c15@4P_ a%EQ2*fnt2> Date: Tue, 4 Mar 2025 21:45:13 +0200 Subject: [PATCH 04/40] Add Groovy import order to .editorconfig --- .editorconfig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index ef62c9e2..71b34f90 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,6 @@ -[*.{gradle,java,kotlin}] +[*.{gradle,groovy,java,kotlin}] indent_style = tab ij_continuation_indent_size = 8 ij_java_imports_layout = $*,|,java.**,|,javax.**,|,*,|,net.fabricmc.** ij_java_class_count_to_use_import_on_demand = 999 +ij_groovy_imports_layout = java.**,|,javax.**,|,*,|,net.fabricmc.**,|,$* From dbe1408a72dc0040aa962cc95b23cb3531aeda26 Mon Sep 17 00:00:00 2001 From: modmuss Date: Fri, 28 Mar 2025 12:27:22 +0000 Subject: [PATCH 05/40] Use a hash for remapped dependency caching. (#1277) * Use a hash from ModDependencyOptions for remapped dependency caching. * Use a different group to allow exclusiveContent to work. * Fix unit tests --- .../mods/ModConfigurationRemapper.java | 17 +++++- .../loom/configuration/mods/ModProcessor.java | 4 +- .../mods/dependency/ModDependency.java | 43 +++++++------ .../mods/dependency/ModDependencyFactory.java | 6 +- .../mods/dependency/ModDependencyOptions.java | 36 +++++++++++ .../mods/dependency/SimpleModDependency.java | 6 +- .../mods/dependency/SplitModDependency.java | 17 +++--- .../java/net/fabricmc/loom/util/CacheKey.java | 60 +++++++++++++++++++ .../test/unit/ModDependencyOptionsTest.groovy | 49 +++++++++++++++ 9 files changed, 202 insertions(+), 36 deletions(-) create mode 100644 src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependencyOptions.java create mode 100644 src/main/java/net/fabricmc/loom/util/CacheKey.java create mode 100644 src/test/groovy/net/fabricmc/loom/test/unit/ModDependencyOptionsTest.groovy diff --git a/src/main/java/net/fabricmc/loom/configuration/mods/ModConfigurationRemapper.java b/src/main/java/net/fabricmc/loom/configuration/mods/ModConfigurationRemapper.java index e5cfca02..9d8b0d49 100644 --- a/src/main/java/net/fabricmc/loom/configuration/mods/ModConfigurationRemapper.java +++ b/src/main/java/net/fabricmc/loom/configuration/mods/ModConfigurationRemapper.java @@ -58,6 +58,8 @@ import org.gradle.api.tasks.SourceSet; import org.gradle.jvm.JvmLibrary; import org.gradle.language.base.artifact.SourcesArtifact; import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import net.fabricmc.loom.LoomGradleExtension; import net.fabricmc.loom.LoomGradlePlugin; @@ -65,6 +67,7 @@ import net.fabricmc.loom.api.RemapConfigurationSettings; import net.fabricmc.loom.configuration.RemapConfigurations; import net.fabricmc.loom.configuration.mods.dependency.ModDependency; import net.fabricmc.loom.configuration.mods.dependency.ModDependencyFactory; +import net.fabricmc.loom.configuration.mods.dependency.ModDependencyOptions; import net.fabricmc.loom.configuration.providers.minecraft.MinecraftSourceSets; import net.fabricmc.loom.util.Checksum; import net.fabricmc.loom.util.Constants; @@ -79,6 +82,8 @@ public class ModConfigurationRemapper { // This can happen when the dependency is a FileCollectionDependency or from a flatDir repository. public static final String MISSING_GROUP = "unspecified"; + private static final Logger LOGGER = LoggerFactory.getLogger(ModConfigurationRemapper.class); + public static void supplyModConfigurations(Project project, ServiceFactory serviceFactory, String mappingsSuffix, LoomGradleExtension extension, SourceRemapper sourceRemapper) { final DependencyHandler dependencies = project.getDependencies(); // The configurations where the source and remapped artifacts go. @@ -135,6 +140,14 @@ public class ModConfigurationRemapper { } } + final ModDependencyOptions modDependencyOptions = ModDependencyOptions.create(project, ModDependencyOptions.class, options -> { + options.getMappings().set(mappingsSuffix); + }); + + if (LOGGER.isInfoEnabled()) { + LOGGER.info("Mod dependency options: {}", modDependencyOptions.getJson()); + } + // Round 1: Discovery // Go through all the configs to find artifacts to remap and // the installer data. The installer data has to be added before @@ -176,7 +189,7 @@ public class ModConfigurationRemapper { continue; } - final ModDependency modDependency = ModDependencyFactory.create(artifact, artifactMetadata, remappedConfig, clientRemappedConfig, mappingsSuffix, project); + final ModDependency modDependency = ModDependencyFactory.create(artifact, artifactMetadata, remappedConfig, clientRemappedConfig, modDependencyOptions, project); scheduleSourcesRemapping(project, sourceRemapper, modDependency); modDependencies.add(modDependency); } @@ -332,7 +345,7 @@ public class ModConfigurationRemapper { } if (dependency.isCacheInvalid(project, "sources")) { - final Path output = dependency.getWorkingFile("sources"); + final Path output = dependency.getWorkingFile(project, "sources"); sourceRemapper.scheduleRemapSources(sourcesInput.toFile(), output.toFile(), false, true, () -> { try { diff --git a/src/main/java/net/fabricmc/loom/configuration/mods/ModProcessor.java b/src/main/java/net/fabricmc/loom/configuration/mods/ModProcessor.java index 32f5f66d..de026ac5 100644 --- a/src/main/java/net/fabricmc/loom/configuration/mods/ModProcessor.java +++ b/src/main/java/net/fabricmc/loom/configuration/mods/ModProcessor.java @@ -253,8 +253,8 @@ public class ModProcessor { } } - private static Path getRemappedOutput(ModDependency dependency) { - return dependency.getWorkingFile(null); + private Path getRemappedOutput(ModDependency dependency) { + return dependency.getWorkingFile(project, null); } private void remapJarManifestEntries(Path jar) throws IOException { diff --git a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependency.java b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependency.java index 166de041..0a6ca669 100644 --- a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependency.java +++ b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependency.java @@ -37,23 +37,21 @@ import net.fabricmc.loom.configuration.mods.ArtifactRef; public abstract sealed class ModDependency permits SplitModDependency, SimpleModDependency { private final ArtifactRef artifact; private final ArtifactMetadata metadata; - protected final String group; - protected final String name; - protected final String version; + private final String group; + private final String name; + private final String version; @Nullable - protected final String classifier; - protected final String mappingsSuffix; - protected final Project project; + private final String classifier; + private final ModDependencyOptions options; - public ModDependency(ArtifactRef artifact, ArtifactMetadata metadata, String mappingsSuffix, Project project) { + public ModDependency(ArtifactRef artifact, ArtifactMetadata metadata, ModDependencyOptions options) { this.artifact = artifact; this.metadata = metadata; this.group = artifact.group(); this.name = artifact.name(); this.version = artifact.version(); this.classifier = artifact.classifier(); - this.mappingsSuffix = mappingsSuffix; - this.project = project; + this.options = options; } /** @@ -71,10 +69,15 @@ public abstract sealed class ModDependency permits SplitModDependency, SimpleMod */ public abstract void applyToProject(Project project); - protected LocalMavenHelper createMaven(String name) { + /** + * Create a maven helper for the local cache. + * @param type The jar type, e.g "common" or "client" for split dependencies. + */ + protected LocalMavenHelper createMavenHelper(Project project, @Nullable String type) { final LoomGradleExtension extension = LoomGradleExtension.get(project); final Path root = extension.getFiles().getRemappedModCache().toPath(); - return new LocalMavenHelper(getRemappedGroup(), name, this.version, this.classifier, root); + final String fullName = getName() + (type != null ? "-" + type : ""); + return new LocalMavenHelper(getGroup(), fullName, this.version, this.classifier, root); } public ArtifactRef getInputArtifact() { @@ -85,22 +88,26 @@ public abstract sealed class ModDependency permits SplitModDependency, SimpleMod return metadata; } - protected String getRemappedGroup() { - return getMappingsPrefix() + "." + group; + protected String getName() { + return "%s-%s".formatted(name, options.getCacheKey()); } - private String getMappingsPrefix() { - return mappingsSuffix.replace(".", "_").replace("-", "_").replace("+", "_"); + protected String getGroup() { + return "remapped.%s".formatted(group); + } + + protected String getVersion() { + return version; } public Path getInputFile() { return artifact.path(); } - public Path getWorkingFile(@Nullable String classifier) { + public Path getWorkingFile(Project project, @Nullable String classifier) { final LoomGradleExtension extension = LoomGradleExtension.get(project); - final String fileName = classifier == null ? String.format("%s-%s-%s.jar", getRemappedGroup(), name, version) - : String.format("%s-%s-%s-%s.jar", getRemappedGroup(), name, version, classifier); + final String fileName = classifier == null ? String.format("%s-%s-%s.jar", getGroup(), getName(), version) + : String.format("%s-%s-%s-%s.jar", getGroup(), getName(), version, classifier); return extension.getFiles().getProjectBuildCache().toPath().resolve("remapped_working").resolve(fileName); } diff --git a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependencyFactory.java b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependencyFactory.java index c1909b57..04a03619 100644 --- a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependencyFactory.java +++ b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependencyFactory.java @@ -41,7 +41,7 @@ import net.fabricmc.loom.util.AttributeHelper; public class ModDependencyFactory { private static final String TARGET_ATTRIBUTE_KEY = "loom-target"; - public static ModDependency create(ArtifactRef artifact, ArtifactMetadata metadata, Configuration targetConfig, @Nullable Configuration targetClientConfig, String mappingsSuffix, Project project) { + public static ModDependency create(ArtifactRef artifact, ArtifactMetadata metadata, Configuration targetConfig, @Nullable Configuration targetClientConfig, ModDependencyOptions options, Project project) { if (targetClientConfig != null && LoomGradleExtension.get(project).getSplitModDependencies().get()) { final Optional cachedTarget = readTarget(artifact); JarSplitter.Target target; @@ -54,11 +54,11 @@ public class ModDependencyFactory { } if (target != null) { - return new SplitModDependency(artifact, metadata, mappingsSuffix, targetConfig, targetClientConfig, target, project); + return new SplitModDependency(artifact, metadata, options, targetConfig, targetClientConfig, target, project); } } - return new SimpleModDependency(artifact, metadata, mappingsSuffix, targetConfig, project); + return new SimpleModDependency(artifact, metadata, options, targetConfig, project); } private static Optional readTarget(ArtifactRef artifact) { diff --git a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependencyOptions.java b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependencyOptions.java new file mode 100644 index 00000000..25098fba --- /dev/null +++ b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/ModDependencyOptions.java @@ -0,0 +1,36 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.configuration.mods.dependency; + +import org.gradle.api.provider.Property; + +import net.fabricmc.loom.util.CacheKey; + +/** + * Inputs used to process a mod dependency. The output jar is cached based on these properties. + */ +public abstract class ModDependencyOptions extends CacheKey { + public abstract Property getMappings(); +} diff --git a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/SimpleModDependency.java b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/SimpleModDependency.java index 34c82cf3..37078fcc 100644 --- a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/SimpleModDependency.java +++ b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/SimpleModDependency.java @@ -40,10 +40,10 @@ public final class SimpleModDependency extends ModDependency { private final Configuration targetConfig; private final LocalMavenHelper maven; - public SimpleModDependency(ArtifactRef artifact, ArtifactMetadata metadata, String mappingsSuffix, Configuration targetConfig, Project project) { - super(artifact, metadata, mappingsSuffix, project); + public SimpleModDependency(ArtifactRef artifact, ArtifactMetadata metadata, ModDependencyOptions options, Configuration targetConfig, Project project) { + super(artifact, metadata, options); this.targetConfig = Objects.requireNonNull(targetConfig); - this.maven = createMaven(name); + this.maven = createMavenHelper(project, null); } @Override diff --git a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/SplitModDependency.java b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/SplitModDependency.java index 7f088357..0d01c5d1 100644 --- a/src/main/java/net/fabricmc/loom/configuration/mods/dependency/SplitModDependency.java +++ b/src/main/java/net/fabricmc/loom/configuration/mods/dependency/SplitModDependency.java @@ -48,13 +48,13 @@ public final class SplitModDependency extends ModDependency { @Nullable private final LocalMavenHelper clientMaven; - public SplitModDependency(ArtifactRef artifact, ArtifactMetadata metadata, String mappingsSuffix, Configuration targetCommonConfig, Configuration targetClientConfig, JarSplitter.Target target, Project project) { - super(artifact, metadata, mappingsSuffix, project); + public SplitModDependency(ArtifactRef artifact, ArtifactMetadata metadata, ModDependencyOptions options, Configuration targetCommonConfig, Configuration targetClientConfig, JarSplitter.Target target, Project project) { + super(artifact, metadata, options); this.targetCommonConfig = Objects.requireNonNull(targetCommonConfig); this.targetClientConfig = Objects.requireNonNull(targetClientConfig); this.target = Objects.requireNonNull(target); - this.commonMaven = target.common() ? createMaven(name + "-common") : null; - this.clientMaven = target.client() ? createMaven(name + "-client") : null; + this.commonMaven = target.common() ? createMavenHelper(project, "common") : null; + this.clientMaven = target.client() ? createMavenHelper(project, "client") : null; } @Override @@ -86,8 +86,8 @@ public final class SplitModDependency extends ModDependency { // Split the jar into 2 case SPLIT -> { final String suffix = variant == null ? "" : "-" + variant; - final Path commonTempJar = getWorkingFile("common" + suffix); - final Path clientTempJar = getWorkingFile("client" + suffix); + final Path commonTempJar = getWorkingFile(project, "common" + suffix); + final Path clientTempJar = getWorkingFile(project, "client" + suffix); final JarSplitter splitter = new JarSplitter(path); splitter.split(commonTempJar, clientTempJar); @@ -114,15 +114,16 @@ public final class SplitModDependency extends ModDependency { if (target == JarSplitter.Target.SPLIT) { createModGroup( + project, getCommonMaven().getOutputFile(null), getClientMaven().getOutputFile(null) ); } } - private void createModGroup(Path commonJar, Path clientJar) { + private void createModGroup(Project project, Path commonJar, Path clientJar) { LoomGradleExtension extension = LoomGradleExtension.get(project); - final ModSettings modSettings = extension.getMods().maybeCreate(String.format("%s-%s-%s", getRemappedGroup(), name, version)); + final ModSettings modSettings = extension.getMods().maybeCreate(String.format("%s-%s-%s", getGroup(), getName(), getVersion())); modSettings.getModFiles().from( commonJar.toFile(), clientJar.toFile() diff --git a/src/main/java/net/fabricmc/loom/util/CacheKey.java b/src/main/java/net/fabricmc/loom/util/CacheKey.java new file mode 100644 index 00000000..b98d28ce --- /dev/null +++ b/src/main/java/net/fabricmc/loom/util/CacheKey.java @@ -0,0 +1,60 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.util; + +import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; + +import com.google.common.base.Suppliers; +import org.gradle.api.Action; +import org.gradle.api.Project; +import org.gradle.api.tasks.Internal; + +import net.fabricmc.loom.util.gradle.GradleTypeAdapter; + +/** + * A simple base class for creating cache keys. Extend this class and create abstract properties to be included in the cache key. + */ +public abstract class CacheKey { + private static final int CHECKSUM_LENGTH = 8; + private final transient Supplier jsonSupplier = Suppliers.memoize(() -> GradleTypeAdapter.GSON.toJson(this)); + private final transient Supplier cacheKeySupplier = Suppliers.memoize(() -> Checksum.sha1Hex(jsonSupplier.get().getBytes(StandardCharsets.UTF_8)).substring(0, CHECKSUM_LENGTH)); + + public static T create(Project project, Class clazz, Action action) { + T instance = project.getObjects().newInstance(clazz); + action.execute(instance); + return instance; + } + + @Internal + public final String getJson() { + return jsonSupplier.get(); + } + + @Internal + public final String getCacheKey() { + return cacheKeySupplier.get(); + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/ModDependencyOptionsTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/ModDependencyOptionsTest.groovy new file mode 100644 index 00000000..08a28505 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/ModDependencyOptionsTest.groovy @@ -0,0 +1,49 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit + +import spock.lang.Specification + +import net.fabricmc.loom.configuration.mods.dependency.ModDependencyOptions +import net.fabricmc.loom.test.util.GradleTestUtil +import net.fabricmc.loom.util.CacheKey + +class ModDependencyOptionsTest extends Specification { + def "test ModDependencyOptions cache key and json value"() { + given: + def project = GradleTestUtil.mockProject() + def modDependencyOptions = CacheKey.create(project, ModDependencyOptions) { + it.getMappings().set("testMappings") + } + + when: + def json = modDependencyOptions.getJson() + def cacheKey = modDependencyOptions.getCacheKey() + + then: + json == '{"__mappings__":"testMappings"}' + cacheKey == "c97692d3" + } +} From 186b774a2e01e736b42de4c832e81bfe5decf830 Mon Sep 17 00:00:00 2001 From: modmuss Date: Mon, 7 Apr 2025 11:53:55 +0100 Subject: [PATCH 06/40] Verify the minecraft jar signature (#1282) * Verify the minecraft jar cert * Don't verify old server jars. * Checkstyle * Unit test fixes * Add a list of known version hashes for versions that we cannot verify the jar signature. Either the versions arent signed, or are signed with a SHA-1. * Only verify minecraft jars when they were actually downloaded again. * Add property to disable verification * Fix import * Fix bundled jars --- build.gradle | 3 + gradle/test.libs.versions.toml | 6 +- .../minecraft/MergedMinecraftProvider.java | 1 - .../minecraft/MinecraftProvider.java | 67 +- .../minecraft/SingleJarMinecraftProvider.java | 3 +- .../minecraft/SplitMinecraftProvider.java | 2 - .../minecraft/verify/CertificateChain.java | 194 +++++ .../verify/CertificateRevocationList.java | 122 ++++ .../minecraft/verify/JarVerifier.java | 92 +++ .../minecraft/verify/KnownVersions.java | 57 ++ .../verify/MinecraftJarVerification.java | 113 +++ .../verify/SignatureVerificationFailure.java | 35 + .../net/fabricmc/loom/util/Constants.java | 4 + .../loom/util/ZipReprocessorUtil.java | 2 +- .../fabricmc/loom/util/download/Download.java | 8 +- .../loom/util/download/DownloadBuilder.java | 12 +- .../loom/util/download/DownloadExecutor.java | 17 +- .../loom/util/download/DownloadResult.java | 29 + src/main/resources/certs/known_versions.json | 682 ++++++++++++++++++ src/main/resources/certs/mojangcs.cer | 120 +++ src/main/resources/certs/readme.md | 5 + .../unit/download/DownloadFileTest.groovy | 29 +- .../providers/CertificateChainTest.groovy | 110 +++ .../CertificateRevocationListTest.groovy | 110 +++ .../unit/providers/JarVerifierTest.groovy | 127 ++++ .../unit/providers/KnownVersionsTest.groovy | 40 + .../MinecraftJarVerificationTest.groovy | 157 ++++ .../loom/test/util/CertificateUtils.groovy | 131 ++++ .../loom/test/util/GradleTestUtil.groovy | 6 +- .../test/util/KnownVersionsGenerator.groovy | 143 ++++ .../loom/test/util/TestServiceFactory.groovy | 8 +- 31 files changed, 2402 insertions(+), 33 deletions(-) create mode 100644 src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/CertificateChain.java create mode 100644 src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/CertificateRevocationList.java create mode 100644 src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/JarVerifier.java create mode 100644 src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/KnownVersions.java create mode 100644 src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/MinecraftJarVerification.java create mode 100644 src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/SignatureVerificationFailure.java create mode 100644 src/main/java/net/fabricmc/loom/util/download/DownloadResult.java create mode 100644 src/main/resources/certs/known_versions.json create mode 100644 src/main/resources/certs/mojangcs.cer create mode 100644 src/main/resources/certs/readme.md create mode 100644 src/test/groovy/net/fabricmc/loom/test/unit/providers/CertificateChainTest.groovy create mode 100644 src/test/groovy/net/fabricmc/loom/test/unit/providers/CertificateRevocationListTest.groovy create mode 100644 src/test/groovy/net/fabricmc/loom/test/unit/providers/JarVerifierTest.groovy create mode 100644 src/test/groovy/net/fabricmc/loom/test/unit/providers/KnownVersionsTest.groovy create mode 100644 src/test/groovy/net/fabricmc/loom/test/unit/providers/MinecraftJarVerificationTest.groovy create mode 100644 src/test/groovy/net/fabricmc/loom/test/util/CertificateUtils.groovy create mode 100644 src/test/groovy/net/fabricmc/loom/test/util/KnownVersionsGenerator.groovy diff --git a/build.gradle b/build.gradle index 6a1fd781..777dd96c 100644 --- a/build.gradle +++ b/build.gradle @@ -155,6 +155,9 @@ dependencies { } testImplementation testLibs.mockito testImplementation testLibs.java.debug + testImplementation testLibs.bcprov + testImplementation testLibs.bcutil + testImplementation testLibs.bcpkix compileOnly runtimeLibs.jetbrains.annotations testCompileOnly runtimeLibs.jetbrains.annotations diff --git a/gradle/test.libs.versions.toml b/gradle/test.libs.versions.toml index df35b023..651dfb70 100644 --- a/gradle/test.libs.versions.toml +++ b/gradle/test.libs.versions.toml @@ -5,6 +5,7 @@ javalin = "6.3.0" mockito = "5.14.2" java-debug = "0.52.0" mixin = "0.15.3+mixin.0.8.7" +bouncycastle = "1.80" gradle-nightly = "8.14-20250225001625+0000" fabric-loader = "0.16.9" @@ -18,4 +19,7 @@ mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } java-debug = { module = "com.microsoft.java:com.microsoft.java.debug.core", version.ref = "java-debug" } mixin = { module = "net.fabricmc:sponge-mixin", version.ref = "mixin" } gradle-nightly = { module = "org.gradle:dummy", version.ref = "gradle-nightly" } -fabric-loader = { module = "net.fabricmc:fabric-loader", version.ref = "fabric-loader" } \ No newline at end of file +fabric-loader = { module = "net.fabricmc:fabric-loader", version.ref = "fabric-loader" } +bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } +bcpkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } +bcutil = { module = "org.bouncycastle:bcutil-jdk18on", version.ref = "bouncycastle" } \ No newline at end of file diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/MergedMinecraftProvider.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/MergedMinecraftProvider.java index 3f2d4437..59c11584 100644 --- a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/MergedMinecraftProvider.java +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/MergedMinecraftProvider.java @@ -93,7 +93,6 @@ public final class MergedMinecraftProvider extends MinecraftProvider { File minecraftServerJar = getMinecraftServerJar(); if (getServerBundleMetadata() != null) { - extractBundledServerJar(); minecraftServerJar = getMinecraftExtractedServerJar(); } diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/MinecraftProvider.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/MinecraftProvider.java index b789a3b0..2aed7d1e 100644 --- a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/MinecraftProvider.java +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/MinecraftProvider.java @@ -29,6 +29,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; import com.google.common.base.Preconditions; import org.gradle.api.JavaVersion; @@ -41,9 +42,12 @@ import net.fabricmc.loom.LoomGradleExtension; import net.fabricmc.loom.api.mappings.layered.MappingsNamespace; import net.fabricmc.loom.configuration.ConfigContext; import net.fabricmc.loom.configuration.providers.BundleMetadata; +import net.fabricmc.loom.configuration.providers.minecraft.verify.MinecraftJarVerification; +import net.fabricmc.loom.configuration.providers.minecraft.verify.SignatureVerificationFailure; import net.fabricmc.loom.util.Constants; import net.fabricmc.loom.util.download.DownloadExecutor; import net.fabricmc.loom.util.download.GradleDownloadProgressListener; +import net.fabricmc.loom.util.gradle.GradleUtils; import net.fabricmc.loom.util.gradle.ProgressGroup; public abstract class MinecraftProvider { @@ -88,10 +92,18 @@ public abstract class MinecraftProvider { } } - downloadJars(); + boolean didDownload = downloadJars(); if (provideServer()) { serverBundleMetadata = BundleMetadata.fromJar(minecraftServerJar.toPath()); + + if (serverBundleMetadata != null) { + extractBundledServerJar(); + } + } + + if (didDownload) { + verifyJars(); } final MinecraftLibraryProvider libraryProvider = new MinecraftLibraryProvider(this, configContext.project()); @@ -109,7 +121,34 @@ public abstract class MinecraftProvider { } } - private void downloadJars() throws IOException { + private void verifyJars() throws IOException, SignatureVerificationFailure { + if (GradleUtils.getBooleanProperty(getProject(), Constants.Properties.DISABLE_MINECRAFT_VERIFICATION)) { + LOGGER.info("Skipping Minecraft jar verification!"); + } + + LOGGER.info("Verifying Minecraft jars"); + + MinecraftJarVerification verification = getProject().getObjects().newInstance(MinecraftJarVerification.class, minecraftVersion()); + + if (provideClient()) { + verification.verifyClientJar(minecraftClientJar.toPath()); + } + + if (provideServer()) { + if (serverBundleMetadata == null) { + verification.verifyServerJar(minecraftServerJar.toPath()); + } else { + verification.verifyServerJar(getMinecraftExtractedServerJar().toPath()); + } + } + + LOGGER.info("Jar verification complete"); + } + + // Returns true when a file was downloaded + private boolean downloadJars() throws IOException { + AtomicBoolean didDownload = new AtomicBoolean(false); + try (ProgressGroup progressGroup = new ProgressGroup(getProject(), "Download Minecraft jars"); DownloadExecutor executor = new DownloadExecutor(2)) { if (provideClient()) { @@ -117,7 +156,12 @@ public abstract class MinecraftProvider { getExtension().download(client.url()) .sha1(client.sha1()) .progress(new GradleDownloadProgressListener("Minecraft client", progressGroup::createProgressLogger)) - .downloadPathAsync(minecraftClientJar.toPath(), executor); + .downloadPathAsync(minecraftClientJar.toPath(), executor) + .thenAccept(downloadResult -> { + if (downloadResult.didDownload()) { + didDownload.set(true); + } + }); } if (provideServer()) { @@ -125,12 +169,25 @@ public abstract class MinecraftProvider { getExtension().download(server.url()) .sha1(server.sha1()) .progress(new GradleDownloadProgressListener("Minecraft server", progressGroup::createProgressLogger)) - .downloadPathAsync(minecraftServerJar.toPath(), executor); + .downloadPathAsync(minecraftServerJar.toPath(), executor) + .thenAccept(downloadResult -> { + if (downloadResult.didDownload()) { + didDownload.set(true); + } + }); } } + + if (didDownload.get()) { + LOGGER.info("Downloaded new Minecraft jars"); + return true; + } + + LOGGER.info("Using cached Minecraft jars"); + return false; } - protected final void extractBundledServerJar() throws IOException { + private void extractBundledServerJar() throws IOException { Preconditions.checkArgument(provideServer(), "Not configured to provide server jar"); Objects.requireNonNull(getServerBundleMetadata(), "Cannot bundled mc jar from none bundled server jar"); diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/SingleJarMinecraftProvider.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/SingleJarMinecraftProvider.java index c96564fa..ee682109 100644 --- a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/SingleJarMinecraftProvider.java +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/SingleJarMinecraftProvider.java @@ -139,14 +139,13 @@ public abstract sealed class SingleJarMinecraftProvider extends MinecraftProvide } @Override - public Path getInputJar(SingleJarMinecraftProvider provider) throws Exception { + public Path getInputJar(SingleJarMinecraftProvider provider) { BundleMetadata serverBundleMetadata = provider.getServerBundleMetadata(); if (serverBundleMetadata == null) { return provider.getMinecraftServerJar().toPath(); } - provider.extractBundledServerJar(); return provider.getMinecraftExtractedServerJar().toPath(); } diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/SplitMinecraftProvider.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/SplitMinecraftProvider.java index d2190142..8b56df43 100644 --- a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/SplitMinecraftProvider.java +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/SplitMinecraftProvider.java @@ -74,8 +74,6 @@ public final class SplitMinecraftProvider extends MinecraftProvider { throw new UnsupportedOperationException("Only Minecraft versions using a bundled server jar can be split, please use a merged jar setup for this version of minecraft"); } - extractBundledServerJar(); - final Path clientJar = getMinecraftClientJar().toPath(); final Path serverJar = getMinecraftExtractedServerJar().toPath(); diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/CertificateChain.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/CertificateChain.java new file mode 100644 index 00000000..e0c16f45 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/CertificateChain.java @@ -0,0 +1,194 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.configuration.providers.minecraft.verify; + +import java.io.IOException; +import java.io.InputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jetbrains.annotations.Nullable; + +/** + * A node in the certificate chain. + */ +public interface CertificateChain { + /** + * The certificate itself. + */ + X509Certificate certificate(); + + /** + * The issuer of this certificate, or null if this is a root certificate. + */ + @Nullable CertificateChain issuer(); + + /** + * The children of this certificate, or an empty list if this is a leaf certificate. + */ + List children(); + + /** + * Verify that this certificate chain matches exactly with another one. + * @param other the other certificate chain + */ + void verifyChainMatches(CertificateChain other) throws SignatureVerificationFailure; + + /** + * Recursively visit all certificates in the chain, including this one. + */ + static void visitAll(CertificateChain chain, CertificateConsumer consumer) throws SignatureVerificationFailure { + consumer.accept(chain.certificate()); + + for (CertificateChain child : chain.children()) { + visitAll(child, consumer); + } + } + + /** + * Load certificate chain from the classpath, returning the root certificate. + */ + static CertificateChain getRoot(String name) throws IOException { + try (InputStream is = JarVerifier.class.getClassLoader().getResourceAsStream("certs/" + name + ".cer")) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection certificates = cf.generateCertificates(is).stream() + .map(c -> (X509Certificate) c) + .toList(); + return getRoot(certificates); + } catch (CertificateException e) { + throw new RuntimeException("Failed to load certificate: " + name, e); + } + } + + /** + * Takes an unordered collection of certificates and builds a tree structure. + */ + static CertificateChain getRoot(Collection certificates) { + Map certificateNodes = new HashMap<>(); + + for (X509Certificate certificate : certificates) { + Impl node = new Impl(); + node.certificate = certificate; + certificateNodes.put(certificate.getSubjectX500Principal().getName(), node); + } + + for (X509Certificate certificate : certificates) { + String subject = certificate.getSubjectX500Principal().getName(); + String issuer = certificate.getIssuerX500Principal().getName(); + + if (subject.equals(issuer)) { + continue; // self-signed + } + + Impl parent = certificateNodes.get(issuer); + Impl self = certificateNodes.get(subject); + + if (parent == self) { + throw new IllegalStateException("Certificate " + subject + " is its own issuer"); + } + + if (parent == null) { + throw new IllegalStateException("Certificate " + subject + " defines issuer " + issuer + " which is not in the chain"); + } + + parent.children.add(self); + self.issuer = parent; + } + + List roots = certificateNodes.values() + .stream() + .filter(node -> node.issuer == null) + .toList(); + + if (roots.size() != 1) { + throw new IllegalStateException("Expected exactly one root certificate, but found " + roots.size()); + } + + return roots.get(0); + } + + @FunctionalInterface + interface CertificateConsumer { + void accept(X509Certificate certificate) throws SignatureVerificationFailure; + } + + class Impl implements CertificateChain { + X509Certificate certificate; + @Nullable CertificateChain.Impl issuer; + List children = new ArrayList<>(); + + private Impl() { + } + + @Override + public X509Certificate certificate() { + return certificate; + } + + @Override + public @Nullable CertificateChain issuer() { + return issuer; + } + + @Override + public List children() { + return children; + } + + @Override + public void verifyChainMatches(CertificateChain other) throws SignatureVerificationFailure { + if (!this.certificate().equals(other.certificate())) { + throw new SignatureVerificationFailure("Certificate mismatch: " + this + " != " + other); + } + + if (this.children().size() != other.children().size()) { + throw new SignatureVerificationFailure("Certificate mismatch: " + this + " has " + this.children().size() + " children, but " + other + " has " + other.children().size()); + } + + if (this.children.isEmpty()) { + // Fine, leaf certificate + return; + } + + if (this.children.size() != 1) { + // TODO support this, not needed currently + throw new UnsupportedOperationException("Validating Certificate chain with multiple children is not supported"); + } + + this.children.get(0).verifyChainMatches(other.children().get(0)); + } + + @Override + public String toString() { + return certificate.getSubjectX500Principal().getName(); + } + } +} diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/CertificateRevocationList.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/CertificateRevocationList.java new file mode 100644 index 00000000..24c3ec08 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/CertificateRevocationList.java @@ -0,0 +1,122 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.configuration.providers.minecraft.verify; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.CRLException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509CRL; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.gradle.api.Project; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.fabricmc.loom.LoomGradleExtension; +import net.fabricmc.loom.util.download.DownloadException; + +public record CertificateRevocationList(Collection crls, boolean downloadFailure) { + /** + * Hardcoded CRLs for Mojang's certificate, we don't want to add a large dependency just to parse this each time. + */ + public static final List CSC3_2010 = List.of( + "http://crl.verisign.com/pca3-g5.crl", + "http://crl.verisign.com/pca3.crl", + "http://csc3-2010-crl.verisign.com/CSC3-2010.crl" + ); + + private static final Logger LOGGER = LoggerFactory.getLogger(CertificateRevocationList.class); + + /** + * Attempt to download the CRL from the given URL, if we fail to get it its not the end of the world. + */ + public static CertificateRevocationList create(Project project, List urls) throws IOException { + List crls = new ArrayList<>(); + + boolean downloadFailure = false; + + for (String url : urls) { + try { + crls.add(download(project, url)); + } catch (DownloadException e) { + LOGGER.warn("Failed to download CRL from {}: {}", url, e.getMessage()); + LOGGER.warn("Loom will not be able to verify the integrity of the minecraft jar"); + downloadFailure = true; + } + } + + return new CertificateRevocationList(crls, downloadFailure); + } + + static X509CRL download(Project project, String url) throws IOException { + final LoomGradleExtension extension = LoomGradleExtension.get(project); + final String name = url.substring(url.lastIndexOf('/') + 1); + final Path path = extension.getFiles().getUserCache().toPath() + .resolve("crl") + .resolve(name); + + LOGGER.info("Downloading CRL from {} to {}", url, path); + + extension.download(url) + .allowInsecureProtocol() + .maxAge(Duration.ofDays(7)) // Cache the CRL for a week + .downloadPath(path); + + return parse(path); + } + + static X509CRL parse(Path path) throws IOException { + try (InputStream inStream = Files.newInputStream(path)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509CRL) cf.generateCRL(inStream); + } catch (CRLException | CertificateException e) { + throw new RuntimeException(e); + } + } + + /** + * Verify that none of the certs in the chain are revoked. + * @throws SignatureVerificationFailure if the certificate is revoked + */ + public void verify(CertificateChain certificateChain) throws SignatureVerificationFailure { + CertificateChain.visitAll(certificateChain, this::verify); + } + + private void verify(X509Certificate certificate) throws SignatureVerificationFailure { + for (X509CRL crl : crls) { + if (crl.isRevoked(certificate)) { + throw new SignatureVerificationFailure("Certificate " + certificate.getSubjectX500Principal().getName() + " is revoked"); + } + } + } +} diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/JarVerifier.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/JarVerifier.java new file mode 100644 index 00000000..19779031 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/JarVerifier.java @@ -0,0 +1,92 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.configuration.providers.minecraft.verify; + +import java.io.IOException; +import java.nio.file.Path; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.fabricmc.loom.util.ZipReprocessorUtil; + +public final class JarVerifier { + private static final Logger LOGGER = LoggerFactory.getLogger(JarVerifier.class); + + private JarVerifier() { + } + + public static void verify(Path jarPath, CertificateChain certificateChain) throws IOException, SignatureVerificationFailure { + Objects.requireNonNull(jarPath, "jarPath"); + Objects.requireNonNull(certificateChain, "certificateChain"); + + if (certificateChain.issuer() != null) { + throw new IllegalStateException("Can only verify jars from a root certificate"); + } + + Set jarCertificates = new HashSet<>(); + + try (JarFile jarFile = new JarFile(jarPath.toFile(), true)) { + for (JarEntry jarEntry : Collections.list(jarFile.entries())) { + if (ZipReprocessorUtil.isSpecialFile(jarEntry.getName()) + || jarEntry.getName().equals("META-INF/MANIFEST.MF") + || jarEntry.isDirectory()) { + continue; + } + + try { + // Must read the entire entry to trigger the signature verification + byte[] bytes = jarFile.getInputStream(jarEntry).readAllBytes(); + } catch (SecurityException e) { + throw new SignatureVerificationFailure("Jar entry " + jarEntry.getName() + " failed signature verification", e); + } + + Certificate[] entryCertificates = jarEntry.getCertificates(); + + if (entryCertificates == null) { + throw new SignatureVerificationFailure("Jar entry " + jarEntry.getName() + " does not have a signature"); + } + + Arrays.stream(entryCertificates) + .map(c -> (X509Certificate) c) + .forEach(jarCertificates::add); + } + } + + CertificateChain jarCertificateChain = CertificateChain.getRoot(jarCertificates); + + jarCertificateChain.verifyChainMatches(certificateChain); + LOGGER.debug("Jar {} is signed by the expected certificate", jarPath); + } +} diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/KnownVersions.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/KnownVersions.java new file mode 100644 index 00000000..f585a956 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/KnownVersions.java @@ -0,0 +1,57 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.configuration.providers.minecraft.verify; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +import com.google.common.base.Suppliers; + +import net.fabricmc.loom.LoomGradlePlugin; + +/** + * The know versions keep track of the versions that are signed using SHA1 or not signature at all. + * The maps are the Minecraft version to sha256 hash of the jar file. + */ +public record KnownVersions( + Map client, + Map server) { + public static final Supplier INSTANCE = Suppliers.memoize(KnownVersions::load); + + private static KnownVersions load() { + try (InputStream is = KnownVersions.class.getClassLoader().getResourceAsStream("certs/known_versions.json"); + Reader reader = new InputStreamReader(Objects.requireNonNull(is))) { + return LoomGradlePlugin.GSON.fromJson(reader, KnownVersions.class); + } catch (IOException e) { + throw new UncheckedIOException("Failed to load known versions", e); + } + } +} diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/MinecraftJarVerification.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/MinecraftJarVerification.java new file mode 100644 index 00000000..1a864838 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/MinecraftJarVerification.java @@ -0,0 +1,113 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.configuration.providers.minecraft.verify; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import javax.inject.Inject; + +import com.google.common.base.Function; +import org.gradle.api.Project; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.fabricmc.loom.util.Checksum; + +public abstract class MinecraftJarVerification { + private static final Logger LOGGER = LoggerFactory.getLogger(MinecraftJarVerification.class); + + private final String minecraftVersion; + + @Inject + protected abstract Project getProject(); + + @Inject + public MinecraftJarVerification(String minecraftVersion) { + this.minecraftVersion = minecraftVersion; + } + + public void verifyClientJar(Path path) throws IOException, SignatureVerificationFailure { + verifyJarSignature(path, KnownJarType.CLIENT); + } + + public void verifyServerJar(Path path) throws IOException, SignatureVerificationFailure { + verifyJarSignature(path, KnownJarType.SERVER); + } + + private void verifyJarSignature(Path path, KnownJarType type) throws IOException, SignatureVerificationFailure { + CertificateChain chain = CertificateChain.getRoot("mojangcs"); + CertificateRevocationList revocationList = CertificateRevocationList.create(getProject(), CertificateRevocationList.CSC3_2010); + + try { + revocationList.verify(chain); + JarVerifier.verify(path, chain); + } catch (SignatureVerificationFailure e) { + if (isValidKnownVersion(path, minecraftVersion, type)) { + LOGGER.info("Minecraft {} signature verification failed, but is a known version", path.getFileName()); + return; + } + + LOGGER.error("Verification of Minecraft {} signature failed: {}", path.getFileName(), e.getMessage()); + throw e; + } + } + + private boolean isValidKnownVersion(Path path, String version, KnownJarType type) throws IOException, SignatureVerificationFailure { + Map knownVersions = type.getKnownVersions(); + String expectedHash = knownVersions.get(version); + + if (expectedHash == null) { + return false; + } + + LOGGER.info("Found executed hash ({}) for known version: {}", expectedHash, version); + String hash = Checksum.sha256Hex(Files.readAllBytes(path)); + + if (hash.equalsIgnoreCase(expectedHash)) { + LOGGER.info("Minecraft {} hash matches known version", path.getFileName()); + return true; + } + + throw new SignatureVerificationFailure("Hash mismatch for known Minecraft version " + version + ": expected " + expectedHash + ", got " + hash); + } + + private enum KnownJarType { + CLIENT(KnownVersions::client), + SERVER(KnownVersions::server),; + + private final Function> knownVersions; + + KnownJarType(Function> knownVersions) { + this.knownVersions = knownVersions; + } + + private Map getKnownVersions() { + return knownVersions.apply(KnownVersions.INSTANCE.get()); + } + } +} diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/SignatureVerificationFailure.java b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/SignatureVerificationFailure.java new file mode 100644 index 00000000..03b368c0 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/configuration/providers/minecraft/verify/SignatureVerificationFailure.java @@ -0,0 +1,35 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.configuration.providers.minecraft.verify; + +public final class SignatureVerificationFailure extends Exception { + public SignatureVerificationFailure(String message) { + super(message); + } + + public SignatureVerificationFailure(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/net/fabricmc/loom/util/Constants.java b/src/main/java/net/fabricmc/loom/util/Constants.java index 8a7a9c08..4ca0a1f6 100644 --- a/src/main/java/net/fabricmc/loom/util/Constants.java +++ b/src/main/java/net/fabricmc/loom/util/Constants.java @@ -149,6 +149,10 @@ public class Constants { public static final String RUNTIME_JAVA_COMPATIBILITY_VERSION = "fabric.loom.runtimeJavaCompatibilityVersion"; public static final String DECOMPILE_CACHE_MAX_FILES = "fabric.loom.decompileCacheMaxFiles"; public static final String DECOMPILE_CACHE_MAX_AGE = "fabric.loom.decompileCacheMaxAge"; + /** + * Skip the signature verification of the Minecraft jar after downloading it. + */ + public static final String DISABLE_MINECRAFT_VERIFICATION = "fabric.loom.disableMinecraftVerification"; } public static final class Manifest { diff --git a/src/main/java/net/fabricmc/loom/util/ZipReprocessorUtil.java b/src/main/java/net/fabricmc/loom/util/ZipReprocessorUtil.java index 39f0f282..e59b8396 100644 --- a/src/main/java/net/fabricmc/loom/util/ZipReprocessorUtil.java +++ b/src/main/java/net/fabricmc/loom/util/ZipReprocessorUtil.java @@ -46,7 +46,7 @@ public class ZipReprocessorUtil { private static final String META_INF = "META-INF/"; // See https://docs.oracle.com/en/java/javase/20/docs/specs/jar/jar.html#signed-jar-file - private static boolean isSpecialFile(String zipEntryName) { + public static boolean isSpecialFile(String zipEntryName) { if (!zipEntryName.startsWith(META_INF)) { return false; } diff --git a/src/main/java/net/fabricmc/loom/util/download/Download.java b/src/main/java/net/fabricmc/loom/util/download/Download.java index df903148..158651ac 100644 --- a/src/main/java/net/fabricmc/loom/util/download/Download.java +++ b/src/main/java/net/fabricmc/loom/util/download/Download.java @@ -145,13 +145,13 @@ public final class Download { } } - void downloadPath(Path output) throws DownloadException { + DownloadResult downloadPath(Path output) throws DownloadException { boolean downloadRequired = requiresDownload(output); if (!downloadRequired) { // Does not require download, we are done here. progressListener.onEnd(); - return; + return new DownloadResultImpl(false); } try { @@ -162,6 +162,8 @@ public final class Download { } finally { progressListener.onEnd(); } + + return new DownloadResultImpl(true); } private void doDownload(Path output) throws DownloadException { @@ -483,4 +485,6 @@ public final class Download { private DownloadException error(Throwable throwable, String message, Object... args) { return new DownloadException(message.formatted(args), throwable); } + + private record DownloadResultImpl(boolean didDownload) implements DownloadResult { } } diff --git a/src/main/java/net/fabricmc/loom/util/download/DownloadBuilder.java b/src/main/java/net/fabricmc/loom/util/download/DownloadBuilder.java index ab787bfb..8da80ce0 100644 --- a/src/main/java/net/fabricmc/loom/util/download/DownloadBuilder.java +++ b/src/main/java/net/fabricmc/loom/util/download/DownloadBuilder.java @@ -33,6 +33,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.Locale; +import java.util.concurrent.CompletableFuture; @SuppressWarnings("UnusedReturnValue") public class DownloadBuilder { @@ -115,15 +116,12 @@ public class DownloadBuilder { return new Download(this.url, this.expectedHash, this.useEtag, this.forceDownload, this.offline, maxAge, progressListener, httpVersion, downloadAttempt); } - public void downloadPathAsync(Path path, DownloadExecutor executor) { - executor.runAsync(() -> downloadPath(path)); + public CompletableFuture downloadPathAsync(Path path, DownloadExecutor executor) { + return executor.runAsync(() -> downloadPath(path)); } - public void downloadPath(Path path) throws DownloadException { - withRetries((download) -> { - download.downloadPath(path); - return null; - }); + public DownloadResult downloadPath(Path path) throws DownloadException { + return withRetries((download) -> download.downloadPath(path)); } public String downloadString() throws DownloadException { diff --git a/src/main/java/net/fabricmc/loom/util/download/DownloadExecutor.java b/src/main/java/net/fabricmc/loom/util/download/DownloadExecutor.java index f1a4606e..a4c9eba0 100644 --- a/src/main/java/net/fabricmc/loom/util/download/DownloadExecutor.java +++ b/src/main/java/net/fabricmc/loom/util/download/DownloadExecutor.java @@ -24,10 +24,11 @@ package net.fabricmc.loom.util.download; -import java.io.UncheckedIOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -40,20 +41,20 @@ public class DownloadExecutor implements AutoCloseable { executorService = Executors.newFixedThreadPool(threads); } - void runAsync(DownloadRunner downloadRunner) { + CompletableFuture runAsync(DownloadRunner downloadRunner) { if (!downloadExceptions.isEmpty()) { - return; + return CompletableFuture.failedFuture(new DownloadException("Download blocked due to previous errors")); } - executorService.execute(() -> { + return CompletableFuture.supplyAsync(() -> { try { - downloadRunner.run(); + return downloadRunner.run(); } catch (DownloadException e) { executorService.shutdownNow(); downloadExceptions.add(e); - throw new UncheckedIOException(e); + throw new CompletionException(e); } - }); + }, executorService); } @Override @@ -79,6 +80,6 @@ public class DownloadExecutor implements AutoCloseable { @FunctionalInterface public interface DownloadRunner { - void run() throws DownloadException; + DownloadResult run() throws DownloadException; } } diff --git a/src/main/java/net/fabricmc/loom/util/download/DownloadResult.java b/src/main/java/net/fabricmc/loom/util/download/DownloadResult.java new file mode 100644 index 00000000..4ac1deb1 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/util/download/DownloadResult.java @@ -0,0 +1,29 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.util.download; + +public interface DownloadResult { + boolean didDownload(); +} diff --git a/src/main/resources/certs/known_versions.json b/src/main/resources/certs/known_versions.json new file mode 100644 index 00000000..f8653c49 --- /dev/null +++ b/src/main/resources/certs/known_versions.json @@ -0,0 +1,682 @@ +{ + "client": { + "14w18b": "8403c9fb03b1e9c60cb6fb2f97e25dd60041aba4f0a62596f4e63eed49cfa9c0", + "14w18a": "8b18e1c6bcc01d9a96e44171e8ed05bd8193a2bb6a3c9c9f5fecce7492b04cac", + "14w17a": "db07fed9bed91d5de8f48c3906b87b7c05aa2b7242a16000c24009eabcf3516e", + "1.7.9": "38f8d799a9b42fb539ca7250e317dd6546910c8ac7718a720c11aad79780e8d8", + "1.7.8": "09d06a078aaedc075682440a5d87d473ee5ebfb35270aa8f085575f133029a31", + "1.7.7": "3e41fd81092a1e9bb7d3d268ebd8c8700b5906065974dc6a962ba8417d57ba73", + "14w10c": "542a846d2ca4975069230c1b8e3da143731966190ff307cc202fd75117fa7c83", + "14w10b": "02367e9efcbd9feff8c41afb9e1e2801076190821fd259de61d0d49137c9c078", + "14w10a": "267dcc0da9deff6a5e8ad4a80b3ecf1da0f17c9184b7c236c3b11e2350f92d53", + "14w08a": "5fefa25e18becd6d4a522b21488f835100dde75d1e20612ff1864586453f8ab9", + "1.7.5": "5f83b944b59c48ea7fa8f92fefd491ecb6d1e8d6c9b412fc849f6457c8cee27b", + "14w07a": "298f9b762fdd56fab0aa250385629f8971cebc7054c37d48c8f3f65454f62116", + "14w06b": "f1e87881adf7e02af3dc69a2f57631c32aed31d709b7820037771af28faf55cc", + "14w06a": "047db6da1fb6a5d6d9f8500c4f37fc274d351dc3aeec5b4b5a5879565617265c", + "14w05b": "7c9d9fbdcdca7160bef96cdb1ada2e8cc4edad73d0f996e36305a79d3a1790bc", + "14w05a": "893f6b8129a1fdee79c0aec3f912a11a6f3af3240debdf75a685aad2a00b118b", + "14w04b": "0278bdc4c5ba4e7035555fc5e5b90b8d005478ad12be32613ecc6fe03aee4da7", + "14w04a": "a09bae6167d85fc4cabef338a5f9f4b710b029079354eb9c36097fa9dc137244", + "14w03b": "f027f1aaded0445f769aad31770cc93abdeb6bb7b66cf23b1b32d68feaf638b6", + "14w03a": "d640a5a69510251fe8417cb96acd9f2ee2e302e671adb33581abcbe6e27eb1cd", + "14w02c": "1189a21527efd3e2bc9936787d6697b865b84f917e271fa29edd8b1e6ddfb66d", + "14w02b": "02080a3cea941c7fdedebada32315c62a121a2d372310f80ba119c4ba2fd8d2d", + "14w02a": "4b08784409fbf168a8d27a74f8a4c992fe50ace58021434a1fd2d22d6df2bc01", + "1.7.4": "c51494c52a612648cbcb7ebcdaedbcb61f6cd287f6215f90113a43136cb62526", + "1.7.3": "8b1d90ae662b235072d05dbc27d38ae5fef8b322a21d10302703fd798feb05c5", + "13w49a": "63e598023cbdbf77f4ec08b7fae4c25ab276b6dd17f3f59ff1086d1334ebdb00", + "13w48b": "d606b7eb41bdcbedb678bb5aa87d05e9e0fb13b31e16dac845ea658b3e970d51", + "13w48a": "92edd6cfa4ec6dd46f361bfbc2a537803f92177c80a643def663d2c7be376385", + "13w47e": "0cf206856ebaf09f08fa27faa5cedce3230fe837b5486806ea8354b952aae7c9", + "13w47d": "19d5f72e2aa1779a02335a3a4692c1a1a2a04be8d0329c15bf675bcd74464fdf", + "13w47c": "0a69ec62aca3a4b5c6f2956df4cabe465d4a7f6f2fc80a7ac1444936f07a5dd3", + "13w47b": "11216038d4c85cbf1cb2d3bf61808b0d26ca287b064b5fd8d511e11323414bf2", + "13w47a": "e8b0204b3d35b2a6c381417d8a32d3163c9e98caa4c733b4e9914989fecc7282", + "1.7.2": "507fb3660e77ab1a3000424d9b08c61606ae1e5a9be41caa3728bddd9597365f", + "1.7.1": "0ae4bb959d5d916ce7a6a83882aa154bb4233ea8a3624d3fb0970a5fd3720fa8", + "1.7": "af3ce46c2b91d5ae61bc5c590fb8c72b00b538875c8de225e9f2f2dd465afb50", + "13w43a": "ae0fe25f37d971787e73116fd091c1e1edbce197552bf15be70862ac7b4e5c40", + "13w42b": "e5ec51369856028631bb973cffdb13e5b83ba3fdd466314469ddedaa5c1725ba", + "13w42a": "1c1d7ee75225aedba2c04f871f7f1de6261f42debbc1219024bb2ec4af0e47ae", + "13w41b": "3e9eaef7162673b5fb062017ee02e847cc7e53395bd9366ce1d7db95d808dd6e", + "13w41a": "ef428b96e3550b7c733d43544950553971503bf99dc68942971d35c5269d73a3", + "13w39b": "cf804e8ed8c0589df08baa76f2f77eb89046b1df51b5e7e7a6ec198012a06bb1", + "13w39a": "a4cbdea94098d07c30c2ed4941344ee0fa2019edfc6e483826cc7e075e9934e1", + "13w38c": "d28cb5d42a8bd4bd9e870f13ce6f6cb2cf5427b43efb631a71751cff56c32288", + "13w38b": "0ea32cb82f5f49db167987907e4ff14e5358a42cc202c794a8cd4525cc286009", + "13w38a": "941e72596782c260e2cc8687757c141060b3f3d1b77c1a75e7c4e4e2b04504e4", + "1.6.4": "f4513e51c766cdd1d32ca8acd0835d7e16e2851ebea3f2a3b4ce5ae696baf3ae", + "1.6.3": "e6dc707af0a0cd050d5f66da38476c81f2ad6437074e46c852bae410ee2a30d3", + "13w37b": "998240601ae985884f7899933410188075320e432ca2257748b56f17e8d09770", + "13w37a": "61252567fe990c23689a1bbd3514d82460442b3c6822dafc2fdd06a2e5746809", + "13w36b": "00e81ac87bfe31a47f2b7f5aeb532649e0b75b847a237b3bcd2f6b101a161667", + "13w36a": "796e2540899dac97bcdc873a84f08d74a41212ec5648e5f70ae1e87f75dcf692", + "1.6.2": "08406549a47412294923705bf023b1080ae14da22ec2770d16d920ce420c3092", + "1.6.1": "1d8a2945e196db1226930ca51f20f8dbe2d9578884949c569c5cc8dc5fbdcc53", + "1.6": "1362475725c495e34fba6cd8bd10af1e621d9425180c8428b9cd8efca168e611", + "13w26a": "71e49cfd67aade77449ee898bdbdd719149ff142be501d29312d702db107d530", + "13w25c": "26c2259621d151b29ffcda4b4f2f39d044b835a4d5152cc3f87138b81a6ab080", + "13w25b": "18d9e3ff2ae3184c5d2be0ea6ad5ebac496e2ef8454a8911b036b078087c8c35", + "13w25a": "8ebc73a02b8b3c952d678b9cadd3250bdf47218513baabe6b7529e8871e19b9e", + "13w24b": "bfa6fc3154afad3ce6f3e416f7bf8b7dcc93f21b8d67fc1cb8872346462e73c9", + "13w24a": "391edb763fa015d2893ddf9345ffa676322e939719b16ff77fd442fcac873282", + "13w23b": "53ba4cb9f0e2ad5ddddb6cebddca18cfd85db3968ae8b3ac4f36ba93169faeac", + "13w23a": "f99593e38e106713d022c43cca369ec08187a062b8112b34cfb6f484534d342a", + "13w22a": "cd3b4e88804f7e6ba6ca604f3e53c49770d8b5a967c9f069c78b637f9a79d407", + "13w21b": "6790b306bdc554d5485edbee9cbb434707f8f45c85d36041079d80588dd2d96b", + "13w21a": "7a267523ed4f42e654bd80817368dead64b3e6c3a4afb8f4b2070d87441a004a", + "13w19a": "0cc59566ee89deb80090c8752c35cc532667921332ed4b4d5b2b136e6131388c", + "13w18c": "45d3b2ff10dfbb55f5f4d719b26cdd3c5e232261d0a3d9d66c2536f7a1c9695a", + "13w18b": "bc54c2f86339d489a0f20376fbb23f19dcf8464645a675cf0eeb623b249c2934", + "13w18a": "1750c66d6c4aaeb75061ef1cb8f55821553ad469ea3a8dbdf1f27c70af2ac53d", + "13w17a": "d9b3725b86e69b04eb55f24aacd92cb339d77e1083f6c986acfd5ae8ae1511d3", + "1.5.2": "dc0fa48951f61c12eafede5e46e248aa86ab86d1e4c28cd880c1d9c348ec44d6", + "13w16b": "9a7108fbefc3e4a85b82100eaa57f725668540d98a5b0160a55ada148d5e80a7", + "13w16a": "54eeeb77ef7813415bf31551ea14005d612d64fee0d04e5b543bff4b5c2bc787", + "1.5.1": "e3164ae3a18954b0e09aefa20afd9c6713d9ca3b63d603fc8ae2502026149922", + "1.5": "4ad020a9c3fd95850370a45ea3511a8fd98728f7dffda68007ca2e99276b67c4", + "1.4.7": "c5f4972bd775e03b1d3a1255d962ffc00f8c1f6ad98a30a149d5f107153c0da6", + "1.4.5": "b7f06f2019ccfe5d8aaf7e4fb5f5e8e2bdc3ce6509c4ab4f559a5ce4ffe011a8", + "1.4.6": "c4bb5fd8f98ac7160b34fff7a65f809e0930ee39c5d94f463f0884a6477b25ca", + "1.4.4": "9e155ec17c574488c0bf361aedb1c485d1f08b31fa9d21ddcbcdd441a0d41f53", + "1.4.3": "06d60d55d1c16f60884f0def32fa249145bf7e0b8f5058ad885e3aeaecf798fb", + "1.4.2": "199f44e06dfbc20567f9b6db0a965776b38208359a62956b43906bdc51118cf2", + "1.4.1": "773e2bbe53db57ff8ba9dd530ad62db546f6d9f4c37b0dcb7631e337d26641e3", + "1.4": "3a70045649d016e8026e98ed0488b1578910b9bef0dd48a744690501b369f43c", + "1.3.2": "24ec25082cf2bafad90518b5e24d23fdfec55f1bff7f2ead391ab40501eb8354", + "1.3.1": "26a5586a1dd5555e918813284ab6cf05af1fb997f66b99c93035b03ca066cc52", + "1.3": "a71f49a4a9fae65db94da535e5b0318606151927dcacb68908ec937c71ac7b71", + "1.2.5": "c1c3740a912ef523a8bd46605ab5708643498330140cba175c7ce6f177e468e1", + "1.2.4": "ece050b625d1836a035197ed312dc59caf26429f4b386c81bdd84cdff3ca80cb", + "1.2.3": "ea15a60614f96a6bb9aecf865de213635c62d664124092d84bce240622c4a9d3", + "1.2.2": "5cc415005abc4931238c1af056f7f7aa650990951a58142d7ee855532b2b0fd8", + "1.2.1": "3c519ce303504c2f112f6d2f94f81cf1570c83ce81348df7db93ada317e33054", + "1.1": "e065e9681462aa1b78f3cc589ba880d9aae51994bcd4af8c67bcab5c47391ca0", + "1.0": "136e3dd54454e96175badf50bee2cdebdab9e7d66fee4fd6d135f39ead99eb58", + "b1.8.1": "3a61a9cb5b8b6cade30a8bfe21f6793122c7349d0c3bbe1cd0fc2a93add36f4a", + "b1.8": "5d861a5ae1ada4f659905dfde3ff76859a8fd687b2b040b2cc97b0e4e43d9358", + "b1.7.3": "af1fa04b8006d3ef78c7e24f8de4aa56f439a74d7f314827529062d5bab6db4c", + "b1.7.2": "16c8f63be9e5039f2ce974974a5688d772e93c9dba53731deac4b2434efb8d12", + "b1.7": "43efdd70ac7c7b1b7d5c2ccb6c1aad8d9fb7af110187fb4d1070b21d04648b7c", + "b1.6.6": "7764cb6cd9832d3b270d749a9ebf44e5ec8297faf5f14753146ab70cbd316b66", + "b1.6.5": "b203d6d62f5b97671d262d88c7bf8891b8501b401fe9ababc9e4ee2d960cd0a8", + "b1.6.4": "02d02cd94905c4a2c771b6c1696d236cb38150600a3dfb9946393d3e1431df0d", + "b1.6.3": "a88794d52654321527669ac38cb39764ab41cef804ad98eae8e7e00861bf845f", + "b1.6.2": "b3a6cff53774e1c0f7e9faee014f49728b72a7b1d1301fc9d39eb12cc20fc310", + "b1.6.1": "5108cdc9f7f9c5514ad2d16ee1b2a7bbfaaa13f1816aafdcd24e9ee975a4707a", + "b1.6": "a084d22ea2c872a564ebef4e5b99be9ec0727e3f7be6cfb4f6cb2f9dea4522f8", + "b1.5_01": "088e96109679889c62cf8a91c499bc44ca292252872c242ffefcad95be077618", + "b1.5": "d99cba867acc5aabf383f8252f047d5d1e9afb4f531c9b7205d1b845cd4fa7f0", + "b1.4_01": "36170e61a2ce9dcb6dda5af63440070d1bad82c0846d779f25c2fb0bdc14992e", + "b1.4": "c48c430cb60d3197ca4b9c0aa33821ea56fa10edba5a10a46b77aa46883a6908", + "b1.3_01": "e993b789dd450e1538667def644e1376bd702fe26eebec3197598d5157042684", + "b1.3b": "335ed6e306324ba4b7eb8ae16479839ae5678f0c1da62757e515d640801e1159", + "b1.2_02": "c803cd9c4a0815b98317efaee3dcc84863049a5a3b5d250c56776669c8996131", + "b1.2_01": "55df77ca089baa030c5a4e62905ea78f755485ea3a8e968a2e0ff549bfe13bc0", + "b1.2": "a0996c12b0bfd5a9c85a03542cecf02f573f4cd4dd60f16b05323e897750cc9c", + "b1.1_02": "3439c894641b07bb1b31b89209e9dc5818755353d1f646ee48e583610d6670f1", + "b1.1_01": "f5a4cf4b631c67cafc490dcb43c17dbbab0383d3ec1ffb946f0eb217374a8368", + "b1.0.2": "82e28d25b493ade968cc0015e5b1c2e9145bbbe247d7f1ea7d603e7ddfe056bd", + "b1.0_01": "61829173ce6d311246db4f68bf649bab5ef9f8e29c436cd7c9f2ae494db7f1bb", + "b1.0": "ad62df9cc678bfa15ff981f56aa8aa7ad0d60a1e584c39b981f9ec089ed450f0", + "a1.2.6": "63276bf2617068ffbaf2a1992d1f06f9339c96c21991eafc9583d5f3e7074b9c", + "a1.2.5": "fdde933363df3a1d95a76d180dbdb14464f8390f954d272613ed25ce42155787", + "a1.2.4_01": "047a05550de80c186a3f23d7c4c8b25056dcc0a775e73f4e4d919f39d8bd1c37", + "a1.2.3_04": "e80e49d2ea895198fcd2d3866e4541a4d464aa5263f3923d540cbaf1053f9eb9", + "a1.2.3_02": "bc23d23764761484472060a49353598bbaa33d574e6953015e75e192e03bb9f9", + "a1.2.3_01": "17c0463e56800aa257de3e76011d7f3db015b44fe2f3e846275fcee7f6129571", + "a1.2.3": "76b99d9a0d884bb0de99257740fd391c319abebc2ec30174a39bb63e5ebe69b1", + "a1.2.2b": "fe33a245d0c1a995ffc82f2673436029fd0c4f04b9597fa094a1fd6157cd65d7", + "a1.2.2a": "054c311b6ac2181f8d362d579ab8d3992ebff1867f19c480191e417c030e531a", + "a1.2.1_01": "7eab51320f26cf68ff9b06fcf34b64ee60aa7aa0ee5c0421e8fb0a265e811c18", + "a1.2.1": "7eab51320f26cf68ff9b06fcf34b64ee60aa7aa0ee5c0421e8fb0a265e811c18", + "a1.2.0_02": "cb4d712cbbe51a6a8b375ca771fdc4ff3fc70bc7bdd640e53a020ae7e687af56", + "a1.2.0_01": "e658a3a6eceac9eed89bd1e2a00768f72dd5aa8ad085fde59a9e1cff2f3c43b9", + "a1.2.0": "2139af187a74e4c60af45110665f38777fe6df07da0b72930fa08245c732e149", + "a1.1.2_01": "167a9cef74eb60417ab9670df10953634c0dba8371ebae1e44588e0ba0a7b07d", + "a1.1.2": "6beaee2c909ed33591a985ab6bd03d6962958c0b3d0aeb7a7a3cc144adf7f50d", + "a1.1.0": "0723b15a4b56b202a46574f2bf039da2760f838e8ea3ce5aac683b26dbf38e8e", + "a1.0.17_04": "34105933da180843e575909978246fb373f2db53b00905b92c8110cd19fad6f2", + "a1.0.17_02": "9505e19f30b79ce2964504de29594e18afeed7fb5dcb3f0960177705880dde0e", + "a1.0.16": "fd14110691b14ea51f9320a949e2b3c1855e38af2668bf4e743c19b08de00234", + "a1.0.15": "c9018315807056c610a30ff08d6d9515712a50997d6c06ff7d923676d61e87ce", + "a1.0.14": "0859315d26dab43e004454f19f51301351e826acf3ffaed636d7d2a5f6d5a584", + "a1.0.11": "a11120202e2ae0b474a60c2944b03cc5dac5c6d659a0926f8c881619de70c17c", + "a1.0.5_01": "5ef06e9c7e0421505e3cecc732f2d192a9e04b5a55840d91418a301bae375c70", + "a1.0.4": "e464928fdfc445de13b91b635d31c1aadf212c656f9d7a9f3ae54da5c2783f5f", + "inf-20100618": "26c18bbdb55c0c7f5858a8094ce082a76f28d76e7f6f3a035383129f75a365c0", + "c0.30_01c": "3bfaa9ceccfd49f62d4c1863d5cd565c24de1f83bab982a38e4811ac369993a5", + "c0.0.13a": "b3b5a88834c2351c948950c6d60c822d0d1d60f88c5adad4794f8ad6fe5b3a33", + "c0.0.13a_03": "40595f0c37adb1c581d0d4d836733806ff75af977836f815f1a5600fef0a43be", + "c0.0.11a": "2ca13b43ea3efc0388c5c1d4613854f6412f97d4c02f4b18729f648e46f02d1f", + "rd-161348": "cb8bda0074ac44d8d26bfe0c101f08edeb2291915f32ad1d2909831cd84934e0", + "rd-160052": "bbd6e24a276c5082f8aafae152bed450c161d56ff776472821de56c0daa7fe1e", + "rd-20090515": "cb8bda0074ac44d8d26bfe0c101f08edeb2291915f32ad1d2909831cd84934e0", + "rd-132328": "0627a893265fd697fff165626e000a2640c6a6ece6b64b75c3ab0194f960ed8b", + "rd-132211": "407460840eaeab01260b9e7951bd518f7c31e2e3f12a352e436502fa7050a6e7" + }, + "server": { + "21w38a": "56ebc9c2a2997a983df5777d7fa1e2d25ba38fa3b740b3cd1db8308819449e5a", + "21w37a": "051cf95cc9aa199f4872bcfaf873ab39841c0a266ac09093d2cad6c93e14d7b0", + "1.17.1": "e8c211b41317a9f5a780c98a89592ecb72eb39a6e475d4ac9657e5bc9ffaf55f", + "1.17.1-rc2": "b54452f67071054983935a02ec344a5d9e0c7ade5a7a4f7966c5f8cceb62335f", + "1.17.1-rc1": "643c414bfa6493f2e644b64bda0e558fb209a12bde27bcaaa508e2976006660a", + "1.17.1-pre3": "b4a23854646c7631aa0e39520184e2a36eaeb4674139264b480cc46d60ca88de", + "1.17.1-pre2": "dbe08754799fc8661f0140d34391885f367a64048437080cc2b3048b11032e19", + "1.17.1-pre1": "c710f543d60df1d0218848c34ba240044dc2eaaa7f210d825b83eb9a5c6af9fa", + "1.17": "7b390d8d9f6b5649b226d82686aec7f11bd9aa4430bb5cac9072ffd32f3c1f4b", + "1.17-rc2": "038782cd1017e75513c6954086b423e227691e91d76567ddeea749119af3756f", + "1.17-rc1": "64a0f55142ff835deac34b7d949f4173e01791e13bfc3825561b1f72156603e8", + "1.17-pre5": "b6ec94e542dc0783bfd76f822f44f0a3434a4dbc7205c545e3c6f42a4bb9b323", + "1.17-pre4": "885a42fb4243b6dc4d4f6fb4f32797f27b88bc74e402b1ae19b69a77b789a257", + "1.17-pre3": "3db83b98c5afb4a49ad2d5e8b1dde30db8edd35d470602b878e5c4b01bfd30a7", + "1.17-pre2": "58bc609735b32aade48a857b95ec92c16202eace828e2d6c6b5384e68083c5bc", + "1.17-pre1": "f2e66bc5d818f1afbf80d46cbcd178cd14fba10ad1ce21330fcf2426e5295b60", + "21w20a": "ea47315ba2b9abfe3ddc08a0e0da4713434b2a3cc8a404a907c1e12267ff6120", + "21w19a": "4242ddb3304917206ee57d4f8733ac6f271bfcd789c9b93cbe5ebb684bba366c", + "21w18a": "991df5a799aad616244022e52ce058c1b52c48eb67c2388f0e9bf514aaff8073", + "21w17a": "79c5fe68415b0198e6716706cdbb7f900b146b678af2883b5ca6edc42e783207", + "21w16a": "6d5fab818c5928c9bb9060f7b2d9968110c620a62def1ce92aaefc39ddc1a4f5", + "21w15a": "afb63475c6e2ba324fcdc4630ae9fefdc422ff86bd6a962c9ca3094ba6a6bff1", + "21w14a": "03467ee277f483cdadfa2f603e6473105f277b0f3fd5f9d5c77ffb2c34159a5c", + "21w13a": "fcd97c853dc0edc5c07cd4753b73bfb9a1ae72c616ba811c044f6075272eeb68", + "21w11a": "b5d0f819e6361327ca96661eed042f92751ff282a137b03acfca763707d6c7e5", + "21w10a": "cd8f9846c42332d4859a35f7645e325dc07b59d4ebdce76a3ff50d73a81b3ab5", + "21w08b": "d8ec1e31705409c641f3ed293b26207f62091b524e3364dbea344dec0a63f386", + "21w08a": "59ea0491b83285061bcfd8ecda579d2dc204f9182a093c4f1f539311a046f3bd", + "21w07a": "292e9f641eaf137b51cc32e68000ad96cd2bb6b6e89b3f31a82de2e2501f0f9a", + "21w06a": "18dac09c4014641ddd6bd17c84425a614a27290dc9cf499671750f7746a4f9f5", + "21w05b": "91583bcc0639113cec87b536de9d94da88cc8381e769d97c319b98a8a14ea17e", + "21w05a": "2c833407945fb4a21f845fe1e2611bdf940573ab6d2df9a307b1a6d3ffdb7079", + "21w03a": "ca3c83d4747ef8e9f458ecc5814561c6042095842aa4a7b604101dc1dca03e05", + "1.16.5": "58f329c7d2696526f948470aa6fd0b45545039b64cb75015e64c12194b373da6", + "1.16.5-rc1": "77417618470294df7df7e10ee1969441287c32f9a6807ced290c2b048fb3808b", + "20w51a": "311493da9f55ee4ba3d17f6615e44167abd69749f917758994952a17a15c665e", + "20w49a": "374f5a9fcc4d72b3a5b094fe04e7a075fdce2d27558d4115ad323d74fa6e577c", + "20w48a": "cd5167235cee40ee8b5487015b0e172964472c1753b9034de19058c31daaa332", + "20w46a": "d66328d61a0442b37ad7e125f2bf69ec4f38295e525e4d42c83bf5d990ffb764", + "20w45a": "0647b8dce42f02c807c7bd83a06c6d68845f917bb9ac1260529a83636b403675", + "1.16.4": "444d30d903a1ef489b6737bb9d021494faf23434ca8568fd72ce2e3d40b32506", + "1.16.4-rc1": "78e7cf12a0e07ae1f5c314720b36cc44ac936fde89263d57927dadd9889f18ba", + "1.16.4-pre2": "5131ae739be591e23e779beca823bcff1df8dc764b6eda2abec3c7f2a9c9df27", + "1.16.4-pre1": "0ae53adc1154fda746dc373dfb5af7b4d1c253f418c84118880d1840f362c468", + "1.16.3": "32e450e74c081aec06dcfbadfa5ba9aa1c7f370bd869e658caec0c3004f7ad5b", + "1.16.3-rc1": "e0b7fb222eb8c4225bf66441045f491da06de851382ece144bcaff38044ef88a", + "1.16.2": "2902ed3ff84e4f810a2c0620c6b6df9c3ef8488b272c61274d5eac2433876f39", + "1.16.2-rc2": "fe0cbac64b7d62ccc50519aa4b8c58a780d8aafca4629d964bbab9903774a37f", + "1.16.2-rc1": "bed63f508d1aa1dd7293ee7d965c62237ffe9e11a753164ede274759758cadfd", + "1.16.2-pre3": "fd81170c5abc101e41a79a218cca9ef59d44821a7cea540b3704d1a79159f098", + "1.16.2-pre2": "a35a434a4bce236c95828e95a4814c2c2938c39feffbcb707bedc430c08eb39d", + "1.16.2-pre1": "3f0eb69be02920a4ba5006047fba2534d3d2142e90df25de4fdee105f7e4894e", + "20w30a": "127b624cbd83fac5931f753ecc16ade2c6324d163ed6370c75b3561ed2381799", + "20w29a": "b8a34501e01b1f2e5cc7e34d90ec8fd54cd2f99557c7fb464ca5bbb6ab4d4237", + "20w28a": "c00f686fa78732a1446fc17d7c3a76695df64b8085005e6c816aea19622b3019", + "20w27a": "a01d3784a033cfee18eb43069f73f450f8a57ef64615ccff8d819af4d2394d85", + "1.16.1": "2782d547724bc3ffc0ef6e97b2790e75c1df89241f9d4645b58c706f5e6c935b", + "1.16": "7d2d2d127b90baf2bd8fc61092cbff42bea1bdfae30a2838f45edb31294979b9", + "1.16-rc1": "372963701a2a7ae47eccc8bf9879a11f2e283c2ef5f33a0275c44aa26daf9883", + "1.16-pre8": "ac4a92fad98af7b65e09e6d4a833860242b9f4938c94c74b8e7661ed5139ab5e", + "1.16-pre7": "d99c608ab3d8aa84fe9851684518fa97967a50d8d2c7ad45b449c4ed72e72a73", + "1.16-pre6": "b33afa7c5ff0586334e2961cc5d81700a1c5d8e7bfacbc155534d41fe4d20e4c", + "1.16-pre5": "cf7999b76c7659bf4a277943b61c0571b8bd7c3e8764f7bbce7a16d461ab7b2c", + "1.16-pre4": "70da4865676df62aae67a897b47684efcfbebef491f30bdfde0962e821b03727", + "1.16-pre3": "9c9b0163aaea79e9c437e7c27befa27482ec21284a77e0f07b2e7765d44e1b00", + "1.16-pre2": "cdba37b0fcb9ed958cf999d3f763d146738e2ec339ee7c9052db053b47e09f8c", + "1.16-pre1": "c9c62c1410601fdd9b14cf0e2545251577512fab586bc75c10c748ee1d9a8ed9", + "20w22a": "c155d45a4bbf6150d7b187ecce11eed199040299f9ccc789cf3e6d89e667a786", + "20w21a": "68fecc31f9ee1952d099269a946e7be8742b04ee90a447e9cb42dbc41ec46883", + "20w20b": "933a424ad1e82d33b0d782b54158e877969dd0893329f190495ca3ba287e8358", + "20w20a": "67c6a4ef7a0cfdf1212b6d9ef28de92a855a78a9b053e3ef5e6bca5e5bf01a41", + "20w19a": "bcc4c321cbe9c1d95ca4d46677f93488a10549337d224495ca2cc1b2fe01bf17", + "20w18a": "9deb9a207c9f4ecf43bd16fbd7d948a3f85642981d65e62715312a4f9e36114e", + "20w17a": "fc40e4759636430547f22df66d6431a99abe16406d29035283d8fb60b7286a55", + "20w16a": "3026f0a3e750b16b5a08c1e7a5172ebdb908ec662af52759228f00d1275a7c62", + "20w15a": "eecca6cee337657ece906dd0055b275283e0b9bf82e9703bebc2ad88b45328a4", + "20w14a": "0dfd9a8d5b09f0f5abf9b297855c8332e490b266c21e77ccfc3c60560dd1e5ed", + "20w14infinite": "1b31cb0c36471632c97b3ea30962c4b8f210f73094a81d9dab8cf9c6f15871fb", + "20w13b": "66fca31e1d3979161e2ac5114647c44faf3347e2eba2a1793c6769e71d2a50da", + "20w13a": "f216eded69f2f22cfbe3c3ed8baafea4a33b2c0f94a04778f09d2dc20236cf86", + "20w12a": "461a870208ff3ff51a5327d6067c8734f9b8cfef9633a70d3dc550cad31b3e17", + "20w11a": "bc2de25797bce59b753507cc9f3cda660c1065999a46c7ef16c3e1f51abc9413", + "20w10a": "fd52aa742e806c6fcfb7afab7f7c94528ff369995df47b474c9a6e5596527887", + "20w09a": "b5417d9821ecddb112eff4dfe5b5b456c2f000896518b10f7ecc0f767600f531", + "20w08a": "fec35ad3e793348ad344afb493cb22d17938a98caa27913da4c37a9782a98e74", + "20w07a": "16def0cf2d6db7f251496772ee44dd68c5af0c71512facd29765ff2ca16852f5", + "20w06a": "cbdee7195fb76e87d39d57fe07e6e10a82b5e9ce376129edb113e2b9cb6ce7e1", + "1.15.2": "80cf86dc2004ec6a2dc0183d1c75a9af3ba0669f7c332e4247afb1d76fb67e8a", + "1.15.2-pre2": "7271730b514ae2520bdefa4fc58614c40511c43ac0d9079e664ff68811226c30", + "1.15.2-pre1": "0452ef9adc2a75394474d7fbd84a043f24659e1a8ae885c4b1d61301e53ca858", + "1.15.1": "a0c062686bee5a92d60802ca74d198548481802193a70dda6d5fe7ecb7207993", + "1.15.1-pre1": "6a2a5c42bc79b24f13659fe90a37b30d470c4011f3477bdaedcdbc429ecdc548", + "1.15": "e0fe1749263b5ec211b358b598b46e787645bffa8411414f0c812a92bdc70c84", + "1.15-pre7": "4bdc556ac0deec5fee4d1ad13abbf5ba5798992f60381747767f33f6c3262653", + "1.15-pre6": "d574de39dd9897e3d6c616a04313fa54ac394c027e25f212defb99e4210f222a", + "1.15-pre5": "1a78bed6ad7c6c4e2f78ef5552b1085a98ace4190968f0d78a1663510b0116d6", + "1.15-pre4": "2313cc6f9e2b6d5111c513c68fcd7162fce255f836e67ac21473dc3a5c812924", + "1.15-pre3": "549bc3993c77ca5cfafbcc224b66dda0d8f01e194cd3df80daf7cdae66632934", + "1.15-pre2": "3a4bd1e1635e3cd56abe7903036b1fb70514204ca4f3d9e4195703e1a9cc324e", + "1.15-pre1": "7c9c58700f70bd8be4e07ff9ba10e6b0eae64735e7b7c784f277dbd7d26e9f61", + "19w46b": "e108ffa6fab53c29d159a3d38ea24fea0f5d2f7783dbf707a4059bc364748604", + "19w46a": "9823fa660bc1ec137f1b8f166267a36ce355dcd20a64f48e1ad36a6b915e3551", + "19w45b": "6d4583a47adbc27463e173880cbcaf55a35537806857105c7eafa7f808c73cae", + "19w45a": "76caafcd6f41017f2c53409b22927a720313ed2b6e6600dd2fcfc3a3569669d5", + "19w44a": "36f8714c89b3f2be48a36bd8e57a751abe3ad6b32bc7e5431cc8923d1f1d774a", + "19w42a": "a729692962823fbede0c86f03aa9e15517f55e7b99ca970415433263be08a88f", + "19w41a": "dcd0755feffdccb93ffabef6a70c85992c3549a0f3868a38187d6b05f7137820", + "19w40a": "6043b833358370fbb7455ab66f5384d37ce8efd5df1578f1426aa389bcece864", + "19w39a": "0ffd910953ab50a736dcc0ab214a0659966713857422e9b7211440b7b181a427", + "19w38b": "61966431ad3d6d70f01582d0e44a6fb88d397da45038b14e08739107fed27906", + "19w38a": "fd23eb1dde29585ba9fd483b13efbf5cbf0a363178fc7207d2754efb8fa68dcf", + "19w37a": "c5d58c9ee416dfa2ffb5523062eeec002048f3a3cd1d3512f3a1f7938a43278c", + "19w36a": "392ec1912c4ff94db122584a32110e2ac4b2a7bc0ac7ce01fc7fb956748890ae", + "19w35a": "5a32b44741e23f43aad504cf83be944b5c5bb75cae1d640f7ecfb7f7e339e744", + "19w34a": "e90576934e580b87b8467cbec0c2a8614e8ed4b829aa4a409d89b7442ea610cc", + "1.14.4": "5ecdedab3a6e129321a444490d0a467c25ea702a24a99cebe3b6aed41f8f5729", + "1.14.4-pre7": "23aba0515ddc24f0936461275651f25ab2b94c927302fdd58cbddd40b2114260", + "1.14.4-pre6": "f18763257d0f23e17906c1a45c1f0ccb123ea68b6b916d2725a8516409cbc110", + "1.14.4-pre5": "b9acac7a5963bbce7ca45d2dc70a4444b69965d007795d32ded9f5beb680aa13", + "1.14.4-pre4": "4b280aa3bba0bf8213c7347daf043bb2943d9a692da359beee841a9149bd3e80", + "1.14.4-pre3": "d86b1ca6eedd93c3b6c5768ab9901176c95fe07976ce949e4d01354698ad0b53", + "1.14.4-pre2": "1f894e747df7106ef604d9000462e5b8a0fb8347fcbda4ac72e534fec3f704f2", + "1.14.4-pre1": "f326a95cf33d4744258da423659dd4feb06d4c5bcfc1e7d5a3282063b8084102", + "1.14.3": "942256f0bfec40f2331b1b0c55d7a683b86ee40e51fa500a2aa76cf1f1041b38", + "1.14.3-pre4": "0f14bd6bcfb987bb98e1e445f1eaf0a330fe8c3f0dce577707bb6611e600af53", + "1.14.3-pre3": "4230cf4f2ed3940dce187cbc6d6f5ed5286b3cc0ec51cdb190bfb0f696b6a563", + "1.14.3-pre2": "6285e75d6d63e82ca07308369465640b5570eda46cfa28aa63dc47a83105e86a", + "1.14.3-pre1": "ca7ecc41eb29dfda1fec8a4d0a2122b1550ae8129aa7c6cb3e831140dcf90438", + "1.14.2": "b47fd85155ae77c2bc59e62a215310c4dce87c7dfdf7588385973fa20ff4655b", + "1.14.2 Pre-Release 4": "8be46e24663acb8a7905029d352af180b39fb98ef7123f609ce2ad208981340a", + "1.14.2 Pre-Release 3": "efb78b0d2f20ac0ec4ec2a27a1eeed7c39d6324060950f8f17eda81a32cf5329", + "1.14.2 Pre-Release 2": "33589482861ad3423c36625f76205498d4e55796956d44f20025ab9e972d34f7", + "1.14.2 Pre-Release 1": "47ff9fff64aea5460c1efbd4dd62c7477e2f28ecbb357edadedf7da558cc1cc5", + "1.14.1": "f822f0b730b7e1f05fca84248a6873400bac4ca449ff6762a55cab3d68b1f03e", + "1.14.1 Pre-Release 2": "0b8271aa0f4c0cdefd311d4d878747405efe60c95710e0a93518f1bc996ced9c", + "1.14.1 Pre-Release 1": "6aa6978e91f9b89c9ce4e878e4cf93ec3d4adffa1fdd276924ea544ba282cd28", + "1.14": "671e3d334dd601c520bf1aeb96e49038145172bef16bc6c418e969fd8bf8ff6c", + "1.14 Pre-Release 5": "dcd1365d9031c17c64a1aca227e0ea1b339f92f162b83aed4c30d784fe3dd690", + "1.14 Pre-Release 4": "008f97dd87b89afc8eaf672e72bf3c37ea4d7961cadda8fe502b51130c418136", + "1.14 Pre-Release 3": "b689a062f9958121da1f656862a5239c3ab70169f6e1851f824621da78636db2", + "1.14 Pre-Release 2": "c2868040da54c422bcd037aa139526558b0abd0aa9d25b995384b0a9322b60c5", + "1.14 Pre-Release 1": "89b5b5fcf3602657b65cacd7358923fb0a921dd9e3aef0a57fee89f3acfea6ef", + "19w14b": "dffe87aee391720976d3d9badff28f56886c50fc6bd9cc80e4248a6a6cb683eb", + "19w14a": "16f04769de4092d9fec1285fddc9d41432f09c93acc88eed1b817204d5109624", + "3D Shareware v1.34": "90d4fc6471ff11ddaff3f981327f851e26891c184716af74c84c6e2fbfd22e67", + "19w13b": "14e08085fc7b42936a9d7567c821cf94b4fe8b4e631ae71132a71e8e8cc189b3", + "19w13a": "60c5eb086681dc5b0f82b42ae10f940c6215c69e1dde558356cf04c8c63a03ce", + "19w12b": "db02070f79fd42d659bc2de8e373836a77504bd4624a62acf1f6b61a6a967614", + "19w12a": "7241d81af90bc685ef1b4b500119cfdbce97f60363a5286b863b00102ad33a2b", + "19w11b": "a95568c5f2255eb3ec1b6125574db35f4f0eca1c1ee1276748d2019d277d17be", + "19w11a": "c9326a467b8d503812035c9000fe527632dcfbc0feab3d3012f4e8c8def0755d", + "19w09a": "10623b38e5343c90aa5b56afa85b6a9a5508c509586afcba3ce75ff3de04b781", + "19w08b": "7674fb600ebcaaeec29ce4ae0975b85b8cfe768eb2b2dafaab1d2840c6d976d8", + "19w08a": "cee4a8ce0645e04ba95a5a7c3f7d98679979c1f781881aeb870a36d303993d24", + "19w07a": "ca225e2ef6b5f39c4c7558fe8d58931d6eb3c9cff8b69b24583b70443e83baeb", + "19w06a": "f3913592807eedd540faa160b914785ce39eaecfc908cdb5170f68e0b442973c", + "19w05a": "efa7ec1b4ab2102146969b9055fdf055f0ae651fca785fd83d32c419647d6a7e", + "19w04b": "1088d3c424dc084f1d10acf4da07315b61617ae7545dad2e0a0d99b5e4afda8d", + "19w04a": "4a70a201366bf22639b9e535ba7fe418a082bde36d4882acef6373589b9c793a", + "19w03c": "420a09a8011532b5f14aff99c4ffc3eaa63ac55732f7701998d9eea62ed8f16f", + "19w03b": "3b5f4bb672b9881bbf29edc935fab991124d5dc3d533cad5b4789f94d6678919", + "19w03a": "39af3a62ac674bab60af54559cad3dd1f37e8b3d097908173849551f0050b8eb", + "19w02a": "60a8947079785b4dfd6398b5fe5213205a18704953dfa9779ba1a76cb68cdf6d", + "18w50a": "a3a6e4519823971b04b8ec719d78443a5b2482161d0dd5d00711cb49cb342ffe", + "18w49a": "926ab61e8064b90d35f8f48d85703b1862056eb781769934551c8a20f4b37e13", + "18w48b": "1b03939ba45778497638df11582525ab9c436c3f53fc9b1d35f229240ec14fe1", + "18w48a": "86db5f64d2bba30a29d3c89dfc5eb1c3fa69db15ee864bbb159980105020cb17", + "18w47b": "e88d8a372a02c1c917f90e330c0b84425c60ebec79c323ebba93b5da2e1faa2a", + "18w47a": "c43648005b63737e2d62a9978a8ac3a6fa218952d64660deb9aade56d1ae20ee", + "18w46a": "0d1688a0aa0e5ccd5689949138fde0585e2039b775a1665e762f9a98d8d3860f", + "18w45a": "d9652f4958f840a34746e2dd349d8e91fb92b1d5193cfed0b799267a447d5c0f", + "18w44a": "f623d5a5f2c0360eef8397320f8784b4d25e7d513e05d27745e23696212196c0", + "18w43c": "91d8eeaef10c01de935fbd8018e9360b4606b4dfd000cfa25d99ceaf7faa1310", + "18w43b": "d64207c164f66248b168e6b8db1daad034f0e796ffdaec8dc3e9bf671eeac2b9", + "18w43a": "0d400f0ad9f31d789d779abaeaa455f120dfede7fc9aa4b429e4f63c7a20cc0f", + "1.13.2": "ffd3aa2c25c5ba68a706b59f2abdc69ac1748e115ca9d3b47941e197736f088e", + "1.13.2-pre2": "5d7e47d3dbe2f464dd4118ffedd37d5aafa09f601bab9806aeb61dc9f42a41f7", + "1.13.2-pre1": "029fab08d8ebd36d77c79e9649d88f5fde1d6d71ca50beb40ec754f95ab8ed43", + "1.13.1": "2ea6047e7651c429228340acd7d1e35f4f6c7af42f59f92b0b1cd476561253d1", + "1.13.1-pre2": "358feb0457aec2940f0a53252cd9ed66f5837710e97871f0d4d25dcfdf00b5f7", + "1.13.1-pre1": "7a6cb5c06dc582e2b5799fec44e8cc2d105ed1229c0576273749e2d067aafe44", + "18w33a": "b116e0785bd7e4853468a9e9f300c07d872f2e2339d682e7237ec62076174dd1", + "18w32a": "99de375c834c939347f025b1afd93d6a464fdf8c3d431cb70701cb535a11c90f", + "18w31a": "d27eb76f6143fe2cd6e641c388b588299e9417c156c498cd39be97c91639608b", + "18w30b": "6127da70d52b47d51adccb0c1627f692f56678ea68de404e9dd5e6a15e6a3ae8", + "18w30a": "6e83809a5a308e16ce266909424b8a6f539af47c82f2b964a57e355a30c65325", + "1.13": "e76f3927904d331c969a2c437d5661ac02f24be86062dd1c607bfd4ebdc550b9", + "1.13-pre10": "a8ff609f7891618b63f1f163d2416650779d2c4fcd6300e296fd994983736b5f", + "1.13-pre9": "33a1812ea0f128551cb2cad28f28808b11434e16c55f4af7b42ba9f9eee787d2", + "1.13-pre8": "a658c769b8f29ac4fc103a4c7b24cada1f0c48400658bd2a7def60b1152711b9", + "1.13-pre7": "21086e3ee7ab37f91dca33a57e682276f8f28d3dff37a5029d756d3dc36da0d3", + "1.13-pre6": "be788e95a3b60545a506e7841c091651f1f32d5a078ad12f9b4c74e57121bec9", + "1.13-pre5": "96acdd0a6389fdc049ba597818a1d27a4b6747385c43b7db2d1e9acd2ea3529a", + "1.13-pre4": "45a8d618b2b8771d91f8f689871cb2163228461eb7e637acf95dcf419f830b94", + "1.13-pre3": "3c9da719f5501ed27fb54cfa19e4fd96cbb69e07b92c0def37fdcb45782f4780", + "1.13-pre2": "c3bc9ef540ecb5819252c4ab85cd7bf492a5e077e250d3b4958c8ea0774cc8ee", + "1.13-pre1": "3fad9696de535038e90d2b4338d817256cde2d9b23cdead190ee8900aa9a2a83", + "18w22c": "4a176e787d8635afc5ddce5477a8fa992c8da07df7284062dd1e6139511be83e", + "18w22b": "d95f737959b708b7af63fcf71eed9d8266e668ca522a7eeb67a38460692bf6b4", + "18w22a": "c534aa4cd9251a005c701a22b60e69e35bb39d7479962dae3f3aff9b4ade0eb9", + "18w21b": "0bc312dbe2c1ee8fc411210708dea05ad51da13784d94c6445922e6541f29184", + "18w21a": "b38caf0c824db0a4fc3d3056a2f47d93ffa9f9aaca1bd4bca8d8ba0a35e0118c", + "18w20c": "24ace4e82c4b907bb3caa6953cefcdd33f60d4cc00f8f474365a9f1402ceceb3", + "18w20b": "37561559343a5fa0354d1d4408bbb5da190e2b5cf31d79cc939cdd494c27c34b", + "18w20a": "e9cd263e1608c8d40841a9254214cd3c93479711056cea004265a4dd322f2996", + "18w19b": "2c048d0accef680ffef7cd2722682f65765d2d085633bc8b5b7e5baf1d378207", + "18w19a": "548def192939baddf8717619ffb157d95cb22f069b6627e4f26008298cee4e10", + "18w16a": "16696c021eecb5150656f6041874a5ef7f40f1a01f1c53c68885b3f17ca249b1", + "18w15a": "b9b11bab9b5d1fef199fc277bd0b68533581d3150f49a5ecc7f8e39dcf627af1", + "18w14b": "b38223a2452ce41aa7e2266a7b05d2015c99faae2426b39713c9f1648665dfa2", + "18w14a": "58cfdfb099ac2bcc7fc67536b0a8b94488ac0acd099dba10bd280f82285c71ab", + "18w11a": "4b11887faa3725becf4385f8b0c014cbfb24d6745216a69177deb91c168250a1", + "18w10d": "c1b13e09fed92138b04151b65b6144be42fdf28f71d8a5035980422114302e0f", + "18w10c": "949ace0cc5a1193f4c1d5a54ea16b7db4ea5cf36643f6bc521df4b82ff12a990", + "18w10b": "d88febaf435c7858ef4faca46550f8cb8f06df2448332a1afaccb54a1385cf64", + "18w10a": "11ef7c590536ba0496e6d9aa089df5ad2b47d2ea1531bf62a30cfb6ea841c225", + "18w09a": "17b0dd2f4f75d30868b2bbe5251cef7d9a82e3c06a843aef54934f93f27ac139", + "18w08b": "1dc97992a9ec2558e348f5e4f67d55415cb906d494b2e8f5e44d7613cd2567b1", + "18w08a": "116d6ec30088b552a2c8f38da270ac11e49d4e0f6226716df81e023ab4dbb749", + "18w07c": "b25ec60a1cd18c878c99865db5767ab86c54d7e18e9aa0b6e5d379feffcd6f64", + "18w07b": "990777b050e015d99483a902990247c55ef402fe6a18aed4b53bfea01a92ef35", + "18w07a": "e40a0b64b3e8372cf8673953abd32b625c64115b0a482d4f16989bf340c15127", + "18w06a": "5f4d89418606c8c14be0d428e991b5cd62b58fd2762a6040284877f29eafc60b", + "18w05a": "399767cd2cbe45b4dfb2a3746b885a989151ca4fa4c2a22cede14333be28f8ea", + "18w03b": "a8d380b9a28a07e544308ab55e0c86f01dd509c890409a6a4a7d0a88f7b51fe8", + "18w03a": "5cf4d2680901e4483f3d7c09b7aecff612a07954eca35e12b4645640334e0b92", + "18w02a": "58bb24f857ecb851c0b34ec711225642fd75252ce62b55ac39a7650f89462631", + "18w01a": "d017bfc7ee82317140cac4c0f722da3f7e4f6bbe73f45f10ae8bd2cf84787f55", + "17w50a": "46e7c69769cf1da826258d9d6b17592273c2728bf91e21fd26b3508dca0bebb9", + "17w49b": "a68135f67ca398bbb9d74942f94db015458c0fba7f94968753decabf56dd9518", + "17w49a": "b1f37d282f5100b1ff46a40c1f25465983f9e1fa24fd511247c18f69bb2cc80a", + "17w48a": "af1cd881b372f8460467b4d55120f89fe39a2410c6d15f5ae34ba8d0fe65e12e", + "17w47b": "6d88ff769dade25bcfeb56c6292061487ba2e3613c29a313b8e97e624aafe02c", + "17w47a": "376f208c9f43356697f5e28d0a3f50c112b78da35358c3b0f89c99e171b6cd30", + "17w46a": "417b0b0b39f5c8f1775ec3dbb8cfdda116d3175f4b68fafa87092644ac8a22fb", + "17w45b": "79ca13a3ae15b69a665762dc7859c50769b6fcd2f4d3f55797a242851155d253", + "17w45a": "a230593306d30e11d2e3d33bacf33873461d060270e138dc083871ceaee96032", + "17w43b": "2f4d74b3fa01d970289e9c4a8e5e00b2930d8edb621b2d693ddd6bfbf7df8b99", + "17w43a": "0362895039ad160810c7a96886938cf01d79aef040da65feefa3e9ed8e60cc82", + "1.12.2": "fe1f9274e6dad9191bf6e6e8e36ee6ebc737f373603df0946aafcded0d53167e", + "1.12.2-pre2": "724f1b2560afceda2f0bae37b4b9d3cfada7203edab2f4e1ad5fba8d9085c67c", + "1.12.2-pre1": "c4413733ed6d43f77706c7c8888b9dd4301d613a96f9e1d91d11d3e0c3cb6380", + "1.12.1": "848912640bccfa7ea34a2cc1c76cb2b35f8467c4216d9603917c991660f91a8b", + "1.12.1-pre1": "95a52cd874e32fbc4e4e490c64a1ccb47628f693a910c49a132dab9907694c9e", + "17w31a": "cdee889556c01336f33040c24adb6d59e474f8008f93aeeb7cb09a38d08f487e", + "1.12": "feebff3834e41cc096522525707d2dd27adc2431b1f3145b9d0ccfc4c8a3dc09", + "1.12-pre7": "670fe62bc16e28dfac35816e72ea639cc6cee2c11113d5c777f7916bbf0a3e68", + "1.12-pre6": "0eaa41ac44f2821fadb8f795e0b1081588418972a4c00e700dfc943ce19497a4", + "1.12-pre5": "53860ea9b4c0635da1246af27349b2e76912b1fa74790a2ecd8b60b99b9a15fa", + "1.12-pre4": "ba153a70a4775e03149abf4d93ff4f5272fdba005f5a3b24f9c781fa31c8c965", + "1.12-pre3": "9441df4d269ac3f7adc4229c00bb16c1f6292badaf89b425a0ba936ac98de051", + "1.12-pre2": "7c049cca26ee35fb3758487019c9c6d4784400089283ea85c6874e7d4d1db3cb", + "1.12-pre1": "1ff015429a46b8d6433fbb775c5b3d93863bacd30e085b1ac52e32e3fbbe6c62", + "17w18b": "acd52df80ad8c90463184e2b0e2355695aa3857ebd65902f8b82fcb029a23310", + "17w18a": "4db1fb4e55b4d87c85e2b7580606c9104065ad4a5ebe09f3f9c9269099967540", + "17w17b": "a0cc570906ac5cda9995edd7648189f3df1c42b11e6ff48ee304fa6f0406b366", + "17w17a": "9a6f5985c4612a903b875c09a2ad058440e4c14ecd03b929d1cd83822c536988", + "17w16b": "1d0c3f8028495275fa58c765f55081ea07462c33bdd0860b3d7d726367b739dd", + "17w16a": "8eeead591b5cb4660a0a800af02800816c34e6d221d989e21d20d0b2e1cf87f2", + "17w15a": "1f5125fa4829159297073245aa26fdfeef19f75df958877a0ad980f85d051276", + "17w14a": "1bf64744a93be9efd6eb866bd66875e81dc0af93b0ef0d190c4ea2ff3b8d8dc4", + "17w13b": "24d86a3d9fde9ef9ac748732f641a5a3f600c4c3331c3e1994a7403c5f917926", + "17w13a": "407832c1add81e2f1d1c5f4349df90ab4d0206060399ca5fc5e27cf15029ac2a", + "17w06a": "2f9b96bcfc7b19f51c26e623e22c76d7acbd009ca00ae1483123baaeea97258d", + "1.11.2": "dec47d36b429fd05076b90b1f42c2a25138bc39204aa51b9674ef2a98d64d88a", + "1.11.1": "8002bc32fdba21bf73fa30d94524b0c823bf7256e9c71598d2f18fb319e72c98", + "16w50a": "1fcbc53dfbfcf653c7e27621168a2cc4b204f0c7b07f371ec19fd90a8ac4f4b1", + "1.11": "3277965fd83d26944dfbd1b9740d95cf206985da330a1b3733868e4de7dc6f83", + "1.11-pre1": "41a5a5d9ecbdb9876adb834fa44d17a617b263c2a909dcdd806182b1be47e053", + "16w44a": "5414d2c025591ab3b30e70054582ee21a8a4d1ef1607fec39f09ca2ab3a69820", + "16w43a": "4a9d0c7f6a1c3c78b864039078d5c5c0aa6e4dcf0571bebb32b93dc9e8d18649", + "16w42a": "ad5d72d4b40cde7ece1196d93518419822576bb9fb94ab914c82a1db22aa3606", + "16w41a": "44b8a5de5cca66562e6da8afbe195760292e65cae1ccdde2a0311adc9426695b", + "16w40a": "803679e3afb2ad21366b7b95f19d7e882a2009ff9d8e09220b75c3c220bf2b8f", + "16w39c": "400f18ed5ca26d870e8ff1cb143b02dc1fbcf262647ef2ef9fb3478b90f140d3", + "16w39b": "f3e2d0a4b1215b21eea13b11321c520493ff68d95a867334c16d25e188fa87d1", + "16w39a": "5d7fbc59215e694d8f6814426c47b79b28040081d30ad1d3c10814d6966fb85f", + "16w38a": "34dc55b0c358e68d22831f9da261939f407bc995e93b95d5c3c26c6bf443392e", + "16w36a": "69aec3df1fe9b087be60573111382c3c7fbbfc5e54f8a70e839bcf17ab9b1345", + "16w35a": "24f92327cdf6e836c2e36884a20f5d6b9eb62b2c326e73ec8c816b8ac8f91cd2", + "16w33a": "7ee55ace0109b7f31eab8496838e8b001da154c8c848b2d2cbc26952ef44107b", + "16w32b": "7c59216c96eb750e9e5a18dc255139645b90553942ec9569ea2618e5a88a9849", + "16w32a": "ff7cfbbe3d93e0ddd7d79b9185051d0a607c2d98bf3240c3663e74f9d219d6d8", + "1.10.2": "195f468227c5f9218f3919538b9b16ba34adced67fc7d7b652c508a5e8d07a21", + "1.10.1": "623c8d67e7357c7078f30cee450562c52a05a42c394f064474c7f2d45b4a7d3f", + "1.10": "dd44a72e920a01dedf57507b73642f4a9dd8c6052e1f42ff6cc0635008014201", + "1.10-pre2": "80970a1ad42fc434d306205dad3e71d74d2af057eca8a49756d742628b3725cd", + "1.10-pre1": "920f899b57892424a9c8117f8c21563a76f5fe91ca70f94da467efc7772dbba6", + "16w21b": "52d3b46dac4f555930879f5eb7d326e55dd79a90eef381bd178fcfca42a263da", + "16w21a": "4d9ecda93f23af7f9efef333a4b9ba4d0af2484b4664940c1b6f18f613002c4d", + "16w20a": "9b0b5b31b198e67bdd316b1de23c2df587692d064df1b20746d40d4394891105", + "1.9.4": "13fea7aa10d804dd14ed7ebde2493dc64c7d3c8173369309bd7f6ea4c0ea40ad", + "1.9.3": "0eb669eeca23bad6d4ee5237aa24cd51274dfbda15813477f8414f4bfc2f1f27", + "1.9.3-pre3": "fd57a52c156192c06490a9775d694433065fc8bf93f41d6286ebe68db3fb213f", + "1.9.3-pre2": "7a21a84b472a50ef865872c7eef5ed8c2d0a94aed8a459a11562ed6b1086a5d8", + "1.9.3-pre1": "367f81f9a5c3d8983db29e6531c048d8abb5e62eec666fe1c2725b2ea455b3c3", + "16w15b": "0141cc7bf999021b14be167e087806135878410aa283a1f904a86937593aedee", + "16w15a": "748877dfaedaf531cb0173b39e54d9f005628a19e89e30204cad5ce3823bd6ef", + "16w14a": "37047a269614bf730a6bd942cd1180f7cffff1c33aa3e962ec895d7b5a34b7b4", + "1.RV-Pre1": "b85a55a0fc78771d2c3571dc7c4d9c8f1a2aab9edcfb3e9fe3825dff5befc817", + "1.9.2": "a972d127be3b9d5fafe5bd610a173563cb24331b6664a3dc5f73b3cc76d77081", + "1.9.1": "fcc5393c191afbcc0b706bd94616fb171e2fadaa107a3e5f36298ed2abf76c41", + "1.9.1-pre3": "b62800a3e74c3be7ee99503a848dd523e56c762f69c23d7ebab99d22538a4fa5", + "1.9.1-pre2": "eb162b8f05c638bc5ab296ddc56dfb598849ea29ba349e0dff38a09398904e1a", + "1.9.1-pre1": "20dca2343649d91ef2e017ca61d71c5cace0d73159ac020ec44e156c583509da", + "1.9": "38a797f50c71f55202e2135a30302cf3a5c8cb494c6d225b88599542957d3a7d", + "1.9-pre4": "e55154e2b238be686385dd3a29f98a8aef4b2175cacbc68432037def340fb6fb", + "1.9-pre3": "d652eb5de18eddd95de594d9c45b0aca76c80b72b30ce7b929ce97a512f662b2", + "1.9-pre2": "f5037617e8b0235544f546b178799305e3f32829996e0827c9f1b496fc231047", + "1.9-pre1": "84644f7ea596b7f730cc98018660ba6629e6392323ccdf5c71fb85332bc088f9", + "16w07b": "342bab138d633ddd2470b9b88e65dcc39da5fbb8b65b7fc917600587f084ac54", + "16w07a": "6ecdb84ba29e8478464997f2a0f8774776e209052e9e124e5c015a02b6caa149", + "16w06a": "88d71f7a3eb597b06c0519de221746ecdaab2cd82707ce443828c3c0becdc0f6", + "16w05b": "344ee31868913e095301a9153d118ec1b87bc624251231ca5032978d726e6515", + "16w05a": "e070b7a3a46ec6bd03d950f745e0d298e68a88b360d242797f50c9295e0f7e41", + "16w04a": "dbe527c539d7a3fc2bd6a50b11988bc3e484cd941f93bf8e6c852f4ae194aae5", + "16w03a": "992f0ac1b195a538f4b84ebcbe137935a714658d8acaae197b11508f7744e23d", + "16w02a": "aba2d1579db0afef8064747402622741bd771195d6377bd3629f7dd73a8a5cf8", + "15w51b": "8c8e8a435c8bd832a2c85358124f86ed9ccc327c5c630c28a10b29bd01526c0b", + "15w51a": "2b046d0728a74c0b016c9448cffc6bd49c05d1642156b3133ffcd937b98298a6", + "15w50a": "e8b92400015342eabadcfba5e6f31372a1c71d07dd868e0139277c0de5400d85", + "15w49b": "e948e5aa52ea52e323fbfd25091c7bfc687ff75a529e12efe791ee2b7b6342aa", + "1.8.9": "c18e4245073aaff580eb7359902f0251436568b1647a9e443a924cdb73fa8312", + "15w49a": "8f281199d271ea365542523e486d9b623d061867709833046442d3512c4b4720", + "15w47c": "0e70fb9243c774f12cded32a7c50b812cbf341cafa637fb1be46f788a117b9d4", + "15w47b": "f38c0d3ab4f7a9d7ba75e02cbdaa0ca2761a7406249ee03d51dac130f52c8234", + "15w47a": "ca07d91f61fd9661c52be2fd56270900e2d2d6659ccc8166dc4dfc89bab5f6e7", + "15w46a": "ccdc14e21259818d824c1498f035347bbcb74e9c4d6dc20169d61f3896eeb963", + "15w45a": "863eebd07e30d5d4d9f18f419d6cf123f75ac2b6f2c12dfcd61decd4202d269c", + "15w44b": "bb56195201a68a4966efe30c3c317d69d74ceff756276e159340d17b2b9c44aa", + "15w44a": "0a13803768cb8097648131d681c17f99f75c2948d6e5c6567c220f33c9e579fc", + "15w43c": "dd85afaca19fe0d335819dfd59e6162ad2675b0c59300a27c644b535bf359a8a", + "15w43b": "5efe6b619b1ca31a63657b6b58a1b9d4c27e4f40ae7d50d1970c1d3e692b4ce8", + "15w43a": "074212c0b20aba62419277b7ebcefc64c68d3a80813e95db7966a72ad67f6679", + "15w42a": "a16bbe888735e68872ce6863d93cc9d89df39ed2cc9e227b425e2048c6bc9343", + "15w41b": "8926af4de6224a9fef9f010fe7eb114da54fd4af86e21b055faca20c6f61c2db", + "15w41a": "4ab8a919feb53f5f8cb7325c001ebad6f3d721f0387983459da883c8563af87a", + "15w40b": "e2569a83a80ba0fcdf5c1c495b677c3652d28a3bbc3b14f91a3cbde5a8da0e7a", + "15w40a": "2a4b746ea50cb855710dd0dc3e04bdafc7ffe02ec9501e45b7782a9eaf0c0b55", + "15w39c": "41ed25f97e6e767d47185c5e73e16b288f71398c895f8874fdab620de951a305", + "15w39b": "a840c2fa3f11a3d4591aeeb9235d46cff269959e5c03f40207e00bbbb997abee", + "15w39a": "ed755605c41790de977c07c3bdd283e1eb79d6ee2b4ca33e476a3ff87f8154dd", + "15w38b": "8f7729e2c9a433e4786a04074d78f78d9a8496cab4a28d993558b31d1b8442d4", + "15w38a": "b46119ef3d5c5c2509108f86466cbfc15b5bf87d21358f5d787241f9168b61cb", + "15w37a": "e3e16fdb2149a1cd1fe3a286ae822a84581db90cf79f8f3646d3a3d394a55f34", + "15w36d": "3bcca5c4b0bc4281fbaf9bb24d64c75f90e003fa742f5de996aed0b21a19afd2", + "15w36c": "7ee42fe9f4b8c4df8918c8cdaa806d07b67ec0ecc6a0ea3ebd45cf084a88cee3", + "15w36b": "0641d3294b319a1116633f8b6658433ff6a5d46106f0027529bbaaf73fd0d8fa", + "15w36a": "88b420ae14500ece0164b70470ae114732175ba242c792379d006c9a8b09e92b", + "15w35e": "74bf64ef314fee221d5e330be910aa4a6bf55a8c1ec65d2077fb89be41bd64fd", + "15w35d": "55e871006e45e156de6a0dd4e3516ff7ab41c6e6803ecc9a648117f8fb512b1e", + "15w35c": "3c9afb5bcf6fef5933314ad09dda469be9e6cfbd07b8ab5789e468e4f3f93833", + "15w35b": "c31ee139dcfb4cecfb08d12313cee5bf60dd0f05194d4ddf804df471a0e76446", + "15w35a": "f17463ced603699eeb075916a97ec3de3171c145a619a498fcc8a7f045f4da7e", + "15w34d": "a1b9a9a1fa47bc8ff7e1efc8f3603921ec150abef71f8fefa5f209bbbe079859", + "15w34c": "62a1ae63cbf38ff9f94288b5d3dcdd462918cd4c2d465a809fce3c47ac05baf5", + "15w34b": "9e3c2f29682f2be1a4c628eb94040ac4d67ba4270e98ba8330aa3ce33423ce12", + "15w34a": "46875a087599b89618ee5502dcfebaa33307babb03b221aa9d924d83c5ce3f48", + "15w33c": "f36b658c781660cf4218e3ccc45aacaa2800afd3bc81f136cbe9c13364b6963c", + "15w33b": "c2a8bed5a76a95afb35950c2ee3605bdd3fa774a905103edafe915283e9fb1c6", + "15w33a": "4422eaf4702b96b40a58e7e6d440ca29289669f562575ea4016971971eca8be3", + "15w32c": "e8f3c78a60b659297f4ebda8351779a183385eaa2edab954920eaee184f0ab88", + "15w32b": "5aed03f99346ed70cb2f63279382822abe754f047db3f4820f1581754a0bb843", + "15w32a": "dcdcebf422252abc2fb9f2ff8835c39e3bbd0a0197b293de93eda5818ab3beac", + "15w31c": "3c502fad30a1bcd1b1ffa077825885754bf524b9412e2cbf65628b8609efaccf", + "15w31b": "e0de11fe8a2c9f1020e35d0b178b0eba37476dc38e7f7a0a15bda52cdadd60b2", + "15w31a": "e4f9e7b1ebbef58f1ae99c35c6cd3d0fb13d7f4aa6b68995501f512561c6a63f", + "1.8.8": "39aef720dc5309476f56f2e96a516f3dd3041bbbf442cbfd47d63acbd06af31e", + "1.8.7": "5cf4a49762c996c94f6b8b119f1c80b4de3c12b2f5c53268801905bb7daa0644", + "1.8.6": "7fc66b2b54f0f4d65fdd6d6484a50f432c144ef02072d3435d5660f120f58e0d", + "1.8.5": "6a412e89009acfcd5c56084ddab4f9676c5561bb58a3f22d5ea4ba4ac5d3503e", + "1.8.4": "394a9d0d5bcd03272a58f036b8736a47d26d63b45a4e7c820629114876e72107", + "15w14a": "9920b744ac1f7de76ebf4f33fae5d0b53baef35bb175529a3633f53ae17f2e99", + "1.8.3": "a26751b18ccc80ceef488da645c3b785aa528e2ae20a6a6dbd46f6dc754e62bd", + "1.8.2": "d99f3b3478018cb454fbee36fd60e3c4acbe1132f1cb26b3657f9ad291e7035c", + "1.8.2-pre7": "d19c50683a17c43fb64563a5fe75b6417626c57e2ebd76d04f71316794499b72", + "1.8.2-pre6": "01a28ada45d313a1ef59757496ae0c50b79f9152d5ab9fd83b90e9628345a114", + "1.8.2-pre5": "b9694f042fc6028e3d36160383169fe6d9a5455a05d002733f547466e5bce69f", + "1.8.2-pre4": "0511885adad9255c4d8e1d0b193f8b0fe3cf5fb323629235571d337c46f6a342", + "1.8.2-pre3": "9e5d0000101e61f3c5de520743850954a62d4b0b85df0d75a58f9f9384941e8a", + "1.8.2-pre2": "c4acd0660a05bc76c24c6bd7dec0c8c05fafd03a6218071cda989dfc2ec9d6b8", + "1.8.2-pre1": "00514ad81d46b19a8b8e066f0f4716c2cb0a3275183cbff9db119b01ea5e48fd", + "1.8.1": "ef5f5a1a1a78087859b18153acf97efc6ecb12540ac08d82b9c95024249b9845", + "1.8.1-pre5": "f492628c8a192e9ec8f9f5a4283c3c36748fe703d6733d4304c0cbc725d20ee0", + "1.8.1-pre4": "970cdaccb4ff3acdf2bbc5b4cab07e2ea9a1b20271f09829ffbecaba7bcaf93a", + "1.8.1-pre3": "dac84ad1dbd16365e48d59b6772dde5758256c2c9a315d2bb61dd29854595e70", + "1.8.1-pre2": "0e19fd1db175aa8a957c9cf7ce60dfc430090ed71d4f981af502856f69227630", + "1.8.1-pre1": "c581dc7475b45f35f573ca6ba90e9cf7df9b95ebac007594c7d10b24c781550c", + "1.8": "40e23f3823d6f0e3cbadc491cedb55b8ba53f8ab516b68182ddd1536babeb291", + "1.8-pre3": "da969b62ffde078c8890c40b05c47082bc691ad53b026534c87057a2f4cd0118", + "1.8-pre2": "5863a5256894335787f9a048c96650b1d28ce7e049641268bec89fe6e083a310", + "1.8-pre1": "0927b56f23c480d94889c0a837932a9d44ed848059d04835764f78365ef27660", + "14w34d": "05d4a426e75245475d3c88cbbe913658964661df2d8605a0598200ece76b81de", + "14w34c": "069dd43e6c3235d488f945b675798f5c522a0e9cf811a7487a6fcf48db52a3ca", + "14w34b": "602e9e03cc853f9291aed021f3cbcdc87377d2afbfe19a7de314ba0b759676b4", + "14w34a": "19bdb3b1366ccfac7fab918b9f02f75f420dcdfc210129bf909c720f2cf7a51b", + "14w33c": "594aaf7505dbc447b2cf3b67cc026c4885581e45841107d065544824e6e34667", + "14w33b": "b935e611d9a55f464fe29d6b40ea628d7d5d3e87e03c2ee84a055aed40757b06", + "14w33a": "9f6dc04ea65c2404e3c910efbba33ba057428c873b55ea21b98ade7ebaf8b025", + "14w32d": "a59c224c33d0a5b24d41d13e21509fa79053e413e22b05471118e221cbf65864", + "14w32c": "d673d52b400459ed6b5003af36897149f9917c5146452b87bf806a851661378b", + "14w32b": "c2c4ff5940197e7ab6ef922cccce04f9cee0ef118052c4d607d9351b930f3ae5", + "14w32a": "6969688ad556ee91ae078d31e7d5d84b24dd907ea0ee55976b90a1b16981ddbb", + "14w31a": "a58dd363035c79925a5ce03ef3da2ba5a25d514122e842d0188d4e1055259375", + "14w30c": "55b92a96054b8f50c7b0d4a67fffa4ca9004fc87d88402699d66afe230b42773", + "14w30b": "ebf69ac57cfa9ed3146bcc5b4757cf3faf2b6729b61e016c12e25f8ce4bf01d0", + "14w30a": "a9fa0fe45a96412e1e3dcc065de6b0802f659836e366115c6f9557c572560ad8", + "14w29b": "445d21e51f68b5bd30fd905c5739662f2f42fd6bcff8bf9a8fedc2926d94b407", + "14w29a": "d2f52276df97dc039ffbb6dd2b9a2adfc6fe8227b394e98efdf125646a4c0e8d", + "14w28b": "30974fb655414499f186018c4c68dc970f0ae21cae868ce1b7ee1debaf58b6d3", + "14w28a": "b894cd9051fe26d0619b9a717237ce770f8c622b16b39e514ee9e804fd9afe90", + "14w27b": "05511f5e092e94ac7342ddf9685ec3ca820cda5dcc072ed9a9deadcc6c2856d8", + "14w27a": "a8efb5015780dd070b8fb92a11dd876a20b653031f4314b717c972608003bede", + "14w26c": "e6793c31489f25a76cbb64edb9b675e13db5e9d7e7d48ec12aa2c3f16812c25d", + "14w26b": "f65db21b7e44208ccdfdeec9b57d6e4ea640cbbd8481503ede4a27f3613522c4", + "14w26a": "e998161bd9dbb3ce60c3fc757d227dcabb3a95836ddebc1d8740dd2888c06638", + "14w25b": "96bb81e322c8ad08e6b085cc6f2eca068a069db44fde78afb4760e060bc4cb70", + "14w25a": "decd0e814c06860fc66a8642fccfb1deeaf60bebd7980cbf975a0797051f6509", + "14w21b": "2c9e15b5d47eed678da1172edd0e05f07fca9b2b9d21a91c6d3dc92558c3aac0", + "14w21a": "387c81b1aaffb6982cbf1ba1926620235dccddb27cadc14449cdb5b3befadf1b", + "14w20b": "66543e6776a72fa407cae0e804e9fca642ea41f22b54fcfefbef76f584763a1b", + "14w20a": "0bbb5bd0038c09401033fe5d28f4c207c44bec6920afe5d6cc3eaff1a7b49ac1", + "1.7.10": "c70870f00c4024d829e154f7e5f4e885b02dd87991726a3308d81f513972f3fc", + "1.7.10-pre4": "882648310f8e370b7fcb71a2e4f0dc578d59d3c23f6d24e0509810fc8ae3edf4", + "1.7.10-pre3": "376cf7df05a1d1f265a0aec0129040e9ca75ba43c279e929b9e43ae5adb781b8", + "1.7.10-pre2": "21a9c212e91c0af5b4d56bf0dd411227732554804b009a4fbd36d7b15a4fcbd8", + "1.7.10-pre1": "c0e908addbceaf60d42a424baa1b413264b9618584948794ec5ae151a81f8068", + "14w19a": "632fd260c009bcd29e5d0412657911cfffd8b7664b5f51802bbfa169684a7cfd", + "14w18b": "24d6d49d9f3b332bb3c5a530561279077f986c6e19e8a4b6f7be3842080afaf2", + "14w18a": "86ebb97a1b18657f8468c45c64d7de6ce3ef19e9897564a4adb17abd5dc88fb6", + "14w17a": "c7f77b08a3df25c30386daaf75ec36b3c8968e6621c558c995c427adbd327863", + "14w11b": "131a37f8f960c06bf119d435682bc40ceead6de48b73aef2bda25a418c1c239a", + "1.7.9": "f7b9150d05c2cf8c48541527de310557e6bd9bde73e8ac9479e8ffe722c60a21", + "1.7.8": "5907ef1103acf15952d6d50cde3db01e4fc5a95b9f5fec0be25fa56a5ef0d121", + "1.7.7": "74646f88ed76d878eaaf2b28c4ebd4043bb11255999e389345dc55d2f11c19a6", + "1.7.6": "5cdc6ab6168ae496ce1d1ae96c0b165360184518d030417429ec7687c0b9d527", + "14w11a": "508e7a5c272b0428414ea6a84ca4b22c11d48ff4cecca26ae083390de8983655", + "1.7.6-pre2": "c4e8751d2b38e4ddb9d72189d5d3f0bfb82d5b76ce2d4792878cf37f4162bda8", + "1.7.6-pre1": "bf0407bd78a9583c0957cc6e1657dec4d19813b8006c6ae1e8f1c49f64807981", + "14w10c": "9dae5fe55d939a5837887a1f69844d9154888331fd362b1c3f722516d4f4f5cc", + "14w10b": "83d4a315981a07e1f1951a95c02f4d0d7a0b7d5f7fe476c60af78b6d6410ae2c", + "14w10a": "4b2d17daf2a41a336abdf1e098825a5cbf3b163bfb8d992b9d8c4fc99fd418ff", + "14w08a": "a84139d1887b20fa3363f6b94dd93de41b26c8a1ba1697967ebf26d65de6879d", + "1.7.5": "caa9e13aee32112b3b5305c02ecc01c05502fe244cdb83faa7a01832937542e4", + "14w07a": "44a814b0e306a0368e569e0596719ff07897b355df131b21acbe85199e6f9ff0", + "14w06b": "7d0d40ec07935c79c46a2f1689b0e983dc1214b7f57b9a91c5f4c47b1aed1353", + "14w06a": "5d2f23dc66faf6ab6d1b7196e30c0016d324e3c3a4f2b1facffee2677f8415b7", + "14w05b": "08dd19438e280481c2b83bc912b162250709ff523b127f365cc35c696c452c87", + "14w05a": "0c61c3c1d4069c29cd7db31593061a4b4abbe2b74623b54fa86ef73eac9daf05", + "14w04b": "5492e4ec31762bcd059d1d7238adf5a33e2bb5b5816a5d72a9eee6dbbec8800e", + "14w04a": "d24129f6e93e69706b465134177186d22e404067d58099ccc07f3bec81941085", + "14w03b": "0342ee40cb4bc3e2f2142bc4efd27033093c68c856f8d08f10afef3d0f5f31af", + "14w03a": "7a2a49d7985a9d82bc2026ae0465705cfa12f32e65f9bc1bc4d05de7e3de4f69", + "14w02c": "13095b3c5871fbbe1ee3dee8908b53ba2ab05b50f51dde0681b58c3f24777742", + "14w02b": "d39db2835ff2b2b2f6d6cbaad7dd626a2ffa8097343bf82c72a6203306208ae2", + "14w02a": "aada0c408d7776a08617748cbf0a3fd4abea36474d87fca1639b453512b48980", + "1.7.4": "796d6ca283861a3185f2e87ec321b1233540cdf2638da6e00f1d96d47791031f", + "1.7.3": "027b0bf027910d9ed3c49ff643ee81fa875e95052cd66ab7648a9e602973f266", + "13w49a": "94218edaaed13d8ddffb83cdf2f2f9e4d6b1e43d2094fe0a9e8c231f610a867f", + "13w48b": "9d66f65d85ffecf0c90fdc751f3ae1151f90dcbe175811f198c7320f1b37ee0a", + "13w48a": "8dd6c1fde2cc68f314bb50d7a0eb63a33d2d8ddcf1e5e0d92475529d2006b656", + "13w47e": "8123fad577fae5a92744847f203c3dfc8937de1f42ee21154c9e4d656d783c5f", + "13w47d": "ed8a11184ad77069a92500d0e90ff2f05e0d3ac4f0f40b7e51dd3b6198f869bc", + "13w47c": "aa0ef4490c66e4eb9d013d15a6368c38a658a3706bd9d6ca74ad1e5939879317", + "13w47b": "e4ea5ea684c7cd3c2a435fa35d351e5eb33d5a74b8d6c091001ea3bf3aac6d98", + "13w47a": "e2e288bc12fc7823dcc69d6e8dfbcf9c5e41c7f7032af5f2bb23d1f684977485", + "1.7.2": "b4139899700c1bdbf72880eec4bdb9e46c2cf22d1724a48a018ee0330035462e", + "1.7.1": "78ddbd2dee1c68b5f1d92ff4752cfc6dec3cc15320b41df142e2049c6e3c0ed6", + "1.7": "5a4e9b5a4cdfd7a681195c6016c94d8e5f49e18d91ba0ececa1d81ba5fb15aa7", + "13w43a": "7542353677246babc76d0ab2de4f2c9770c3685ce31f7f08143583b3c7f4f2d7", + "13w42b": "bbbff097c83f1dbb9120eb9f3af0457e339ca58f54b3be612ed4eec09be0b03d", + "13w42a": "0f62c56b10d7b52df893377b60ee9417c956513b8e1f1606b8cb7bee59404642", + "13w41b": "e9ef9fc8304460c9b0efc73e7a07b6b183d2a90d21c4ed13f4087e41742b0aab", + "13w41a": "494126c0143fa85a1b28ac03ee36d1d404931acad8364ee5f5e2acdebb53ed73", + "13w39b": "99229c20fc69fce7994e6db1f0b22224ebc75d5489fbddb4ba85a503ba6ad0ef", + "13w39a": "b738f75209bc5acb3ce9baebd707ceb98306ca59395c7a60e69370d61b48a0b4", + "13w38c": "d42dc65f9a173815a67933b12ef0abed77428142c6559336155a7f9b231b3498", + "13w38b": "571b16ffc98af57ef0f197318b6b3674a07bac40c541acbb90b5e5cca64deebc", + "13w38a": "dd58e97a800b63d88f3a9599120fec9bcae0643dbc70f3aa602e2f620f99fa56", + "1.6.4": "81841a2fedfe0ce19983156a06fa5294335284beeb95c8ca872d3c1a5fcf5774", + "1.6.3": "5a7b3f4ec258b55c8e4feb956d7861b48501a61a618f5c6495fc86ae4985f0e0", + "13w37b": "75090b7f800c690df76b8ada5d31ff96f54e33a24f6337e1c13e71dff625a938", + "13w37a": "3531513b752a1eb65a3f500e14023c04ff9711c61f4769b48e75d31d92a57bad", + "13w36b": "d868871077dba9094990274b8791882ee60374c2696a3b19d04fab5aea6da399", + "13w36a": "1db816ff10df3f0d5a8e9728360ddc8090f6ba7bb5e8c17066ea7ed614303a88", + "1.6.2": "99a7f4088226f5574ec47fa69fda4779376499e5c9c5b8c2342563c7ac35368e", + "1.6.1": "d58a6cc07305bc3bc3915fd8a81ceb07ba4bede3111c971115815789e5674611", + "1.6": "7e6fd851b7646aa32964b0d3370ec33ddc64695074e5c26634d3ff0951617be1", + "13w26a": "3dd2c0ea0ce08c1569acd40e1f09145c620325eafa65ef522551139e1e039fa0", + "13w25c": "949f32be91f32f85f17511abd47b79cacc7bd5d8d8a5d4eb99f282b80bcfd156", + "13w25b": "0dbe9d6cd69551c8f17bcbaf2adecffad6a9c5bc1c0f0e189c2005fb5fa73fc5", + "13w25a": "a9143baab9a1f0d693ebab2420d9e876ff1d62274781a2b2d3112bc0f3da2540", + "13w24b": "b7cd0305793f8a61363781b8dd9800afe0c5bb65e9542407a0c1d15a573b14c2", + "13w24a": "99e504f1ee5dc1b11bf9a084a8d8afaa03f413471f987a39d99cdd561af7b5f2", + "13w23b": "ac28f91f4d9dbe4a2c2098e1231345cb9d15d52df4f0405e4d2beeab30943886", + "13w23a": "005b3ba8d7c26c370be32503868b83984fc67ad88dc5e46b9208bf980335919b", + "13w22a": "a5e21402916da0fb6663a0d0d5584de2b3bf58c2620d4aae6235efb74747fb33", + "13w21b": "3ed24ca37e193d647737ceee6e5f3acb1b40d2209b46bdec9d87df688ecceb77", + "13w21a": "7f4d1dd9cf844fa590cc30051040e1fd8c3c115cb4b48a4b9a200cbe10a5bc0d", + "13w19a": "3d0083272afe405518daa345de20824272669bb91b0568b14e28cad06f4de4d6", + "13w18c": "f0b5bb768e87d88334da5d30c8cbd1503448e248091aaacd8cb8b56bb30c3a76", + "13w18b": "a251665c17145c9f5b0a7aa9f6ed2b7bbb007c48fc7ff4e5dc4cd3b2efb8c4fa", + "13w18a": "46e96fff19bada9e994e20fbe044ec4bc1b226a31f855b4a1760825425676076", + "13w17a": "f2fa011daed1647006df3859147c08d5935f4a9700c0fbd300524c3d800d9fde", + "1.5.2": "4f0c7b79ca2b10716703436550f75fa14e784b999707ffad0ea4e9c38cc256a0", + "13w16b": "ee358d4f84f91f1623f746dcdf157bd526bb2897d5c5293a099ec7ecf72dab51", + "13w16a": "3d3d2f3d9480ef2cb1abd31a1c5879603af0260bd95fd298fed6f026bf42f7ef", + "1.5.1": "e8dc60c93992e495c3a9e6dac7517e6811af7a803a3419eea50dc78e97f51297", + "1.5": "71239880440f8f22a96aac5c00d954401859ddc8847b4f973f563de0b6a5d781", + "1.4.7": "96b7512aead2fb20ddf780d7dd74208d77f209e16058ea8944150179e65b4dd3", + "1.4.5": "b8af871d6b0a03dd2fe65ee9238bb52c60dd5e30d3ded0f37a9eb860e5df206d", + "1.4.6": "90b3b9cd466abcd6ed9e932e1b81f8e34c5771f536670ed9ac493188b021000b", + "1.4.4": "2ea46e24c3c2931dbce11e4d79a83668cd6f002b5bfe131645a98ece099430a3", + "1.4.3": "283c15e256ad4776906e6832de90cecc9a5fd2c28651c6800024c3bc90f5f9fe", + "1.4.2": "16bc7305231d5ceba8b81e43cca8bdcd19cc6d92a488ed270baa5ab827b0fa40", + "1.4.1": "a6ff759d3161ceb3dd9997daaa53c3916f2c8b8b61e38f850a4d9577ea0678ab", + "1.4": "49c50a2c9ad4ab78c1ff9048c1a7f000a4f4d0628000902190ddc1a64d293b71", + "1.3.2": "0795e098d970b459832750d4c6c2c4f58ef55a333a67281418318275e6026eba", + "1.3.1": "62b8c8a3691fb5f51af3bd7efc34d1bc5a227e6162072e9827f439744df994f2", + "1.3": "64ba1cc32240cf12c76b6a235b299c16110e90a535f9c83fc08d9e2e766da0a9", + "1.2.5": "19285d7d16aee740f5a0584f0d80a4940f273a97f5a3eaf251fc1c6c3f2982d1" + } +} \ No newline at end of file diff --git a/src/main/resources/certs/mojangcs.cer b/src/main/resources/certs/mojangcs.cer new file mode 100644 index 00000000..795f7306 --- /dev/null +++ b/src/main/resources/certs/mojangcs.cer @@ -0,0 +1,120 @@ +subject=C=US, O=VeriSign, Inc., OU=Class 3 Public Primary Certification Authority +issuer=C=US, O=VeriSign, Inc., OU=Class 3 Public Primary Certification Authority +-----BEGIN CERTIFICATE----- +MIICPDCCAaUCEDyRMcsf9tAbDpq40ES/Er4wDQYJKoZIhvcNAQEFBQAwXzELMAkG +A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz +cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2 +MDEyOTAwMDAwMFoXDTI4MDgwMjIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV +BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt +YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN +ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE +BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is +I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G +CSqGSIb3DQEBBQUAA4GBABByUqkFFBkyCEHwxWsKzH4PIRnN5GfcX6kb5sroc50i +2JhucwNhkcV8sEVAbkSdjbCxlnRhLQ2pRdKkkirWmnWXbj9T/UWZYB2oK0z5XqcJ +2HUw19JlYD1n1khVdWk/kfVIC0dpImmClr7JyDiGSnoscxlIaU5rfGW/D/xwzoiQ +-----END CERTIFICATE----- + +subject=C=US, O=VeriSign, Inc., OU=VeriSign Trust Network, OU=(c) 2006 VeriSign, Inc. - For authorized use only, CN=VeriSign Class 3 Public Primary Certification Authority - G5 +issuer=C=US, O=VeriSign, Inc., OU=Class 3 Public Primary Certification Authority +-----BEGIN CERTIFICATE----- +MIIE0DCCBDmgAwIBAgIQJQzo4DBhLp8rifcFTXz4/TANBgkqhkiG9w0BAQUFADBf +MQswCQYDVQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xNzA1BgNVBAsT +LkNsYXNzIDMgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw +HhcNMDYxMTA4MDAwMDAwWhcNMjExMTA3MjM1OTU5WjCByjELMAkGA1UEBhMCVVMx +FzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJpU2lnbiBUcnVz +dCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJpU2lnbiwgSW5jLiAtIEZv +ciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJpU2lnbiBDbGFzcyAz +IFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzUwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvJAgIKXo1nmAMqudLO07cfLw8 +RRy7K+D+KQL5VwijZIUVJ/XxrcgxiV0i6CqqpkKzj/i5Vbext0uz/o9+B1fs70Pb +ZmIVYc9gDaTY3vjgw2IIPVQT60nKWVSFJuUrjxuf6/WhkcIzSdhDY2pSS9KP6HBR +TdGJaXvHcPaz3BJ023tdS1bTlr8Vd6Gw9KIl8q8ckmcY5fQGBO+QueQA5N06tRn/ +Arr0PO7gi+s3i+z016zy9vA9r911kTMZHRxAy3QkGSGT2RT+rCpSx4/VBEnkjWNH +iDxpg8v+R70rfk/Fla4OndTRQ8Bnc+MUCH7lP59zuDMKz10/NIeWiu5T6CUVAgMB +AAGjggGbMIIBlzAPBgNVHRMBAf8EBTADAQH/MDEGA1UdHwQqMCgwJqAkoCKGIGh0 +dHA6Ly9jcmwudmVyaXNpZ24uY29tL3BjYTMuY3JsMA4GA1UdDwEB/wQEAwIBBjA9 +BgNVHSAENjA0MDIGBFUdIAAwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cudmVy +aXNpZ24uY29tL2NwczAdBgNVHQ4EFgQUf9Nlp8Ld7LvwMAnzQzn6Aq8zMTMwbQYI +KwYBBQUHAQwEYTBfoV2gWzBZMFcwVRYJaW1hZ2UvZ2lmMCEwHzAHBgUrDgMCGgQU +j+XTGoasjY5rw8+AatRIGCx7GS4wJRYjaHR0cDovL2xvZ28udmVyaXNpZ24uY29t +L3ZzbG9nby5naWYwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8v +b2NzcC52ZXJpc2lnbi5jb20wPgYDVR0lBDcwNQYIKwYBBQUHAwEGCCsGAQUFBwMC +BggrBgEFBQcDAwYJYIZIAYb4QgQBBgpghkgBhvhFAQgBMA0GCSqGSIb3DQEBBQUA +A4GBABMC3fjohgDyWvj4IAxZiGIHzs73Tvm7WaGY5eE43U68ZhjTresY8g3JbT5K +lCDDPLq9ZVTGr0SzEK0saz6r1we2uIFjxfleLuUqZ87NMwwq14lWAyMfs77oOghZ +tOxFNfeKW/9mz1Cvxm1XjRl4t7mi0VfqH5pLr7rJjhJ+xr3/ +-----END CERTIFICATE----- + +subject=C=SE, ST=Stockholm, L=Stockholm, O=Mojang, OU=Digital ID Class 3 - Java Object Signing, CN=Mojang +issuer=C=US, O=VeriSign, Inc., OU=VeriSign Trust Network, OU=Terms of use at https://www.verisign.com/rpa (c)10, CN=VeriSign Class 3 Code Signing 2010 CA +-----BEGIN CERTIFICATE----- +MIIFRzCCBC+gAwIBAgIQWAyDGhMqlzv+buZKWtQ52DANBgkqhkiG9w0BAQUFADCB +tDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL +ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTswOQYDVQQLEzJUZXJtcyBvZiB1c2Ug +YXQgaHR0cHM6Ly93d3cudmVyaXNpZ24uY29tL3JwYSAoYykxMDEuMCwGA1UEAxMl +VmVyaVNpZ24gQ2xhc3MgMyBDb2RlIFNpZ25pbmcgMjAxMCBDQTAeFw0xMjA0MDUw +MDAwMDBaFw0xNTA0MDUyMzU5NTlaMIGKMQswCQYDVQQGEwJTRTESMBAGA1UECBMJ +U3RvY2tob2xtMRIwEAYDVQQHEwlTdG9ja2hvbG0xDzANBgNVBAoUBk1vamFuZzEx +MC8GA1UECxMoRGlnaXRhbCBJRCBDbGFzcyAzIC0gSmF2YSBPYmplY3QgU2lnbmlu +ZzEPMA0GA1UEAxQGTW9qYW5nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEApK2feVemdO2gfn5ewbbUZGeRvSygG+bHwIj4fK8c/epUh169x+hDUCrrSI97 +7mrulhegTTI+zqsF36SRaWOxlDu75LUGSqp/WKCRQyBqiUB+ZIJXaemWIZipBcWv +rPRYm0bZLMJERT1W+KlshCQSkXDcof8zlFnV4HQ9X9zxlk+9uKhYlCuM1c09sjlK +7xegZUIDiu92g/sRIpVHrtyXLbnSpHRxDzFYZkJDPFhVDjK2x8NIuK4yNOf1nWoM +QYu5V/8tD7uG+HVFTiIg1SkOLNW4XCn1+0vUt2TANga+/NZxLSrlR0Zwtm8KPTNj +o7aOZ40dCfbZqQlqk9wS+2X6awIDAQABo4IBezCCAXcwCQYDVR0TBAIwADAOBgNV +HQ8BAf8EBAMCB4AwQAYDVR0fBDkwNzA1oDOgMYYvaHR0cDovL2NzYzMtMjAxMC1j +cmwudmVyaXNpZ24uY29tL0NTQzMtMjAxMC5jcmwwRAYDVR0gBD0wOzA5BgtghkgB +hvhFAQcXAzAqMCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy52ZXJpc2lnbi5jb20v +cnBhMBMGA1UdJQQMMAoGCCsGAQUFBwMDMHEGCCsGAQUFBwEBBGUwYzAkBggrBgEF +BQcwAYYYaHR0cDovL29jc3AudmVyaXNpZ24uY29tMDsGCCsGAQUFBzAChi9odHRw +Oi8vY3NjMy0yMDEwLWFpYS52ZXJpc2lnbi5jb20vQ1NDMy0yMDEwLmNlcjAfBgNV +HSMEGDAWgBTPmanqeyb0S8mOj9fwBSbv49KnnTARBglghkgBhvhCAQEEBAMCBBAw +FgYKKwYBBAGCNwIBGwQIMAYBAQABAf8wDQYJKoZIhvcNAQEFBQADggEBAHT+RhnF +LoqUlSvo0bxl3eUj81FZg0neyCnpGZV1bFqmDwcwHAWRqOSkrOYxTed6v9cJl0q1 +FPXU/6ic0lfUWNqcn0uaS5vfVRpAhRnliLrGlfE5fQfE4lguOUQ4cILK6AxJpeKU +JVDUoeObG2ven83yIy0guevE/1so2VXnV1bFLTtdS5r6iqqMGCshDZMFVleYMo0S +uhfubZDtKIhd9pRLkpg3MzchYLmri5NB67vYZizW11W86QZIWoDIJG8NAWyz6HOJ +rS+ecFa1TBo4gkcKDrd6DT8dMBNUUTTECo6bTGpSfkxjaRK82ZZwS7ui2DMb7K7e +K2GyWhR2PnMg09o= +-----END CERTIFICATE----- + +subject=C=US, O=VeriSign, Inc., OU=VeriSign Trust Network, OU=Terms of use at https://www.verisign.com/rpa (c)10, CN=VeriSign Class 3 Code Signing 2010 CA +issuer=C=US, O=VeriSign, Inc., OU=VeriSign Trust Network, OU=(c) 2006 VeriSign, Inc. - For authorized use only, CN=VeriSign Class 3 Public Primary Certification Authority - G5 +-----BEGIN CERTIFICATE----- +MIIGCjCCBPKgAwIBAgIQUgDlqiVW/BqG7ZbJ1EszxzANBgkqhkiG9w0BAQUFADCB +yjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL +ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJp +U2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxW +ZXJpU2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5IC0gRzUwHhcNMTAwMjA4MDAwMDAwWhcNMjAwMjA3MjM1OTU5WjCBtDEL +MAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZW +ZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTswOQYDVQQLEzJUZXJtcyBvZiB1c2UgYXQg +aHR0cHM6Ly93d3cudmVyaXNpZ24uY29tL3JwYSAoYykxMDEuMCwGA1UEAxMlVmVy +aVNpZ24gQ2xhc3MgMyBDb2RlIFNpZ25pbmcgMjAxMCBDQTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAPUjS16l14q7MunUV/fv5Mcmfq0ZmP6onX2U9jZr +ENd1gTB/BGh/yyt1Hs0dCIzfaZSnN6Oce4DgmeHuN01fzjsU7obU0PUnNbwlCzin +jGOdF6MIpauw+81qYoJM1SHaG9nx44Q7iipPhVuQAU/Jp3YQfycDfL6ufn3B3fkF +vBtInGnnwKQ8PEEAPt+W5cXklHHWVQHHACZKQDy1oSapDKdtgI6QJXvPvz8c6y+W ++uWHd8a1VrJ6O1QwUxvfYjT/HtH0WpMoheVMF05+W/2kk5l/383vpHXv7xX2R+f4 +GXLYLjQaprSnTH69u08MPVfxMNamNo7WgHbXGS6lzX40LYkCAwEAAaOCAf4wggH6 +MBIGA1UdEwEB/wQIMAYBAf8CAQAwcAYDVR0gBGkwZzBlBgtghkgBhvhFAQcXAzBW +MCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy52ZXJpc2lnbi5jb20vY3BzMCoGCCsG +AQUFBwICMB4aHGh0dHBzOi8vd3d3LnZlcmlzaWduLmNvbS9ycGEwDgYDVR0PAQH/ +BAQDAgEGMG0GCCsGAQUFBwEMBGEwX6FdoFswWTBXMFUWCWltYWdlL2dpZjAhMB8w +BwYFKw4DAhoEFI/l0xqGrI2Oa8PPgGrUSBgsexkuMCUWI2h0dHA6Ly9sb2dvLnZl +cmlzaWduLmNvbS92c2xvZ28uZ2lmMDQGA1UdHwQtMCswKaAnoCWGI2h0dHA6Ly9j +cmwudmVyaXNpZ24uY29tL3BjYTMtZzUuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggr +BgEFBQcwAYYYaHR0cDovL29jc3AudmVyaXNpZ24uY29tMB0GA1UdJQQWMBQGCCsG +AQUFBwMCBggrBgEFBQcDAzAoBgNVHREEITAfpB0wGzEZMBcGA1UEAxMQVmVyaVNp +Z25NUEtJLTItODAdBgNVHQ4EFgQUz5mp6nsm9EvJjo/X8AUm7+PSp50wHwYDVR0j +BBgwFoAUf9Nlp8Ld7LvwMAnzQzn6Aq8zMTMwDQYJKoZIhvcNAQEFBQADggEBAFYi +5jSkxGHLSLkBrVaoZA/ZjJHEu8wM5a16oCJ/30c4Si1s0X9xGnzscKmx8E/kDwxT ++hVe/nSYSSSFgSYckRRHsExjjLuhNNTGRegNhSZzA9CpjGRt3HGS5kUFYBVZUTn8 +WBRr/tSk7XlrCAxBcuc3IgYJviPpP0SaHulhncyxkFz8PdKNrEI9ZTbUtD1AKI+b +EM8jJsxLIMuQH12MTDTKPNjlN9ZvpSC9NOsm2a4N58Wa96G0IZEzb4boWLslfHQO +WP51G2M/zjF8m48blp7FU3aEW5ytkfqs7ZO6XcghU8KCU2OvEg1QhxEbPVRSloos +nD2SGgiaBS7Hk6VIkdM= +-----END CERTIFICATE----- + diff --git a/src/main/resources/certs/readme.md b/src/main/resources/certs/readme.md new file mode 100644 index 00000000..1da9fafb --- /dev/null +++ b/src/main/resources/certs/readme.md @@ -0,0 +1,5 @@ +# Minecraft certificate chain + +Exported from the vanilla jar by extracting MOJANGCS.RSA from the jar and then running: + +`openssl pkcs7 -inform DER -in MOJANGCS.RSA -print_certs -out cert.pem` \ No newline at end of file diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/download/DownloadFileTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/download/DownloadFileTest.groovy index cb3edfc3..42afc919 100644 --- a/src/test/groovy/net/fabricmc/loom/test/unit/download/DownloadFileTest.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/unit/download/DownloadFileTest.groovy @@ -38,6 +38,7 @@ import net.fabricmc.loom.util.download.Download import net.fabricmc.loom.util.download.DownloadException import net.fabricmc.loom.util.download.DownloadExecutor import net.fabricmc.loom.util.download.DownloadProgressListener +import net.fabricmc.loom.util.download.DownloadResult class DownloadFileTest extends DownloadTest { @IgnoreIf({ os.windows }) // Requires admin on windows. @@ -115,16 +116,20 @@ class DownloadFileTest extends DownloadTest { } def output = new File(File.createTempDir(), "file.txt").toPath() + def results = [] as List when: for (i in 0..<2) { - Download.create("$PATH/sha1.txt") + def result = Download.create("$PATH/sha1.txt") .sha1("0a4d55a8d778e5022fab701977c5d840bbc486d0") .downloadPath(output) + results << result } then: requestCount == 1 + results[0].didDownload() + !results[1].didDownload() } def "Invalid Sha1"() { @@ -365,6 +370,28 @@ class DownloadFileTest extends DownloadTest { Files.readString(dir.resolve("4.txt")) == "Hello World" } + def "File: Async result"() { + setup: + server.get("/async1") { + it.result("Hello World") + } + + def dir = File.createTempDir().toPath() + + when: + boolean didDownload = false + new DownloadExecutor(2).withCloseable { + Download.create("$PATH/async1") + .downloadPathAsync(dir.resolve("1.txt"), it) + .thenAccept { + didDownload = it.didDownload() + } + } + + then: + didDownload + } + def "File: Async Error"() { setup: server.get("/async2") { diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/providers/CertificateChainTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/providers/CertificateChainTest.groovy new file mode 100644 index 00000000..d3796baf --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/providers/CertificateChainTest.groovy @@ -0,0 +1,110 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit.providers + +import spock.lang.Specification + +import net.fabricmc.loom.configuration.providers.minecraft.verify.CertificateChain +import net.fabricmc.loom.configuration.providers.minecraft.verify.SignatureVerificationFailure +import net.fabricmc.loom.test.util.CertificateUtils + +class CertificateChainTest extends Specification { + def "load mojang's cert chain"() { + when: + def chain = CertificateChain.getRoot("mojangcs") + + then: + chain.certificate().issuerX500Principal.name == "OU=Class 3 Public Primary Certification Authority,O=VeriSign\\, Inc.,C=US" + } + + def "load certificate chain"() { + given: + def keyPair = CertificateUtils.generateKeyPair() + def root = CertificateUtils.createCert(keyPair, "CN=Test Root Certificate") + def intermediate = CertificateUtils.createCert(keyPair, "CN=Test Intermediate Certificate", root) + def leaf = CertificateUtils.createCert(keyPair, "CN=Test Leaf Certificate", intermediate) + + when: + def chain = CertificateChain.getRoot([root, intermediate, leaf]) + + then: + chain.issuer() == null + chain.certificate() == root + chain.children().size() == 1 + + chain.children()[0].issuer().certificate() == root + chain.children()[0].certificate() == intermediate + chain.children()[0].children().size() == 1 + } + + def "matching cert chain"() { + given: + def keyPair = CertificateUtils.generateKeyPair() + def root = CertificateUtils.createCert(keyPair, "CN=Test Root Certificate") + def intermediate = CertificateUtils.createCert(keyPair, "CN=Test Intermediate Certificate", root) + def leaf = CertificateUtils.createCert(keyPair, "CN=Test Leaf Certificate", intermediate) + + when: + def chain1 = CertificateChain.getRoot([root, intermediate, leaf]) + def chain2 = CertificateChain.getRoot([root, intermediate, leaf]) + + then: + chain1.verifyChainMatches(chain2) + } + + def "different leaf cert"() { + given: + def keyPair = CertificateUtils.generateKeyPair() + def root = CertificateUtils.createCert(keyPair, "CN=Test Root Certificate") + def intermediate = CertificateUtils.createCert(keyPair, "CN=Test Intermediate Certificate", root) + def leaf1 = CertificateUtils.createCert(keyPair, "CN=Test Leaf 1 Certificate", intermediate) + def leaf2 = CertificateUtils.createCert(keyPair, "CN=Test Leaf 2 Certificate", intermediate) + + when: + def chain1 = CertificateChain.getRoot([root, intermediate, leaf1]) + def chain2 = CertificateChain.getRoot([root, intermediate, leaf2]) + + chain1.verifyChainMatches(chain2) + + then: + thrown SignatureVerificationFailure + } + + def "different cert"() { + given: + def keyPair = CertificateUtils.generateKeyPair() + def root = CertificateUtils.createCert(keyPair, "CN=Test Root Certificate") + def root2 = CertificateUtils.createCert(keyPair, "CN=Test Root 2 Certificate") + + when: + def chain1 = CertificateChain.getRoot([root]) + def chain2 = CertificateChain.getRoot([root2]) + + chain1.verifyChainMatches(chain2) + + then: + thrown SignatureVerificationFailure + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/providers/CertificateRevocationListTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/providers/CertificateRevocationListTest.groovy new file mode 100644 index 00000000..52259044 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/providers/CertificateRevocationListTest.groovy @@ -0,0 +1,110 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit.providers + +import spock.lang.Specification + +import net.fabricmc.loom.configuration.providers.minecraft.verify.CertificateChain +import net.fabricmc.loom.configuration.providers.minecraft.verify.CertificateRevocationList +import net.fabricmc.loom.configuration.providers.minecraft.verify.SignatureVerificationFailure +import net.fabricmc.loom.test.util.CertificateUtils +import net.fabricmc.loom.test.util.GradleTestUtil + +class CertificateRevocationListTest extends Specification { + // Test to make sure that the CRL URL is correct for the mojang cert chain + // As we don't want to depend on bouncycastle in the main project just to extract the same crl url each time + def "crl url matches"() { + given: + def cert = CertificateChain.getRoot("mojangcs") + when: + def crls = CertificateUtils.getCrls(cert) + then: + crls.sort() == CertificateRevocationList.CSC3_2010 + } + + def "valid cert"() { + given: + def keyPair = CertificateUtils.generateKeyPair() + def root = CertificateUtils.createCert(keyPair, "CN=Test Root Certificate") + def intermediate = CertificateUtils.createCert(keyPair, "CN=Test Intermediate Certificate", root) + def validLeaf = CertificateUtils.createCert(keyPair, "CN=Test Valid Leaf Certificate", intermediate) + def revokedLeaf = CertificateUtils.createCert(keyPair, "CN=Test Revoked Leaf Certificate", intermediate) + + def x509crl = CertificateUtils.createCrl(keyPair, intermediate, [revokedLeaf]) + + def chain = CertificateChain.getRoot([root, intermediate, validLeaf]) + + when: + def crl = new CertificateRevocationList([x509crl], false) + + then: + crl.verify(chain) + } + + def "revoked cert"() { + given: + def keyPair = CertificateUtils.generateKeyPair() + def root = CertificateUtils.createCert(keyPair, "CN=Test Root Certificate") + def intermediate = CertificateUtils.createCert(keyPair, "CN=Test Intermediate Certificate", root) + def revokedLeaf = CertificateUtils.createCert(keyPair, "CN=Test Revoked Leaf Certificate", intermediate) + + def x509crl = CertificateUtils.createCrl(keyPair, intermediate, [revokedLeaf]) + + def chain = CertificateChain.getRoot([ + root, + intermediate, + revokedLeaf + ]) + + when: + def crl = new CertificateRevocationList([x509crl], false) + crl.verify(chain) + + then: + thrown SignatureVerificationFailure + } + + def "Verify Mojang cert"() { + given: + def project = GradleTestUtil.mockProject() + def cert = CertificateChain.getRoot("mojangcs") + when: + def crl = CertificateRevocationList.create(project, CertificateRevocationList.CSC3_2010) + then: + !crl.downloadFailure() + crl.verify(cert) + } + + def "Invalid URL"() { + given: + def project = GradleTestUtil.mockProject() + def cert = CertificateChain.getRoot("mojangcs") + when: + def crl = CertificateRevocationList.create(project, ["http://invalid.url"]) + then: + crl.downloadFailure() + crl.verify(cert) + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/providers/JarVerifierTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/providers/JarVerifierTest.groovy new file mode 100644 index 00000000..fd869091 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/providers/JarVerifierTest.groovy @@ -0,0 +1,127 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit.providers + +import java.nio.file.Files +import java.nio.file.Path + +import spock.lang.Specification + +import net.fabricmc.loom.configuration.providers.minecraft.verify.CertificateChain +import net.fabricmc.loom.configuration.providers.minecraft.verify.JarVerifier +import net.fabricmc.loom.configuration.providers.minecraft.verify.SignatureVerificationFailure +import net.fabricmc.loom.test.LoomTestConstants +import net.fabricmc.loom.util.ZipUtils +import net.fabricmc.loom.util.download.Download + +class JarVerifierTest extends Specification { + public static final String CLIENT_JAR_URL = "https://launcher.mojang.com/v1/objects/7e46fb47609401970e2818989fa584fd467cd036/client.jar" + public static final String INSTALLER_JAR_URL = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.0.3/fabric-installer-1.0.3.jar" + public static final File mcJarDir = new File(LoomTestConstants.TEST_DIR, "jar-verifier") + + def "verify Minecraft Jar"() { + setup: + def clientJar = downloadJarIfNotExists(CLIENT_JAR_URL, "client.jar") + def cert = CertificateChain.getRoot("mojangcs") + when: + JarVerifier.verify(clientJar, cert) + then: + true == true + } + + def "invalid Minecraft Jar, extra entry"() { + setup: + def clientJar = downloadJarIfNotExists(CLIENT_JAR_URL, "client.jar") + Path tempDir = Files.createTempDirectory("test") + def tempJar = tempDir.resolve("client.jar") + Files.copy(clientJar, tempJar) + + ZipUtils.add(tempJar, "extra.txt", "Hello World".bytes) + + def cert = CertificateChain.getRoot("mojangcs") + when: + JarVerifier.verify(tempJar, cert) + then: + def e = thrown SignatureVerificationFailure + e.message == "Jar entry extra.txt does not have a signature" + } + + def "invalid Minecraft Jar, modified entry"() { + setup: + def clientJar = downloadJarIfNotExists(CLIENT_JAR_URL, "client.jar") + Path tempDir = Files.createTempDirectory("test") + def tempJar = tempDir.resolve("client.jar") + Files.copy(clientJar, tempJar) + + ZipUtils.replace(tempJar, "version.json", "Hello World".bytes) + + def cert = CertificateChain.getRoot("mojangcs") + when: + JarVerifier.verify(tempJar, cert) + then: + def e = thrown SignatureVerificationFailure + e.message == "Jar entry version.json failed signature verification" + } + + def "invalid Minecraft Jar, not signed"() { + setup: + Path tempDir = Files.createTempDirectory("test") + def tempJar = tempDir.resolve("client.jar") + + ZipUtils.add(tempJar, "hello.txt", "Hello World".bytes) + + def cert = CertificateChain.getRoot("mojangcs") + when: + JarVerifier.verify(tempJar, cert) + then: + def e = thrown SignatureVerificationFailure + e.message == "Jar entry hello.txt does not have a signature" + } + + def "not minecraft"() { + setup: + def installerJar = downloadJarIfNotExists(INSTALLER_JAR_URL, "installer.jar") + + def cert = CertificateChain.getRoot("mojangcs") + when: + JarVerifier.verify(installerJar, cert) + then: + def e = thrown SignatureVerificationFailure + e.message == "Certificate mismatch: CN=Fabric,OU=CI,O=Fabric,L=Unknown,ST=Unknown,C=Unknown != OU=Class 3 Public Primary Certification Authority,O=VeriSign\\, Inc.,C=US" + } + + static Path downloadJarIfNotExists(String url, String name) { + File dst = new File(mcJarDir, name) + + if (!dst.exists()) { + dst.parentFile.mkdirs() + Download.create(url) + .defaultCache() + .downloadPath(dst.toPath()) + } + + return dst.toPath() + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/providers/KnownVersionsTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/providers/KnownVersionsTest.groovy new file mode 100644 index 00000000..8c71654e --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/providers/KnownVersionsTest.groovy @@ -0,0 +1,40 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit.providers + +import spock.lang.Specification + +import net.fabricmc.loom.configuration.providers.minecraft.verify.KnownVersions + +class KnownVersionsTest extends Specification { + // Just a simple test to make sure we can load the known versions + def "check known versions"() { + when: + def versions = KnownVersions.INSTANCE.get() + then: + versions.client().get("1.2.5") == "c1c3740a912ef523a8bd46605ab5708643498330140cba175c7ce6f177e468e1" + versions.server().get("1.16.5") == "58f329c7d2696526f948470aa6fd0b45545039b64cb75015e64c12194b373da6" + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/providers/MinecraftJarVerificationTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/providers/MinecraftJarVerificationTest.groovy new file mode 100644 index 00000000..acab9c58 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/providers/MinecraftJarVerificationTest.groovy @@ -0,0 +1,157 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit.providers + +import java.nio.file.Files +import java.nio.file.Path + +import spock.lang.Specification + +import net.fabricmc.loom.LoomGradlePlugin +import net.fabricmc.loom.configuration.providers.BundleMetadata +import net.fabricmc.loom.configuration.providers.minecraft.MinecraftVersionMeta +import net.fabricmc.loom.configuration.providers.minecraft.VersionsManifest +import net.fabricmc.loom.configuration.providers.minecraft.verify.MinecraftJarVerification +import net.fabricmc.loom.configuration.providers.minecraft.verify.SignatureVerificationFailure +import net.fabricmc.loom.test.util.GradleTestUtil +import net.fabricmc.loom.util.Constants +import net.fabricmc.loom.util.ZipUtils +import net.fabricmc.loom.util.download.Download + +class MinecraftJarVerificationTest extends Specification { + static Path dir = Path.of(".gradle", "test-files", "jar-verification") + + def "check client verified"() { + setup: + def jar = getJar(version, "client") + def project = GradleTestUtil.mockProject() + def verification = project.objects.newInstance(MinecraftJarVerification.class, version) + + when: + verification.verifyClientJar(jar) + + then: + true == true + + where: + version | _ + "1.21.5" | _ + "1.16.5" | _ + "1.14.4" | _ + "1.7.10" | _ + "1.7.9" | _ // Sha1 signed + "b1.5" | _ // Not signed + } + + def "check bundled server verified"() { + setup: + def jar = getJar(version, "server") + def unpackedJar = jar.resolveSibling(jar.fileName.toString() + ".unpacked") + def project = GradleTestUtil.mockProject() + def verification = project.objects.newInstance(MinecraftJarVerification.class, version) + def bundle = BundleMetadata.fromJar(jar) + bundle.versions().get(0).unpackEntry(jar, unpackedJar, project) + + when: + verification.verifyServerJar(unpackedJar) + + then: + true == true + + where: + version | _ + "1.21.5" | _ + } + + def "check standalone server verified"() { + setup: + def jar = getJar(version, "server") + def project = GradleTestUtil.mockProject() + def verification = project.objects.newInstance(MinecraftJarVerification.class, version) + + when: + verification.verifyServerJar(jar) + + then: + true == true + + where: + version | _ + "1.16.5" | _ + "1.14.4" | _ + "1.7.10" | _ + "1.7.9" | _ // Sha1 signed + "1.2.5" | _ + } + + def "hash mismatch"() { + setup: + def jar = getJar("1.2.5", "client") + def project = GradleTestUtil.mockProject() + def verification = project.objects.newInstance(MinecraftJarVerification.class, "1.2.4") + + when: + verification.verifyClientJar(jar) + + then: + thrown SignatureVerificationFailure + } + + def "unverified jar"() { + setup: + Path tempDir = Files.createTempDirectory("test") + def jar = tempDir.resolve("client.jar") + ZipUtils.add(jar, "hello.txt", "Hello World".bytes) + + def project = GradleTestUtil.mockProject() + def verification = project.objects.newInstance(MinecraftJarVerification.class, "blah") + + when: + verification.verifyClientJar(jar) + + then: + thrown SignatureVerificationFailure + } + + private static Path getJar(String id, String type) { + def versionManifest = Download.create(Constants.VERSION_MANIFESTS) + .downloadString(dir.resolve("manifest.json")) + final VersionsManifest versions = LoomGradlePlugin.GSON.fromJson(versionManifest, VersionsManifest.class) + + def version = versions.getVersion(id) + def manifest = Download.create(version.url) + .sha1(version.sha1) + .downloadString(dir.resolve(version.id + ".json")) + def meta = LoomGradlePlugin.GSON.fromJson(manifest, MinecraftVersionMeta.class) + + def download = meta.download(type) + Path jarPath = dir.resolve(download.sha1() + ".jar") + Download.create(download.url()) + .sha1(download.sha1()) + .downloadPath(jarPath) + + return jarPath + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/util/CertificateUtils.groovy b/src/test/groovy/net/fabricmc/loom/test/util/CertificateUtils.groovy new file mode 100644 index 00000000..1c00e004 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/util/CertificateUtils.groovy @@ -0,0 +1,131 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.util + +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.cert.X509CRL +import java.security.cert.X509Certificate + +import org.bouncycastle.asn1.DERIA5String +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.CRLDistPoint +import org.bouncycastle.asn1.x509.CRLNumber +import org.bouncycastle.asn1.x509.CRLReason +import org.bouncycastle.asn1.x509.DistributionPointName +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.cert.jcajce.JcaX509CRLConverter +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils +import org.bouncycastle.cert.jcajce.JcaX509v2CRLBuilder +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder + +import net.fabricmc.loom.configuration.providers.minecraft.verify.CertificateChain + +/** + * Test code, not for production use. + */ +class CertificateUtils { + private static final JcaContentSignerBuilder SIGNER_BUILDER = new JcaContentSignerBuilder("SHA384withECDSA") + private static final JcaX509CertificateConverter CERT_CONVERTER = new JcaX509CertificateConverter() + private static final JcaX509CRLConverter CRL_CONVERTER = new JcaX509CRLConverter() + + static KeyPair generateKeyPair() { + def keyPairGenerator = KeyPairGenerator.getInstance("EC") + keyPairGenerator.initialize(384) + return keyPairGenerator.generateKeyPair() + } + + static X509Certificate createCert(KeyPair keyPair, String name, X509Certificate parent = null) { + def issuerName = new X500Name(parent ? parent.subjectX500Principal.name : name) + def subjectName = new X500Name(name) + def notBefore = new Date() + def notAfter = new Date(notBefore.getTime() + 365 * 24 * 60 * 60 * 1000) // 1 year + + def serialNumber = BigInteger.valueOf(System.currentTimeMillis()) + def builder = new JcaX509v3CertificateBuilder( + issuerName, + serialNumber, + notBefore, + notAfter, + subjectName, + keyPair.getPublic() + ) + + def contentSigner = SIGNER_BUILDER.build(keyPair.getPrivate()) + return CERT_CONVERTER.getCertificate(builder.build(contentSigner)) + } + + static X509CRL createCrl(KeyPair keyPair, X509Certificate issuerCert, List revokedCerts) { + def builder = new JcaX509v2CRLBuilder(issuerCert, new Date()) + + for (final def revoked in revokedCerts) { + assert revoked.getIssuerX500Principal() == issuerCert.getSubjectX500Principal() + builder.addCRLEntry(revoked.getSerialNumber(), new Date(), CRLReason.keyCompromise) + } + + builder.addExtension(Extension.authorityKeyIdentifier, false, + new JcaX509ExtensionUtils().createAuthorityKeyIdentifier(keyPair.getPublic())) + builder.addExtension(Extension.cRLNumber, false, new CRLNumber(BigInteger.ONE)) + + def crlSigner = SIGNER_BUILDER.build(keyPair.getPrivate()) + return CRL_CONVERTER.getCRL(builder.build(crlSigner)) + } + + static List getCrls(CertificateChain certificateChain) { + def crls = [] as Set + getCrls(certificateChain, crls) + return crls.toList() + } + + static void getCrls(CertificateChain certificateChain, Set crls) { + crls.addAll(getCrls(certificateChain.certificate())) + + certificateChain.children().each { child -> + getCrls(child, crls) + } + } + + static ArrayList getCrls(X509Certificate certificate) { + byte[] crlDistributionPointsValue = certificate.getExtensionValue(Extension.cRLDistributionPoints.getId()) + + if (crlDistributionPointsValue == null) { + return [] + } + + return CRLDistPoint + .getInstance(JcaX509ExtensionUtils.parseExtensionValue(crlDistributionPointsValue)) + .getDistributionPoints() + .findAll { it.getDistributionPoint().type == DistributionPointName.FULL_NAME } + .collectMany { distPoint -> + GeneralNames.getInstance(distPoint.getDistributionPoint().getName()).getNames() + .findAll { it.tagNo == GeneralName.uniformResourceIdentifier } + .collect { DERIA5String.getInstance(it.name).getString() } + } + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/util/GradleTestUtil.groovy b/src/test/groovy/net/fabricmc/loom/test/util/GradleTestUtil.groovy index 2700c2cd..264d3cd8 100644 --- a/src/test/groovy/net/fabricmc/loom/test/util/GradleTestUtil.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/util/GradleTestUtil.groovy @@ -33,6 +33,7 @@ import org.gradle.api.internal.tasks.DefaultSourceSet import org.gradle.api.model.ObjectFactory import org.gradle.api.plugins.ExtensionContainer import org.gradle.api.provider.Property +import org.gradle.api.provider.ProviderFactory import org.gradle.api.tasks.SourceSet import org.gradle.api.tasks.util.PatternFilterable import org.jetbrains.annotations.Nullable @@ -63,9 +64,10 @@ class GradleTestUtil { } static Project mockProject() { - def objectFactory = TestServiceFactory.objectFactory - def providerFactory = TestServiceFactory.providerFactory def mock = mock(Project.class) + def serviceRegistry = TestServiceFactory.createServiceRegistry(mock) + def objectFactory = serviceRegistry.get(ObjectFactory) + def providerFactory = serviceRegistry.get(ProviderFactory) def extensions = mockExtensionContainer() when(mock.getExtensions()).thenReturn(extensions) when(mock.getObjects()).thenReturn(objectFactory) diff --git a/src/test/groovy/net/fabricmc/loom/test/util/KnownVersionsGenerator.groovy b/src/test/groovy/net/fabricmc/loom/test/util/KnownVersionsGenerator.groovy new file mode 100644 index 00000000..33cff626 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/util/KnownVersionsGenerator.groovy @@ -0,0 +1,143 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.util + +import java.nio.file.Path +import java.security.MessageDigest + +import net.fabricmc.loom.LoomGradlePlugin +import net.fabricmc.loom.configuration.providers.BundleMetadata +import net.fabricmc.loom.configuration.providers.minecraft.MinecraftVersionMeta +import net.fabricmc.loom.configuration.providers.minecraft.VersionsManifest +import net.fabricmc.loom.configuration.providers.minecraft.verify.CertificateChain +import net.fabricmc.loom.configuration.providers.minecraft.verify.JarVerifier +import net.fabricmc.loom.configuration.providers.minecraft.verify.KnownVersions +import net.fabricmc.loom.configuration.providers.minecraft.verify.SignatureVerificationFailure +import net.fabricmc.loom.util.Constants +import net.fabricmc.loom.util.download.Download +import net.fabricmc.loom.util.download.DownloadExecutor + +/** + * A quick and dirty script to generate the known_versions.json file + * This file contains a list of all the unsigned versions of Minecraft and their sha1 hashes + * Note: Running this will take a while as it downloads all the versions of Minecraft + */ +class KnownVersionsGenerator { + static Path dir = Path.of(".gradle", "test-files", "unsigned") + static CertificateChain chain = CertificateChain.getRoot("mojangcs") + + static void main(String[] args) { + def versionManifest = Download.create(Constants.VERSION_MANIFESTS) + .downloadString() + final VersionsManifest manifest = LoomGradlePlugin.GSON.fromJson(versionManifest, VersionsManifest.class) + + // Download all the minecraft jars + new DownloadExecutor(10).withCloseable { + for (def version in manifest.versions()) { + downloadVersion(version, it) + } + } + + println("Downloaded all versions") + + def unsignedClientVersions = [:] as Map + def unsignedServerVersions = [:] as Map + + for (def version in manifest.versions()) { + println("Checking version " + version.id) + checkVersion(version, unsignedClientVersions, unsignedServerVersions) + } + + def json = LoomGradlePlugin.GSON.toJson(new KnownVersions(unsignedClientVersions, unsignedServerVersions)) + println(json) + } + + static void downloadVersion(VersionsManifest.Version version, DownloadExecutor downloadExecutor) { + def manifest = Download.create(version.url) + .sha1(version.sha1) + .downloadString(dir.resolve(version.id + ".json")) + def meta = LoomGradlePlugin.GSON.fromJson(manifest, MinecraftVersionMeta.class) + + def client = meta.download("client") + def server = meta.download("server") + + download(client, downloadExecutor) + + if (server != null) { + download(server, downloadExecutor) + } + } + + static void download(MinecraftVersionMeta.Download download, DownloadExecutor executor) { + Path jarPath = dir.resolve(download.sha1() + ".jar") + Download.create(download.url()) + .sha1(download.sha1()) + .downloadPathAsync(jarPath, executor) + } + + static void checkVersion(VersionsManifest.Version version, Map unsignedClientVersions, Map unsignedServerVersions) { + def manifest = Download.create(version.url) + .sha1(version.sha1) + .downloadString(dir.resolve(version.id + ".json")) + def meta = LoomGradlePlugin.GSON.fromJson(manifest, MinecraftVersionMeta.class) + + def client = meta.download("client") + def server = meta.download("server") + + def clientJar = dir.resolve(client.sha1() + ".jar") + + if (!isSigned(clientJar)) { + unsignedClientVersions.put(version.id, sha256(clientJar)) + } + + if (server != null) { + def serverJar = dir.resolve(server.sha1() + ".jar") + if (BundleMetadata.fromJar(serverJar) == null) { + unsignedServerVersions.put(version.id, sha256(serverJar)) + } + } + } + + static boolean isSigned(Path jarPath) { + try { + JarVerifier.verify(jarPath, chain) + return true + } catch (SignatureVerificationFailure ignored) { + return false + } + } + + static String sha256(Path path) { + MessageDigest md = MessageDigest.getInstance("SHA-256") + path.withInputStream { inputStream -> + byte[] buffer = new byte[8192] + int bytesRead + while ((bytesRead = inputStream.read(buffer)) != -1) { + md.update(buffer, 0, bytesRead) + } + } + return md.digest().encodeHex().toString() + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/util/TestServiceFactory.groovy b/src/test/groovy/net/fabricmc/loom/test/util/TestServiceFactory.groovy index 31eb7cdc..52518feb 100644 --- a/src/test/groovy/net/fabricmc/loom/test/util/TestServiceFactory.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/util/TestServiceFactory.groovy @@ -25,6 +25,7 @@ package net.fabricmc.loom.test.util import groovy.transform.CompileStatic +import org.gradle.api.Project import org.gradle.api.internal.CollectionCallbackActionDecorator import org.gradle.api.internal.MutationGuard import org.gradle.api.internal.MutationGuards @@ -71,7 +72,7 @@ class TestServiceFactory { public static final ObjectFactory objectFactory = serviceRegistry.get(ObjectFactory) public static final ProviderFactory providerFactory = serviceRegistry.get(ProviderFactory) - private static ServiceRegistry createServiceRegistry() { + static ServiceRegistry createServiceRegistry(Project project = null) { def services = new DefaultServiceRegistry() services.register { it.add(DefaultPropertyFactory) @@ -83,6 +84,11 @@ class TestServiceFactory { it.add(FileCollectionFactory, fileCollectionFactory()) it.add(DefaultDomainObjectCollectionFactory) it.add(CrossBuildInMemoryCacheFactory, new DefaultCrossBuildInMemoryCacheFactory(mock(ListenerManager))) + + if (project != null) { + it.add(Project, project) + } + //noinspection unused it.addProvider(new ServiceRegistrationProvider() { @Provides From 2af4b7e5915e95897dcf5a2cb5b7c212ffcadf55 Mon Sep 17 00:00:00 2001 From: modmuss Date: Mon, 7 Apr 2025 11:54:08 +0100 Subject: [PATCH 07/40] Handle fabric.mod.json file being empty (#1280) * Throw a nicer error when the fabric.mod.json file is empty * Don't fail just log. --- .../fabricmc/loom/util/fmj/FabricModJsonFactory.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/fabricmc/loom/util/fmj/FabricModJsonFactory.java b/src/main/java/net/fabricmc/loom/util/fmj/FabricModJsonFactory.java index 00fb875e..0cfec9c6 100644 --- a/src/main/java/net/fabricmc/loom/util/fmj/FabricModJsonFactory.java +++ b/src/main/java/net/fabricmc/loom/util/fmj/FabricModJsonFactory.java @@ -116,7 +116,15 @@ public final class FabricModJsonFactory { } try (Reader reader = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) { - return create(LoomGradlePlugin.GSON.fromJson(reader, JsonObject.class), new FabricModJsonSource.SourceSetSource(project, sourceSets)); + final JsonObject modJson = LoomGradlePlugin.GSON.fromJson(reader, JsonObject.class); + + if (modJson == null) { + // fromJson returns null if the file is empty + LOGGER.warn("Failed to parse empty fabric.mod.json: {}", file.getAbsolutePath()); + return null; + } + + return create(modJson, new FabricModJsonSource.SourceSetSource(project, sourceSets)); } catch (JsonSyntaxException e) { LOGGER.warn("Failed to parse fabric.mod.json: {}", file.getAbsolutePath()); return null; From 24fdf5af5c4698811d839bebd087fb3cc3a762c5 Mon Sep 17 00:00:00 2001 From: dicedpixels <121529979+dicedpixels@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:24:26 +0530 Subject: [PATCH 08/40] filter out new realms error message (#1278) --- src/main/resources/log4j2.fabric.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/log4j2.fabric.xml b/src/main/resources/log4j2.fabric.xml index 48bd2f2c..3f9d29bc 100644 --- a/src/main/resources/log4j2.fabric.xml +++ b/src/main/resources/log4j2.fabric.xml @@ -9,6 +9,7 @@ + From d463501e9b2cf4aa14b7e98efcfff7f7e358cdf4 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Mon, 7 Apr 2025 11:55:15 +0100 Subject: [PATCH 09/40] Support putting run configs in folders (#1276) * Remove unused RunConfig.genRuns * Support putting run configs in folders * Fix checkstyle complaints Given how many of my projects use checkstyle, you'd have thought I'd remember to check it before submitting a PR. * Fix IdeaClasspathModificationsTest "I won't run the tests, I've tested manually, and nothing will break". Words spoken before disaster (or at least making a fool of myself in CI). --- .../loom/configuration/ide/RunConfig.java | 69 +++++-------------- .../configuration/ide/RunConfigSettings.java | 12 +++- .../resources/idea_run_config_template.xml | 2 +- .../IdeaClasspathModificationsTest.groovy | 1 + 4 files changed, 32 insertions(+), 52 deletions(-) diff --git a/src/main/java/net/fabricmc/loom/configuration/ide/RunConfig.java b/src/main/java/net/fabricmc/loom/configuration/ide/RunConfig.java index 0e61bc7c..4ee75c8a 100644 --- a/src/main/java/net/fabricmc/loom/configuration/ide/RunConfig.java +++ b/src/main/java/net/fabricmc/loom/configuration/ide/RunConfig.java @@ -38,11 +38,12 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.regex.Pattern; import java.util.stream.Collectors; -import com.google.common.collect.ImmutableMap; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import groovy.xml.XmlUtil; import org.gradle.api.JavaVersion; import org.gradle.api.Project; import org.gradle.api.artifacts.ModuleVersionIdentifier; @@ -50,9 +51,6 @@ import org.gradle.api.artifacts.ResolvedArtifact; import org.gradle.api.artifacts.ResolvedModuleVersion; import org.gradle.api.tasks.SourceSet; import org.gradle.plugins.ide.eclipse.model.EclipseModel; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; import net.fabricmc.loom.LoomGradleExtension; import net.fabricmc.loom.configuration.InstallerData; @@ -64,6 +62,8 @@ import net.fabricmc.loom.util.Constants; import net.fabricmc.loom.util.gradle.SourceSetReference; public class RunConfig { + private static final Pattern VARIABLE = Pattern.compile("%[A-Z_]+%"); + public String configName; public String eclipseProjectName; public String ideaModuleName; @@ -76,42 +76,7 @@ public class RunConfig { public transient SourceSet sourceSet; public Map environmentVariables; public String projectName; - - public Element genRuns(Element doc) { - Element root = this.addXml(doc, "component", ImmutableMap.of("name", "ProjectRunConfigurationManager")); - root = addXml(root, "configuration", ImmutableMap.of("default", "false", "name", configName, "type", "Application", "factoryName", "Application")); - - this.addXml(root, "module", ImmutableMap.of("name", ideaModuleName)); - this.addXml(root, "option", ImmutableMap.of("name", "MAIN_CLASS_NAME", "value", mainClass)); - this.addXml(root, "option", ImmutableMap.of("name", "WORKING_DIRECTORY", "value", runDirIdeaUrl)); - - if (!vmArgs.isEmpty()) { - this.addXml(root, "option", ImmutableMap.of("name", "VM_PARAMETERS", "value", joinArguments(vmArgs))); - } - - if (!programArgs.isEmpty()) { - this.addXml(root, "option", ImmutableMap.of("name", "PROGRAM_PARAMETERS", "value", joinArguments(programArgs))); - } - - return root; - } - - public Element addXml(Node parent, String name, Map values) { - Document doc = parent.getOwnerDocument(); - - if (doc == null) { - doc = (Document) parent; - } - - Element e = doc.createElement(name); - - for (Map.Entry entry : values.entrySet()) { - e.setAttribute(entry.getKey(), entry.getValue()); - } - - parent.appendChild(e); - return e; - } + public String folderName; // Turns camelCase/PascalCase into Capital Case // caseConversionExample -> Case Conversion Example @@ -191,6 +156,7 @@ public class RunConfig { runConfig.environmentVariables = new HashMap<>(); runConfig.environmentVariables.putAll(settings.getEnvironmentVariables()); runConfig.projectName = project.getName(); + runConfig.folderName = settings.getIdeConfigFolder().getOrNull(); return runConfig; } @@ -212,17 +178,20 @@ public class RunConfig { runDir = relativePath + "/" + runDir; } - dummyConfig = dummyConfig.replace("%NAME%", configName); - dummyConfig = dummyConfig.replace("%MAIN_CLASS%", mainClass); - dummyConfig = dummyConfig.replace("%ECLIPSE_PROJECT%", eclipseProjectName); - dummyConfig = dummyConfig.replace("%IDEA_MODULE%", ideaModuleName); - dummyConfig = dummyConfig.replace("%RUN_DIRECTORY%", runDir); - dummyConfig = dummyConfig.replace("%PROGRAM_ARGS%", joinArguments(programArgs).replaceAll("\"", """)); - dummyConfig = dummyConfig.replace("%VM_ARGS%", joinArguments(vmArgs).replaceAll("\"", """)); - dummyConfig = dummyConfig.replace("%IDEA_ENV_VARS%", getEnvVars("")); - dummyConfig = dummyConfig.replace("%ECLIPSE_ENV_VARS%", getEnvVars("")); + var replacements = Map.of( + "%NAME%", configName, + "%MAIN_CLASS%", mainClass, + "%ECLIPSE_PROJECT%", eclipseProjectName, + "%IDEA_MODULE%", ideaModuleName, + "%RUN_DIRECTORY%", runDir, + "%PROGRAM_ARGS%", joinArguments(programArgs).replaceAll("\"", """), + "%VM_ARGS%", joinArguments(vmArgs).replaceAll("\"", """), + "%IDEA_ENV_VARS%", getEnvVars(""), + "%ECLIPSE_ENV_VARS%", getEnvVars(""), + "%IDEA_FOLDER_NAME%", folderName == null ? "" : "folderName=\"" + XmlUtil.escapeXml(folderName) + "\"" + ); - return dummyConfig; + return VARIABLE.matcher(dummyConfig).replaceAll(x -> replacements.getOrDefault(x.group(), "")); } private String getEnvVars(String pattern) { diff --git a/src/main/java/net/fabricmc/loom/configuration/ide/RunConfigSettings.java b/src/main/java/net/fabricmc/loom/configuration/ide/RunConfigSettings.java index b5547e81..7a542829 100644 --- a/src/main/java/net/fabricmc/loom/configuration/ide/RunConfigSettings.java +++ b/src/main/java/net/fabricmc/loom/configuration/ide/RunConfigSettings.java @@ -48,7 +48,7 @@ import net.fabricmc.loom.util.Constants; import net.fabricmc.loom.util.Platform; import net.fabricmc.loom.util.gradle.SourceSetHelper; -public class RunConfigSettings implements Named { +public abstract class RunConfigSettings implements Named { /** * Arguments for the JVM, such as system properties. */ @@ -362,6 +362,7 @@ public class RunConfigSettings implements Named { defaultMainClass = parent.defaultMainClass; source = parent.source; ideConfigGenerated = parent.ideConfigGenerated; + getIdeConfigFolder().set(parent.getIdeConfigFolder()); } public void makeRunDir() { @@ -380,6 +381,15 @@ public class RunConfigSettings implements Named { this.ideConfigGenerated = ideConfigGenerated; } + /** + * Group this run config under the given folder. + * + *

This is currently only supported on IntelliJ IDEA. + * + * @return The property used to set the config folder. + */ + public abstract Property getIdeConfigFolder(); + @ApiStatus.Internal @ApiStatus.Experimental public Property devLaunchMainClass() { diff --git a/src/main/resources/idea_run_config_template.xml b/src/main/resources/idea_run_config_template.xml index 55278f7b..635e7f5a 100644 --- a/src/main/resources/idea_run_config_template.xml +++ b/src/main/resources/idea_run_config_template.xml @@ -1,5 +1,5 @@ - +