diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52d8500..39421fd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: java-version-file: .java-version - name: Set up ja - uses: Netflix/ja/.github/actions/setup-ja@v0.18.2 + uses: Netflix/ja/.github/actions/setup-ja@v0.21.1 - name: Test run: ja test --all diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a03577..f02c4ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: run: echo "BUILD_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" - name: Set up ja - uses: Netflix/ja/.github/actions/setup-ja@v0.18.2 + uses: Netflix/ja/.github/actions/setup-ja@v0.21.1 - name: Test run: ja test --all diff --git a/src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/MavenDeploymentTest.java b/src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/MavenDeploymentTest.java index 61dacb1..eaba8f8 100644 --- a/src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/MavenDeploymentTest.java +++ b/src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/MavenDeploymentTest.java @@ -22,8 +22,10 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.jar.Attributes.Name; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import java.util.stream.Collectors; import javax.tools.ToolProvider; @@ -45,6 +47,86 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class MavenDeploymentTest { + @Test + void publishesNamedAutomaticModule(@TempDir Path directory) throws Exception { + Path artifacts = Files.createDirectory(directory.resolve("artifacts")); + createAutomaticModuleJar("com.example.application", + artifacts.resolve("com.example.application.jar")); + Files.writeString(artifacts.resolve("com.example.application.pom"), + """ + + 4.0.0 + com.example + com.example.application + 1.2.3 + jar + Example application + + + org.example + example-library + 4.5.6 + compile + + + com.acme + com.acme.library + 7.8.9 + + + java.sql + java.sql + 25 + + + + """); + Path repository = directory.resolve("repository"); + createMavenAutomaticModule(repository, "com.acme", "library", "7.8.9", + "com.acme.library"); + var upstream = new Builder("test", "default", repository.toUri().toString()).build(); + + try (var session = ModuleRepositorySession.create(directory.resolve("session"), List.of(upstream)); + var deployment = MavenDeployment.create(artifacts, session, "1.2.3")) { + Map deployed = deployment.artifacts().stream() + .collect(Collectors.toMap(artifact -> artifact.getExtension() + ":" + artifact.getClassifier(), artifact -> artifact)); + assertEquals(Set.of("pom:", "jar:"), deployed.keySet()); + Artifact jar = deployed.get("jar:"); + assertEquals("com.example", jar.getGroupId()); + assertEquals("com.example.application", jar.getArtifactId()); + assertEquals("1.2.3", jar.getVersion()); + Model consumer = readModel(deployed.get("pom:").getPath()); + assertEquals("Example application", consumer.getName()); + assertEquals(2, consumer.getDependencies().size()); + var ordinary = consumer.getDependencies().stream() + .filter(dependency -> dependency.getArtifactId().equals("example-library")) + .findFirst() + .orElseThrow(); + assertEquals("org.example", ordinary.getGroupId()); + assertEquals("4.5.6", ordinary.getVersion()); + var module = consumer.getDependencies().stream() + .filter(dependency -> dependency.getArtifactId().equals("library")) + .findFirst() + .orElseThrow(); + assertEquals("com.acme", module.getGroupId()); + assertEquals("7.8.9", module.getVersion()); + } + } + + @Test + void rejectsAutomaticModuleWithFilenameDerivedName(@TempDir Path directory) throws Exception { + Path artifacts = Files.createDirectory(directory.resolve("artifacts")); + try (var _ = new JarOutputStream(Files.newOutputStream( + artifacts.resolve("com.example.application.jar")))) {} + + try (var session = ModuleRepositorySession.create(directory.resolve("session"), List.of())) { + var failure = assertThrows(IllegalArgumentException.class, + () -> MavenDeployment.create(artifacts, session, "1.2.3")); + assertEquals("Automatic module has no Automatic-Module-Name: com.example.application.jar", + failure.getMessage()); + } + } + @Test void createsConsumerPomFromFlatModuleArtifacts(@TempDir Path directory) throws Exception { Path artifacts = Files.createDirectory(directory.resolve("artifacts")); @@ -137,6 +219,25 @@ void appliesPublicationMetadataToTheMatchingModule(@TempDir Path directory) thro } } + @Test + void deploysNamedAutomaticModule(@TempDir Path directory) throws Exception { + Path artifacts = Files.createDirectory(directory.resolve("artifacts")); + createAutomaticModuleJar("com.example.application", + artifacts.resolve("com.example.application.jar")); + Path repository = directory.resolve("deployed"); + var output = new StringWriter(); + var errors = new StringWriter(); + + int result = new Jig().run(new PrintWriter(output, true), new PrintWriter(errors, true), + "maven", "deploy", "--module-version", "1.2.3", "--repository", + repository.toString(), artifacts.toString()); + + assertEquals(0, result, errors.toString()); + Path deployed = repository.resolve("com/example/com.example.application/1.2.3"); + assertTrue(Files.isRegularFile(deployed.resolve("com.example.application-1.2.3.jar"))); + assertTrue(Files.isRegularFile(deployed.resolve("com.example.application-1.2.3.pom"))); + } + @Test void deploysToARepositorySelectedByPath(@TempDir Path directory) throws Exception { Path artifacts = Files.createDirectory(directory.resolve("artifacts")); @@ -256,6 +357,37 @@ private static void createModuleJar(Path directory, String moduleName, Path jar) .name()); } + private static void createAutomaticModuleJar(String moduleName, Path jar) throws Exception { + var manifest = new Manifest(); + manifest.getMainAttributes().put(Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("Automatic-Module-Name", moduleName); + try (var _ = new JarOutputStream(Files.newOutputStream(jar), manifest)) {} + assertTrue(ModuleFinder.of(jar) + .find(moduleName) + .orElseThrow() + .descriptor() + .isAutomatic()); + } + + private static void createMavenAutomaticModule(Path repository, String groupId, + String artifactId, String version, String moduleName) throws Exception { + Path directory = repository.resolve(groupId.replace('.', '/')) + .resolve(artifactId) + .resolve(version); + Files.createDirectories(directory); + createAutomaticModuleJar(moduleName, + directory.resolve(artifactId + "-" + version + ".jar")); + Files.writeString(directory.resolve(artifactId + "-" + version + ".pom"), + """ + + 4.0.0 + %s + %s + %s + + """.formatted(groupId, artifactId, version)); + } + private static String metadata(String name) { return """ diff --git a/src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/maven/transport/ModuleIdentityTest.java b/src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/ModuleIdentityTest.java similarity index 98% rename from src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/maven/transport/ModuleIdentityTest.java rename to src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/ModuleIdentityTest.java index 75f7091..ec7b3f4 100644 --- a/src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/maven/transport/ModuleIdentityTest.java +++ b/src/com.netflix.tools.jig.test/com/netflix/tools/jig/test/module/ModuleIdentityTest.java @@ -12,7 +12,7 @@ * the License. */ -package com.netflix.tools.jig.test.module.maven.transport; +package com.netflix.tools.jig.test.module; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -42,7 +42,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipFile; -import com.netflix.tools.jig.module.maven.transport.AbstractModuleTransporter.ModuleIdentity; +import com.netflix.tools.jig.module.ModuleIdentity; import com.netflix.tools.jig.module.maven.transport.ZipCentralDirectory; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/src/com.netflix.tools.jig.test/module-info.hash b/src/com.netflix.tools.jig.test/module-info.hash index 7c8176e..d3dd09c 100644 --- a/src/com.netflix.tools.jig.test/module-info.hash +++ b/src/com.netflix.tools.jig.test/module-info.hash @@ -1,5 +1,5 @@ org.apiguardian.api@1.1.2=module:sha256:8fe700256df67fb279c0734898bf7c25c199ef407ae18b918947b38dfdf0af2a -org.jspecify@1.0.0=module:sha256:db02318646febd4e03fc69910df454696d58b2be88e7232a0262c3fe0141bd03 +org.jspecify=module:sha256:db02318646febd4e03fc69910df454696d58b2be88e7232a0262c3fe0141bd03 org.junit.jupiter.api@6.1.3=module:sha256:20c0d83d6f2882d9e5d49353c0f2c51acaa1b35d96ec7f3c242530ad0179b89a org.junit.jupiter.engine@6.1.3=module:sha256:b6dff8c85dc1513bae171968a52a659aaa52dc31fda8b6e48e6e986d1601ed78 org.junit.jupiter.params@6.1.3=module:sha256:691c45b20325740fdb25dea947f22b267161a75a243a2f13ced3f06f88d8bb30 diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/Jig.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/Jig.java index 1fd420b..f1f5b4a 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/Jig.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/Jig.java @@ -68,11 +68,11 @@ import com.netflix.tools.jig.module.ModuleRepositorySession; import com.netflix.tools.jig.module.ModuleResolution; import com.netflix.tools.jig.module.ModuleResolution.IntegrityMode; +import com.netflix.tools.jig.module.ModuleIdentity; import com.netflix.tools.jig.module.SourceModule; import com.netflix.tools.jig.module.SourceModuleFinder; import com.netflix.tools.jig.module.SourceModuleReference; import com.netflix.tools.jig.module.Trace; -import com.netflix.tools.jig.module.maven.transport.AbstractModuleTransporter.ModuleIdentity; import com.netflix.tools.jig.module.maven.transport.TransporterHttpHandler; import com.sun.net.httpserver.HttpServer; diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/JigCommandLine.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/JigCommandLine.java index b12972d..47acd0d 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/JigCommandLine.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/JigCommandLine.java @@ -110,16 +110,17 @@ final class JigCommandLine { private final ToolOption manualApproval = flag("--manual", "Wait for manual approval after validation"); private final CommandLine mavenInstallCommandLine = CommandLine.builder() .description("Install flat module artifacts in the local Maven repository") + .option(moduleVersion) .operand("ARTIFACT-DIRECTORY", "Directory of flat, module-named artifacts", Cardinality.EXACTLY_ONE) .build(); private final CommandLine mavenDeployCommandLine = CommandLine.builder() .description("Deploy flat module artifacts to a Maven repository") - .options(repository, sign) + .options(moduleVersion, repository, sign) .operand("ARTIFACT-DIRECTORY", "Directory of flat, module-named artifacts", Cardinality.EXACTLY_ONE) .build(); private final CommandLine mavenDeployCentralCommandLine = CommandLine.builder() .description("Deploy flat module artifacts to Maven Central") - .options(deploymentName, manualApproval) + .options(moduleVersion, deploymentName, manualApproval) .operand("ARTIFACT-DIRECTORY", "Directory of flat, module-named artifacts", Cardinality.EXACTLY_ONE) .build(); private final CommandLine mavenCommandLine = CommandLine.builder() diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/MavenCommands.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/MavenCommands.java index 15d5b89..f880923 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/MavenCommands.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/MavenCommands.java @@ -49,7 +49,8 @@ static int run(PrintWriter out, PrintWriter err, Supplier"); - out.println(" jig maven deploy --repository [--sign] "); - out.println(" jig maven deploy-central [--name ] [--manual] "); + out.println("Usage: jig maven install [--module-version ] "); + out.println(" jig maven deploy [--module-version ] --repository [--sign] "); + out.println(" jig maven deploy-central [--module-version ] [--name ] [--manual] "); out.println(); out.println("Installs or deploys flat, module-named artifacts."); } private record Request(Path artifacts, RemoteRepository repository, String name, - boolean sign, boolean manual) {} + String moduleVersion, boolean sign, boolean manual) {} } diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/ArtifactCandidates.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/ArtifactCandidates.java index b0c507f..fd94ae8 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/ArtifactCandidates.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/ArtifactCandidates.java @@ -61,6 +61,18 @@ public static Artifact locationCoordinate(String moduleName, String version) { return new DefaultArtifact(moduleNamespace(moduleName), moduleName, "pom", version); } + /** Returns whether an artifact uses the canonical coordinate for its module name. */ + public static boolean isLocationCoordinate(Artifact artifact) { + return isLocationCoordinate(artifact.getGroupId(), artifact.getArtifactId()); + } + + /** Returns whether a group and artifact ID form the canonical coordinate for a module name. */ + public static boolean isLocationCoordinate(String groupId, String artifactId) { + return artifactId != null + && groupId != null + && locationCoordinate(artifactId, null).getGroupId().equals(groupId); + } + /** Returns the coordinate used by the virtual module location repository. */ public static Artifact moduleLocationCoordinate(String moduleName, String version) { return new DefaultArtifact(moduleNamespace(moduleName) + ".module", moduleName, "pom", version); diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/MavenDeployment.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/MavenDeployment.java index 8a614a2..c0f1da4 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/MavenDeployment.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/MavenDeployment.java @@ -47,6 +47,11 @@ private MavenDeployment(Path temporaryDirectory, List artifacts) { } public static MavenDeployment create(Path artifactDirectory, ModuleRepositorySession session) throws IOException { + return create(artifactDirectory, session, null); + } + + public static MavenDeployment create(Path artifactDirectory, ModuleRepositorySession session, + String automaticModuleVersion) throws IOException { Path directory = artifactDirectory.toAbsolutePath().normalize(); if (!Files.isDirectory(directory)) { throw new IllegalArgumentException("Maven artifact directory is not a directory: " + directory); @@ -63,6 +68,10 @@ public static MavenDeployment create(Path artifactDirectory, ModuleRepositorySes var modules = new LinkedHashMap(); for (Path mainArtifact : mainArtifacts) { ModuleDescriptor descriptor = descriptor(mainArtifact); + if (descriptor.isAutomatic() && ModuleIdentity.parseJar(mainArtifact).moduleName() == null) { + throw new IllegalArgumentException("Automatic module has no Automatic-Module-Name: " + + mainArtifact.getFileName()); + } Path expected = directory.resolve(descriptor.name() + ".jar"); if (!mainArtifact.equals(expected)) { throw new IllegalArgumentException("Module JAR must be named " + expected.getFileName() + ": " + mainArtifact); @@ -72,16 +81,45 @@ public static MavenDeployment create(Path artifactDirectory, ModuleRepositorySes } } + var suppliedPoms = new LinkedHashMap(); + var moduleVersions = new LinkedHashMap(); for (var entry : modules.entrySet()) { String moduleName = entry.getKey(); ModuleDescriptor descriptor = entry.getValue(); - String version = descriptor.rawVersion().orElseThrow(() -> new IllegalArgumentException("Module has no version: " + moduleName)); - Artifact coordinate = ArtifactCandidates.locationCoordinate(moduleName, version); Path modulePom = directory.resolve(moduleName + ".pom"); - Model metadata = Files.isRegularFile(modulePom) - ? PublicationMetadataReader.read(modulePom) + Model suppliedPom = Files.isRegularFile(modulePom) + ? descriptor.isAutomatic() + ? PublicationMetadataReader.readAutomaticModulePom(modulePom) + : PublicationMetadataReader.read(modulePom) : null; - Model consumer = consumerPom(metadata, coordinate, descriptor, modules, session); + String suppliedVersion = suppliedPom == null ? null : suppliedPom.getVersion(); + String version = descriptor.rawVersion() + .orElseGet(() -> descriptor.isAutomatic() && suppliedVersion != null + ? suppliedVersion + : automaticModuleVersion); + if (version == null || version.isBlank()) { + throw new IllegalArgumentException("Module has no version: " + moduleName); + } + suppliedPoms.put(moduleName, suppliedPom); + moduleVersions.put(moduleName, version); + } + + for (var entry : modules.entrySet()) { + String moduleName = entry.getKey(); + ModuleDescriptor descriptor = entry.getValue(); + Path modulePom = directory.resolve(moduleName + ".pom"); + Model suppliedPom = suppliedPoms.get(moduleName); + String version = moduleVersions.get(moduleName); + Artifact coordinate = ArtifactCandidates.locationCoordinate(moduleName, version); + boolean completePom = descriptor.isAutomatic() && isCompletePom(suppliedPom); + if (completePom) { + validateAutomaticModulePom(modulePom, suppliedPom, coordinate, + automaticModuleVersion); + } + Model consumer = completePom + ? automaticModulePom(suppliedPom, moduleVersions, session) + : consumerPom(suppliedPom, coordinate, descriptor, modules, moduleVersions, + session); Path generatedPom = temporaryDirectory.resolve(moduleName + ".pom"); writePom(consumer, generatedPom); artifacts.add(artifact(coordinate, "pom", "", generatedPom)); @@ -187,18 +225,88 @@ private static ModuleDescriptor descriptor(Path artifact) { if (references.size() != 1) { throw new IllegalArgumentException("Artifact does not contain exactly one module: " + artifact); } - ModuleDescriptor descriptor = references.iterator() - .next() - .descriptor(); - if (descriptor.isAutomatic()) { - throw new IllegalArgumentException("Artifact does not contain module-info.class: " + artifact); + return references.iterator() + .next() + .descriptor(); + } + + private static boolean isCompletePom(Model model) { + return model != null && (model.getGroupId() != null + || model.getArtifactId() != null + || model.getVersion() != null + || model.getPackaging() != null + || !model.getDependencies().isEmpty()); + } + + private static void validateAutomaticModulePom(Path pom, Model model, Artifact coordinate, + String requestedVersion) { + if (!coordinate.getGroupId().equals(model.getGroupId())) { + throw new IllegalArgumentException(pom.getFileName() + " requires groupId " + + coordinate.getGroupId()); + } + if (!coordinate.getArtifactId().equals(model.getArtifactId())) { + throw new IllegalArgumentException(pom.getFileName() + " requires artifactId " + + coordinate.getArtifactId()); + } + if (!coordinate.getVersion().equals(model.getVersion())) { + throw new IllegalArgumentException(pom.getFileName() + " requires version " + + coordinate.getVersion()); + } + if (requestedVersion != null && !requestedVersion.equals(model.getVersion())) { + throw new IllegalArgumentException(pom.getFileName() + " has version " + + model.getVersion() + " but --module-version is " + requestedVersion); + } + if (model.getPackaging() != null && !"jar".equals(model.getPackaging())) { + throw new IllegalArgumentException(pom.getFileName() + " requires packaging jar"); + } + } + + private static Model automaticModulePom(Model supplied, Map localVersions, + ModuleRepositorySession session) throws IOException { + var dependencies = new ArrayList(); + for (var dependency : supplied.getDependencies()) { + if (!ArtifactCandidates.isLocationCoordinate(dependency.getGroupId(), + dependency.getArtifactId())) { + dependencies.add(dependency); + continue; + } + String moduleName = dependency.getArtifactId(); + if (isSystemModule(moduleName)) { + continue; + } + String version = dependency.getVersion(); + if (version == null || version.isBlank()) { + throw new IllegalArgumentException("Required module has no version: " + moduleName); + } + Artifact coordinate; + String localVersion = localVersions.get(moduleName); + if (localVersion == null) { + coordinate = session.locateModule(moduleName, version); + } else { + if (!version.equals(localVersion)) { + throw new IllegalArgumentException("Required module " + + moduleName + + "@" + + version + + " does not match the artifact directory version " + + localVersion); + } + coordinate = ArtifactCandidates.locationCoordinate(moduleName, version); + } + dependencies.add(Dependency.newBuilder(dependency) + .groupId(coordinate.getGroupId()) + .artifactId(coordinate.getArtifactId()) + .version(coordinate.getVersion()) + .build()); } - return descriptor; + return Model.newBuilder(supplied) + .dependencies(dependencies) + .build(); } private static Model consumerPom(Model merged, Artifact coordinate, ModuleDescriptor descriptor, - Map localModules, ModuleRepositorySession session) - throws IOException { + Map localModules, Map localVersions, + ModuleRepositorySession session) throws IOException { var dependencies = new ArrayList(); for (var requirement : descriptor.requires()) { String name = requirement.name(); @@ -213,7 +321,7 @@ private static Model consumerPom(Model merged, Artifact coordinate, ModuleDescri if (local == null) { dependencyCoordinate = session.locateModule(name, version); } else { - String localVersion = local.rawVersion().orElseThrow(() -> new IllegalArgumentException("Module has no version: " + name)); + String localVersion = localVersions.get(name); if (!version.equals(localVersion)) { throw new IllegalArgumentException(descriptor.name() + " requires " diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/ModuleIdentity.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/ModuleIdentity.java new file mode 100644 index 0000000..e2ef33d --- /dev/null +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/ModuleIdentity.java @@ -0,0 +1,230 @@ +/* + * Copyright 2026 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.netflix.tools.jig.module; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.module.ModuleDescriptor; +import java.lang.module.ModuleDescriptor.Provides; +import java.lang.module.ModuleFinder; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.jar.Attributes.Name; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import javax.lang.model.SourceVersion; + +/** Module identity and descriptor metadata observed in a module artifact. */ +public record ModuleIdentity(String moduleName, String osgiModuleName, ModuleDescriptor descriptor, + Set packages, String mainClass, Map> provides) { + + private static final Pattern VERSIONED_MODULE_INFO = Pattern.compile("META-INF/versions/(\\d+)/module-info\\.class"); + private static final Pattern VERSIONED_ENTRY = Pattern.compile("META-INF/versions/(\\d+)/(.+)"); + + public ModuleIdentity { + if (descriptor != null) { + moduleName = descriptor.name(); + packages = descriptor.packages(); + mainClass = descriptor.mainClass().orElse(null); + var descriptorProvides = new LinkedHashMap>(); + descriptor.provides().forEach(provide -> descriptorProvides.put(provide.service(), provide.providers())); + provides = descriptorProvides; + } + packages = Set.copyOf(packages); + var copiedProvides = new LinkedHashMap>(); + provides.forEach((service, providers) -> copiedProvides.put(service, List.copyOf(providers))); + provides = Map.copyOf(copiedProvides); + } + + public static ModuleIdentity parseJarEntries(Set entryNames, Function reader) { + Manifest manifest = null; + if (entryNames.contains(JarFile.MANIFEST_NAME)) { + var bytes = reader.apply(JarFile.MANIFEST_NAME); + try { + manifest = new Manifest(new ByteArrayInputStream(bytes)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + var manifestIdentity = manifestIdentity(manifest); + var multiRelease = manifest != null && "true".equalsIgnoreCase(manifest.getMainAttributes() + .getValue(Name.MULTI_RELEASE)); + var moduleInfoEntry = findModuleInfo(entryNames, multiRelease); + if (moduleInfoEntry != null) { + var bytes = reader.apply(moduleInfoEntry); + var descriptor = ModuleDescriptor.read(ByteBuffer.wrap(bytes)); + return new ModuleIdentity(null, null, descriptor, Set.of(), null, + Map.of()); + } + + return new ModuleIdentity(manifestIdentity.moduleName(), manifestIdentity.osgiModuleName(), null, + packages(entryNames, multiRelease), manifestIdentity.mainClass(), serviceProviders(entryNames, reader)); + } + + private static ModuleIdentity manifestIdentity(Manifest manifest) { + if (manifest == null) { + return new ModuleIdentity(null, null, null, Set.of(), null, + Map.of()); + } + var attributes = manifest.getMainAttributes(); + return new ModuleIdentity(attributes.getValue("Automatic-Module-Name"), osgiModuleName(attributes.getValue("Bundle-SymbolicName")), null, + Set.of(), attributes.getValue(Name.MAIN_CLASS), Map.of()); + } + + private static String osgiModuleName(String header) { + if (header == null) { + return null; + } + var parameters = header.indexOf(';'); + var name = (parameters < 0 ? header : header.substring(0, parameters)).strip(); + return SourceVersion.isName(name) ? name : null; + } + + private static Map> serviceProviders(Set entryNames, Function reader) { + var prefix = "META-INF/services/"; + var provides = new TreeMap>(); + entryNames.stream() + .filter(name -> name.startsWith(prefix) && name.length() > prefix.length()) + .sorted() + .forEach(name -> { + var providers = Arrays.stream(new String(reader.apply(name), StandardCharsets.UTF_8).split("\\R")) + .map(line -> line.replaceFirst("#.*", "").strip()) + .filter(line -> !line.isEmpty()) + .distinct() + .toList(); + if (!providers.isEmpty()) { + provides.put(name.substring(prefix.length()), providers); + } + }); + return Map.copyOf(provides); + } + + private static Set packages(Set entryNames, boolean multiRelease) { + var packages = new TreeSet(); + var runtimeVersion = JarFile.runtimeVersion().feature(); + for (var original : entryNames) { + var entry = original; + var versioned = VERSIONED_ENTRY.matcher(entry); + if (versioned.matches()) { + var version = Integer.parseInt(versioned.group(1)); + if (!multiRelease || version < JarFile.baseVersion().feature() || version > runtimeVersion) { + continue; + } + entry = versioned.group(2); + } + if (!entry.endsWith(".class") || entry.equals("module-info.class")) { + continue; + } + var separator = entry.lastIndexOf('/'); + if (separator <= 0 || entry.startsWith("META-INF/")) { + continue; + } + packages.add(entry.substring(0, separator) + .replace('/', '.')); + } + return Set.copyOf(packages); + } + + public static ModuleIdentity parseJar(Path jarPath) { + var reference = ModuleFinder.of(jarPath).findAll().stream() + .findFirst() + .orElseThrow(); + var descriptor = reference.descriptor(); + if (!descriptor.isAutomatic()) { + return new ModuleIdentity(null, null, descriptor, Set.of(), null, + Map.of()); + } + + var manifestIdentity = manifestIdentity(jarPath); + var provides = descriptor.provides().stream() + .collect(Collectors.toMap(Provides::service, Provides::providers)); + return new ModuleIdentity( + manifestIdentity.moduleName(), + manifestIdentity.osgiModuleName(), + null, + descriptor.packages(), + descriptor.mainClass().orElse(null), + provides); + } + + private static ModuleIdentity manifestIdentity(Path jarPath) { + try (var jar = new JarFile(jarPath.toFile())) { + return manifestIdentity(jar.getManifest()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static ModuleIdentity parseJmod(Path jmodPath) { + try (var zf = new ZipFile(jmodPath.toFile())) { + var entryNames = zf.stream() + .map(ZipEntry::getName) + .collect(Collectors.toSet()); + return parseJmodEntries(entryNames, name -> { + try (var in = zf.getInputStream(zf.getEntry(name))) { + return in.readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static ModuleIdentity parseJmodEntries(Set entryNames, Function reader) { + var prefix = "classes/"; + var classes = entryNames.stream() + .filter(name -> name.startsWith(prefix)) + .map(name -> name.substring(prefix.length())) + .collect(Collectors.toSet()); + return parseJarEntries(classes, name -> reader.apply(prefix + name)); + } + + private static String findModuleInfo(Set entryNames, boolean multiRelease) { + var best = entryNames.contains("module-info.class") ? "module-info.class" : null; + if (!multiRelease) { + return best; + } + var highestVersion = -1; + var runtimeVersion = JarFile.runtimeVersion().feature(); + for (var name : entryNames) { + var matcher = VERSIONED_MODULE_INFO.matcher(name); + if (!matcher.matches()) { + continue; + } + var version = Integer.parseInt(matcher.group(1)); + if (version >= JarFile.baseVersion().feature() && version <= runtimeVersion && version > highestVersion) { + highestVersion = version; + best = name; + } + } + return best; + } +} diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/PublicationMetadataReader.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/PublicationMetadataReader.java index accb00f..b853b18 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/PublicationMetadataReader.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/PublicationMetadataReader.java @@ -28,7 +28,7 @@ /** Reads the publication metadata contributed by a module POM. */ final class PublicationMetadataReader { private static final String MODEL_NAMESPACE = "http://maven.apache.org/POM/4.0.0"; - private static final Set ALLOWED_ELEMENTS = Set.of( + private static final Set PUBLICATION_ELEMENTS = Set.of( "modelVersion", "name", "description", @@ -36,12 +36,33 @@ final class PublicationMetadataReader { "licenses", "developers", "scm"); + private static final Set AUTOMATIC_MODULE_ELEMENTS = Set.of( + "modelVersion", + "groupId", + "artifactId", + "version", + "packaging", + "name", + "description", + "url", + "licenses", + "developers", + "scm", + "dependencies"); private PublicationMetadataReader() {} static Model read(Path pom) throws IOException { + return read(pom, PUBLICATION_ELEMENTS); + } + + static Model readAutomaticModulePom(Path pom) throws IOException { + return read(pom, AUTOMATIC_MODULE_ELEMENTS); + } + + private static Model read(Path pom, Set allowedElements) throws IOException { Path absolute = pom.toAbsolutePath().normalize(); - validateElements(absolute); + validateElements(absolute, allowedElements); try (var input = Files.newInputStream(absolute)) { Model model = new MavenStaxReader().read(input); if (!"4.0.0".equals(model.getModelVersion())) { @@ -53,7 +74,7 @@ static Model read(Path pom) throws IOException { } } - private static void validateElements(Path pom) throws IOException { + private static void validateElements(Path pom, Set allowedElements) throws IOException { var inputFactory = XMLInputFactory.newFactory(); inputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); inputFactory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); @@ -69,7 +90,7 @@ private static void validateElements(Path pom) throws IOException { throw new IllegalArgumentException(pom.getFileName() + " must be a Maven 4.0.0 project model"); } - if (depth == 2 && !ALLOWED_ELEMENTS.contains(reader.getLocalName())) { + if (depth == 2 && !allowedElements.contains(reader.getLocalName())) { throw new IllegalArgumentException(pom.getFileName() + " contains unsupported element " + reader.getLocalName()); diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/AbstractModuleTransporter.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/AbstractModuleTransporter.java index 68b176d..bb9c4b6 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/AbstractModuleTransporter.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/AbstractModuleTransporter.java @@ -20,35 +20,19 @@ import java.io.IOException; import java.io.OutputStream; import java.io.UncheckedIOException; -import java.lang.module.ModuleDescriptor; -import java.lang.module.ModuleDescriptor.Provides; -import java.lang.module.ModuleFinder; import java.net.URI; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.HexFormat; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.SequencedSet; import java.util.Set; -import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Function; -import java.util.jar.Attributes.Name; -import java.util.jar.JarFile; -import java.util.jar.Manifest; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; -import javax.lang.model.SourceVersion; import javax.xml.stream.XMLStreamException; import com.netflix.tools.jig.internal.org.apache.maven.api.metadata.Metadata; @@ -80,6 +64,7 @@ import com.netflix.tools.jig.internal.org.eclipse.aether.version.Version; import com.netflix.tools.jig.internal.org.eclipse.aether.version.VersionScheme; import com.netflix.tools.jig.module.ArtifactCandidates; +import com.netflix.tools.jig.module.ModuleIdentity; /** * Base class for jig's virtual Maven transporters. Provides resolver helpers, @@ -99,212 +84,6 @@ public abstract class AbstractModuleTransporter extends AbstractTransporter { private static final HexFormat HEX = HexFormat.of(); - /** Module identity and descriptor metadata observed in a module artifact. */ - public record ModuleIdentity(String moduleName, String osgiModuleName, ModuleDescriptor descriptor, - Set packages, String mainClass, Map> provides) { - - private static final Pattern VERSIONED_MODULE_INFO = Pattern.compile("META-INF/versions/(\\d+)/module-info\\.class"); - private static final Pattern VERSIONED_ENTRY = Pattern.compile("META-INF/versions/(\\d+)/(.+)"); - - public ModuleIdentity { - if (descriptor != null) { - moduleName = descriptor.name(); - packages = descriptor.packages(); - mainClass = descriptor.mainClass().orElse(null); - var descriptorProvides = new LinkedHashMap>(); - descriptor.provides().forEach(provide -> descriptorProvides.put(provide.service(), provide.providers())); - provides = descriptorProvides; - } - packages = Set.copyOf(packages); - var copiedProvides = new LinkedHashMap>(); - provides.forEach((service, providers) -> copiedProvides.put(service, List.copyOf(providers))); - provides = Map.copyOf(copiedProvides); - } - - public static ModuleIdentity parseJarEntries(Set entryNames, Function reader) { - Manifest manifest = null; - if (entryNames.contains(JarFile.MANIFEST_NAME)) { - var bytes = reader.apply(JarFile.MANIFEST_NAME); - try { - manifest = new Manifest(new ByteArrayInputStream(bytes)); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - var manifestIdentity = manifestIdentity(manifest); - var multiRelease = manifest != null && "true".equalsIgnoreCase(manifest.getMainAttributes() - .getValue(Name.MULTI_RELEASE)); - var moduleInfoEntry = findModuleInfo(entryNames, multiRelease); - if (moduleInfoEntry != null) { - var bytes = reader.apply(moduleInfoEntry); - var descriptor = ModuleDescriptor.read(ByteBuffer.wrap(bytes)); - return new ModuleIdentity(null, null, descriptor, Set.of(), null, - Map.of()); - } - - return new ModuleIdentity(manifestIdentity.moduleName(), manifestIdentity.osgiModuleName(), null, - packages(entryNames, multiRelease), manifestIdentity.mainClass(), serviceProviders(entryNames, reader)); - } - - private static ModuleIdentity manifestIdentity(Manifest manifest) { - if (manifest == null) { - return new ModuleIdentity(null, null, null, Set.of(), null, - Map.of()); - } - var attributes = manifest.getMainAttributes(); - return new ModuleIdentity(attributes.getValue("Automatic-Module-Name"), osgiModuleName(attributes.getValue("Bundle-SymbolicName")), null, - Set.of(), attributes.getValue(Name.MAIN_CLASS), Map.of()); - } - - private static String osgiModuleName(String header) { - if (header == null) { - return null; - } - var parameters = header.indexOf(';'); - var name = (parameters < 0 ? header : header.substring(0, parameters)).strip(); - return SourceVersion.isName(name) ? name : null; - } - - private static Map> serviceProviders(Set entryNames, Function reader) { - var prefix = "META-INF/services/"; - var provides = new TreeMap>(); - entryNames.stream() - .filter(name -> name.startsWith(prefix) && name.length() > prefix.length()) - .sorted() - .forEach(name -> { - var providers = Arrays.stream(new String(reader.apply(name), StandardCharsets.UTF_8).split("\\R")) - .map(line -> line.replaceFirst("#.*", "").strip()) - .filter(line -> !line.isEmpty()) - .distinct() - .toList(); - if (!providers.isEmpty()) { - provides.put(name.substring(prefix.length()), providers); - } - }); - return Map.copyOf(provides); - } - - private static Set packages(Set entryNames, boolean multiRelease) { - var packages = new TreeSet(); - var runtimeVersion = JarFile.runtimeVersion().feature(); - for (var original : entryNames) { - var entry = original; - var versioned = VERSIONED_ENTRY.matcher(entry); - if (versioned.matches()) { - var version = Integer.parseInt(versioned.group(1)); - if (!multiRelease || version < JarFile.baseVersion().feature() || version > runtimeVersion) { - continue; - } - entry = versioned.group(2); - } - if (!entry.endsWith(".class") || entry.equals("module-info.class")) { - continue; - } - var separator = entry.lastIndexOf('/'); - if (separator <= 0 || entry.startsWith("META-INF/")) { - continue; - } - packages.add(entry.substring(0, separator) - .replace('/', '.')); - } - return Set.copyOf(packages); - } - - public static ModuleIdentity parseJar(Path jarPath) { - var reference = ModuleFinder.of(jarPath).findAll().stream() - .findFirst() - .orElseThrow(); - var descriptor = reference.descriptor(); - if (!descriptor.isAutomatic()) { - return new ModuleIdentity(null, null, descriptor, Set.of(), null, - Map.of()); - } - - var manifestIdentity = manifestIdentity(jarPath); - var provides = descriptor.provides().stream() - .collect(Collectors.toMap(Provides::service, Provides::providers)); - return new ModuleIdentity( - manifestIdentity.moduleName(), - manifestIdentity.osgiModuleName(), - null, - descriptor.packages(), - descriptor.mainClass().orElse(null), - provides); - } - - private static ModuleIdentity manifestIdentity(Path jarPath) { - try (var jar = new JarFile(jarPath.toFile())) { - return manifestIdentity(jar.getManifest()); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - private static ModuleIdentity parseJarEntries(Path jarPath) { - try (var zf = new ZipFile(jarPath.toFile())) { - var entryNames = zf.stream() - .map(ZipEntry::getName) - .collect(Collectors.toSet()); - return parseJarEntries(entryNames, name -> { - try (var in = zf.getInputStream(zf.getEntry(name))) { - return in.readAllBytes(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - public static ModuleIdentity parseJmod(Path jmodPath) { - try (var zf = new ZipFile(jmodPath.toFile())) { - var entryNames = zf.stream() - .map(ZipEntry::getName) - .collect(Collectors.toSet()); - return parseJmodEntries(entryNames, name -> { - try (var in = zf.getInputStream(zf.getEntry(name))) { - return in.readAllBytes(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - public static ModuleIdentity parseJmodEntries(Set entryNames, Function reader) { - var prefix = "classes/"; - var classes = entryNames.stream() - .filter(name -> name.startsWith(prefix)) - .map(name -> name.substring(prefix.length())) - .collect(Collectors.toSet()); - return parseJarEntries(classes, name -> reader.apply(prefix + name)); - } - - private static String findModuleInfo(Set entryNames, boolean multiRelease) { - var best = entryNames.contains("module-info.class") ? "module-info.class" : null; - if (!multiRelease) { - return best; - } - var highestVersion = -1; - var runtimeVersion = JarFile.runtimeVersion().feature(); - for (var name : entryNames) { - var matcher = VERSIONED_MODULE_INFO.matcher(name); - if (!matcher.matches()) { - continue; - } - var version = Integer.parseInt(matcher.group(1)); - if (version >= JarFile.baseVersion().feature() && version <= runtimeVersion && version > highestVersion) { - highestVersion = version; - best = name; - } - } - return best; - } - } - private static final String IDENTITY_CACHE_KEY = "jig.module.identity.cache"; private static final String BOM_CACHE_KEY = "jig.module.bom.cache"; diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/JdkTransporter.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/JdkTransporter.java index 3cb3ee7..051bcf0 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/JdkTransporter.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/JdkTransporter.java @@ -105,7 +105,7 @@ import com.netflix.tools.jig.internal.org.eclipse.aether.util.FileUtils; import com.netflix.tools.jig.internal.org.eclipse.aether.util.FileUtils.CollocatedTempFile; import com.netflix.tools.jig.internal.org.eclipse.aether.util.FileUtils.TempFile; -import com.netflix.tools.jig.module.maven.transport.AbstractModuleTransporter.ModuleIdentity; +import com.netflix.tools.jig.module.ModuleIdentity; import com.netflix.tools.jig.module.maven.transport.JdkHttpClientState.AuthenticationScope; import com.netflix.tools.jig.module.maven.transport.JdkHttpClientState.ClientProfile; import com.netflix.tools.jig.module.maven.transport.JdkHttpClientState.ProxyProfile; diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/MavenModuleProbe.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/MavenModuleProbe.java index 2dea6d2..9ae2035 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/MavenModuleProbe.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/MavenModuleProbe.java @@ -17,7 +17,7 @@ import java.util.Objects; import java.util.function.Supplier; -import com.netflix.tools.jig.module.maven.transport.AbstractModuleTransporter.ModuleIdentity; +import com.netflix.tools.jig.module.ModuleIdentity; /** * Scoped probe for module identity via the Maven transport layer. diff --git a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/ModuleProxyTransporter.java b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/ModuleProxyTransporter.java index 5921711..7a22f63 100644 --- a/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/ModuleProxyTransporter.java +++ b/src/com.netflix.tools.jig/com/netflix/tools/jig/module/maven/transport/ModuleProxyTransporter.java @@ -69,6 +69,7 @@ import com.netflix.tools.jig.module.ArtifactCandidates; import com.netflix.tools.jig.module.MavenArtifactOrigin; import com.netflix.tools.jig.module.MavenDependency; +import com.netflix.tools.jig.module.ModuleIdentity; /** * Maps Maven artifacts into the canonical module coordinate space. @@ -286,11 +287,11 @@ private static String resolutionContext(String path) { } } Artifact requested = parseArtifactPath(contentPath); - if (requested != null && isCanonical(requested)) { + if (requested != null && ArtifactCandidates.isLocationCoordinate(requested)) { return "resolve " + requested.getArtifactId() + "@" + requested.getVersion(); } Artifact metadata = parseMetadataPath(URI.create(contentPath)); - if (metadata != null && isCanonical(metadata)) { + if (metadata != null && ArtifactCandidates.isLocationCoordinate(metadata)) { return "resolve " + metadata.getArtifactId() + " versions"; } return null; @@ -312,14 +313,14 @@ private Resource resourceInContext(String path) throws Exception { } Artifact metadata = parseMetadataPath(URI.create(path)); - if (metadata != null && isCanonical(metadata)) { + if (metadata != null && ArtifactCandidates.isLocationCoordinate(metadata)) { trace("generate canonical metadata %s:%s", metadata.getGroupId(), metadata.getArtifactId()); var model = metadataModel(metadata); return generatedResource(output -> serializeMetadata(output, model)); } Artifact requested = parseArtifactPath(path); - if (requested == null || !isCanonical(requested)) { + if (requested == null || !ArtifactCandidates.isLocationCoordinate(requested)) { throw new FileNotFoundException(path); } trace("canonical resource %s", requested); @@ -705,9 +706,4 @@ private void recordLocation(String moduleName, Artifact target) throws IOExcepti } } - private static boolean isCanonical(Artifact artifact) { - return ArtifactCandidates.locationCoordinate(artifact.getArtifactId(), null) - .getGroupId() - .equals(artifact.getGroupId()); - } }