/* * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. * * This code is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. * * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ /* * @test * @bug 8338675 * @summary javac shouldn't silently change .jar files on the classpath * @library /tools/lib /tools/javac/lib * @modules jdk.compiler/com.sun.tools.javac.api * jdk.compiler/com.sun.tools.javac.main * @build toolbox.ToolBox toolbox.JarTask toolbox.JavacTask * @run junit TestNoOverwriteJarFiles */ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Execution; import toolbox.JavacTask; import toolbox.ToolBox; import javax.annotation.processing.RoundEnvironment; import javax.lang.model.element.TypeElement; import javax.tools.FileObject; import java.io.IOException; import java.io.OutputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.FileTime; import java.time.Instant; import java.util.Set; import java.util.jar.JarOutputStream; import java.util.zip.ZipEntry; import static javax.tools.StandardLocation.CLASS_OUTPUT; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD; /** * Tests that javac cannot unexpectedly modify contents of JAR files on the * class path. *

* Consider the following javac behaviours: *

    *
  1. If there is no source path, javac searches the classpath for sources, * including inside JAR files. *
  2. If a Java source file was modified more recently that an existing class * file, or if no class file exists, javac will compile it from the source. *
  3. If there is no output directory specified, javac will put compiled class * files next to their corresponding sources. *
* Taken together, this suggests that a newly compiled class file should be * written back into the JAR in which its source was found, possibly overwriting * an existing class file entry. This would be very problematic. *

* This test ensures javac will not modify JAR files on the classpath, even if * it compiles sources contained within them. Instead, the class file will be * written into the current working directory, which mimics the JDK 8 behavior. * *

Important

* * This test creates files from Java compilation and annotation processing, and * relies on files being written to the current working directory. Since jtreg * currently offers no way to run each test case in its own directory, or clean * the test directory between test cases, we must be careful to: * * The alternative approach would be to compile the test classes in a specified * working directory unique to each test case, but this is currently only * possible using a subprocess via {@code Task.Mode.EXEC} , and this has two * serious disadvantages: * */ @Execution(SAME_THREAD) public class TestNoOverwriteJarFiles { private static final String LIB_SOURCE_FILE_NAME = "lib/LibClass.java"; private static final String LIB_CLASS_FILE_NAME = "lib/LibClass.class"; private static final String LIB_CLASS_TYPE_NAME = "lib.LibClass"; private static final Path TEST_LIB_JAR = Path.of("lib.jar"); private static final Path OUTPUT_CLASS_FILE = Path.of("LibClass.class"); // Source which can only compile against the Java source in the test library. public static final String TARGET_SOURCE = """ class TargetClass { static final String VALUE = lib.LibClass.NEW_FIELD; } """; // Not expensive to create, but conceptually a singleton. private static final ToolBox toolBox = new ToolBox(); @BeforeAll public static void ensureEmptyTestDirectory() throws IOException { try (var files = Files.walk(Path.of("."), 1)) { // Always includes the given path as the first returned element, so skip it. if (files.skip(1).findFirst().isPresent()) { throw new IllegalStateException("Test working directory must be empty."); } } } @BeforeEach public void cleanUpTestDirectory() throws IOException { toolBox.cleanDirectory(Path.of(".")); } @Test public void jarFileNotModifiedOrdinaryCompilation() throws IOException { byte[] originalJarBytes = compileTestLibJar(); new JavacTask(toolBox) .sources(TARGET_SOURCE) .classpath(TEST_LIB_JAR) .run() .writeAll(); // Assertion 1: The JAR is unchanged. assertArrayEquals(originalJarBytes, Files.readAllBytes(TEST_LIB_JAR), "Jar file was modified."); // Assertion 2: An output class file was written to the current directory. assertTrue(Files.exists(OUTPUT_CLASS_FILE), "Output class file missing."); } // As above, but the JAR is added to the source path instead (with same results). @Test public void jarFileNotModifiedForSourcePath() throws IOException { byte[] originalJarBytes = compileTestLibJar(); new JavacTask(toolBox) .sources(TARGET_SOURCE) .sourcepath(TEST_LIB_JAR) .run() .writeAll(); // Assertion 1: The JAR is unchanged. assertArrayEquals(originalJarBytes, Files.readAllBytes(TEST_LIB_JAR), "Jar file was modified."); // Assertion 2: An output class file was written to the current directory. assertTrue(Files.exists(OUTPUT_CLASS_FILE), "Output class file missing."); } @Test public void jarFileNotModifiedAnnotationProcessing() throws IOException { byte[] originalJarBytes = compileTestLibJar(); new JavacTask(toolBox) .sources(TARGET_SOURCE) .classpath(TEST_LIB_JAR) .processors(new TestAnnotationProcessor()) // Use "-implicit:none" to avoid writing the library class file. .options("-implicit:none", "-g:source,lines,vars") .run() .writeAll(); // Assertion 1: The JAR is unchanged. assertArrayEquals(originalJarBytes, Files.readAllBytes(TEST_LIB_JAR), "Jar file was modified."); // Assertion 2: All expected output files were written to the current directory. assertDummyFile("DummySource.java"); assertDummyFile("DummyClass.class"); assertDummyFile("DummyResource.txt"); // Assertion 3: The class file itself wasn't written (because we used "-implicit:none"). assertFalse(Files.exists(OUTPUT_CLASS_FILE), "Unexpected class file in working directory."); } static class TestAnnotationProcessor extends JavacTestingAbstractProcessor { @Override public boolean process(Set annotations, RoundEnvironment env) { // Only run this once (in the final pass), or else we get spurious failures about // trying to recreate file objects (not allowed during annotation processing). if (!env.processingOver()) { return false; } TypeElement libClass = elements.getTypeElement(LIB_CLASS_TYPE_NAME); try { // Note: A generated Java source file must be legal Java, but a generated class // file that's unreferenced will never be loaded, so can contain any bytes. writeFileObject( filer.createSourceFile("DummySource", libClass), "DummySource.java", "class DummySource {}"); writeFileObject( filer.createClassFile("DummyClass", libClass), "DummyClass.class", "<>"); writeFileObject( filer.createResource(CLASS_OUTPUT, "", "DummyResource.txt", libClass), "DummyResource.txt", "Dummy Resource Bytes"); } catch (IOException e) { throw new RuntimeException(e); } return false; } } static void writeFileObject(FileObject file, String expectedName, String contents) throws IOException { URI fileUri = file.toUri(); // Check that the file URI doesn't look like it is associated with a JAR. assertTrue(fileUri.getSchemeSpecificPart().endsWith("/" + expectedName)); // The JAR file system would have a scheme of "jar", not "file". assertEquals("file", fileUri.getScheme()); // Testing a negative is fragile, but a JAR URI would be expected to contain "jar!". assertFalse(fileUri.getSchemeSpecificPart().contains("jar!")); // Write dummy data (which should end up in the test working directory). try (OutputStream os = file.openOutputStream()) { os.write(contents.getBytes()); } } static void assertDummyFile(String filename) throws IOException { Path path = Path.of(filename); assertTrue(Files.exists(path), "Output file missing: " + filename); assertTrue(Files.readString(path).contains("Dummy"), "Unexpected file contents: " + filename); } // Compiles and writes the test library JAR (LIB_JAR) into the current directory. static byte[] compileTestLibJar() throws IOException { Path libDir = Path.of("lib"); toolBox.createDirectories(libDir); try { toolBox.writeFile(LIB_SOURCE_FILE_NAME, """ package lib; public class LibClass { public static final String OLD_FIELD = "This will not compile with Target"; } """); // Compile the old (broken) source and then store the class file in the JAR. // The class file is generated in the lib/ directory, which we delete after // making the JAR. This ensures that when compiling the target class, it's // the source file being read from the JAR, new JavacTask(toolBox) .files(LIB_SOURCE_FILE_NAME) .run() .writeAll(); // If timestamps are equal JAR file resolution of classes is ambiguous // (currently "last one wins"), so give the source we want to be used a // newer timestamp. Instant now = Instant.now(); try (OutputStream jos = Files.newOutputStream(Path.of("lib.jar"))) { JarOutputStream jar = new JarOutputStream(jos); writeEntry(jar, LIB_SOURCE_FILE_NAME, """ package lib; public class LibClass { public static final String NEW_FIELD = "This will compile with Target"; } """.getBytes(StandardCharsets.UTF_8), now.plusSeconds(1)); writeEntry(jar, LIB_CLASS_FILE_NAME, Files.readAllBytes(Path.of(LIB_CLASS_FILE_NAME)), now); jar.close(); } // Return the JAR file bytes for comparison later. return Files.readAllBytes(TEST_LIB_JAR); } finally { toolBox.cleanDirectory(libDir); toolBox.deleteFiles(libDir); } } // Note: JarOutputStream only writes modification time, not creation time, but // that's what Javac uses to determine "newness" so it's fine. private static void writeEntry(JarOutputStream jar, String name, byte[] bytes, Instant timestamp) throws IOException { ZipEntry e = new ZipEntry(name); e.setLastModifiedTime(FileTime.from(timestamp)); jar.putNextEntry(e); jar.write(bytes); jar.closeEntry(); } }