Skip to content

Commit bd74922

Browse files
david-beaumontlahodaj
authored andcommitted
8338675: javac shouldn't silently change .jar files on the classpath
Reviewed-by: jlahoda
1 parent 8d3d1d4 commit bd74922

File tree

3 files changed

+329
-4
lines changed

3 files changed

+329
-4
lines changed

src/jdk.compiler/share/classes/com/sun/tools/javac/file/JavacFileManager.java

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -913,13 +913,21 @@ private JavaFileObject getFileForOutput(Location location,
913913
if (getClassOutDir() != null) {
914914
dir = getClassOutDir();
915915
} else {
916+
// Sibling is the associated source of the class file (e.g. x/y/Foo.java).
917+
// The base name for class output is the class file name (e.g. "Foo.class").
916918
String baseName = fileName.basename();
917-
if (sibling != null && sibling instanceof PathFileObject pathFileObject) {
919+
// Use the sibling to determine the output location where possible, unless
920+
// it is in a JAR/ZIP file (we don't attempt to write class files back into
921+
// archives).
922+
if (sibling instanceof PathFileObject pathFileObject && !pathFileObject.isJarFile()) {
918923
return pathFileObject.getSibling(baseName);
919924
} else {
920-
Path p = getPath(baseName);
921-
Path real = fsInfo.getCanonicalFile(p);
922-
return PathFileObject.forSimplePath(this, real, p);
925+
// Without the sibling present, we just create an output path in the
926+
// current working directory (this isn't great, but it is what older
927+
// versions of the JDK did).
928+
Path userPath = getPath(baseName);
929+
Path realPath = fsInfo.getCanonicalFile(userPath);
930+
return PathFileObject.forSimplePath(this, realPath, userPath);
923931
}
924932
}
925933
} else if (location == SOURCE_OUTPUT) {

src/jdk.compiler/share/classes/com/sun/tools/javac/file/PathFileObject.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,13 @@ protected PathFileObject(BaseFileManager fileManager, Path path) {
354354
*/
355355
abstract PathFileObject getSibling(String basename);
356356

357+
/**
358+
* Returns whether this file object represents a file in a JAR archive.
359+
*/
360+
boolean isJarFile() {
361+
return this instanceof JarFileObject;
362+
}
363+
357364
/**
358365
* Return the Path for this object.
359366
* @return the Path for this object.
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
/*
2+
* Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
*
5+
* This code is free software; you can redistribute it and/or modify it
6+
* under the terms of the GNU General Public License version 2 only, as
7+
* published by the Free Software Foundation.
8+
*
9+
* This code is distributed in the hope that it will be useful, but WITHOUT
10+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
12+
* version 2 for more details (a copy is included in the LICENSE file that
13+
* accompanied this code).
14+
*
15+
* You should have received a copy of the GNU General Public License version
16+
* 2 along with this work; if not, write to the Free Software Foundation,
17+
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
18+
*
19+
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
20+
* or visit www.oracle.com if you need additional information or have any
21+
* questions.
22+
*/
23+
24+
/*
25+
* @test
26+
* @bug 8338675
27+
* @summary javac shouldn't silently change .jar files on the classpath
28+
* @library /tools/lib /tools/javac/lib
29+
* @modules jdk.compiler/com.sun.tools.javac.api
30+
* jdk.compiler/com.sun.tools.javac.main
31+
* @build toolbox.ToolBox toolbox.JarTask toolbox.JavacTask
32+
* @run junit TestNoOverwriteJarFiles
33+
*/
34+
35+
import org.junit.jupiter.api.BeforeAll;
36+
import org.junit.jupiter.api.BeforeEach;
37+
import org.junit.jupiter.api.Test;
38+
import org.junit.jupiter.api.parallel.Execution;
39+
import toolbox.JavacTask;
40+
import toolbox.ToolBox;
41+
42+
import javax.annotation.processing.RoundEnvironment;
43+
import javax.lang.model.element.TypeElement;
44+
import javax.tools.FileObject;
45+
import java.io.IOException;
46+
import java.io.OutputStream;
47+
import java.net.URI;
48+
import java.nio.charset.StandardCharsets;
49+
import java.nio.file.Files;
50+
import java.nio.file.Path;
51+
import java.nio.file.attribute.FileTime;
52+
import java.time.Instant;
53+
import java.util.Set;
54+
import java.util.jar.JarOutputStream;
55+
import java.util.zip.ZipEntry;
56+
57+
import static javax.tools.StandardLocation.CLASS_OUTPUT;
58+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
59+
import static org.junit.jupiter.api.Assertions.assertEquals;
60+
import static org.junit.jupiter.api.Assertions.assertFalse;
61+
import static org.junit.jupiter.api.Assertions.assertTrue;
62+
import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;
63+
64+
/**
65+
* Tests that javac cannot unexpectedly modify contents of JAR files on the
66+
* class path.
67+
* <p>
68+
* Consider the following javac behaviours:
69+
* <ol>
70+
* <li>If there is no source path, javac searches the classpath for sources,
71+
* including inside JAR files.
72+
* <li>If a Java source file was modified more recently that an existing class
73+
* file, or if no class file exists, javac will compile it from the source.
74+
* <li>If there is no output directory specified, javac will put compiled class
75+
* files next to their corresponding sources.
76+
* </ol>
77+
* Taken together, this suggests that a newly compiled class file should be
78+
* written back into the JAR in which its source was found, possibly overwriting
79+
* an existing class file entry. This would be very problematic.
80+
* <p>
81+
* This test ensures javac will not modify JAR files on the classpath, even if
82+
* it compiles sources contained within them. Instead, the class file will be
83+
* written into the current working directory, which mimics the JDK 8 behavior.
84+
*
85+
* <h2>Important</h2>
86+
*
87+
* This test creates files from Java compilation and annotation processing, and
88+
* relies on files being written to the current working directory. Since jtreg
89+
* currently offers no way to run each test case in its own directory, or clean
90+
* the test directory between test cases, we must be careful to:
91+
* <ul>
92+
* <li>Use {@code @Execution(SAME_THREAD)} to run test cases sequentially.
93+
* <li>Clean up the test directory ourselves between test cases (via
94+
* {@code @BeforeEach}).
95+
* </ul>
96+
* The alternative approach would be to compile the test classes in a specified
97+
* working directory unique to each test case, but this is currently only
98+
* possible using a subprocess via {@code Task.Mode.EXEC} , and this has two
99+
* serious disadvantages:
100+
* <ul>
101+
* <li>It significantly complicates compilation setup.
102+
* <li>It prevents step-through debugging of the annotation processor.
103+
* </ul>
104+
*/
105+
@Execution(SAME_THREAD)
106+
public class TestNoOverwriteJarFiles {
107+
private static final String LIB_SOURCE_FILE_NAME = "lib/LibClass.java";
108+
private static final String LIB_CLASS_FILE_NAME = "lib/LibClass.class";
109+
private static final String LIB_CLASS_TYPE_NAME = "lib.LibClass";
110+
111+
private static final Path TEST_LIB_JAR = Path.of("lib.jar");
112+
private static final Path OUTPUT_CLASS_FILE = Path.of("LibClass.class");
113+
114+
// Source which can only compile against the Java source in the test library.
115+
public static final String TARGET_SOURCE =
116+
"""
117+
class TargetClass {
118+
static final String VALUE = lib.LibClass.NEW_FIELD;
119+
}
120+
""";
121+
122+
// Not expensive to create, but conceptually a singleton.
123+
private static final ToolBox toolBox = new ToolBox();
124+
125+
@BeforeAll
126+
public static void ensureEmptyTestDirectory() throws IOException {
127+
try (var files = Files.walk(Path.of("."), 1)) {
128+
// Always includes the given path as the first returned element, so skip it.
129+
if (files.skip(1).findFirst().isPresent()) {
130+
throw new IllegalStateException("Test working directory must be empty.");
131+
}
132+
}
133+
}
134+
135+
@BeforeEach
136+
public void cleanUpTestDirectory() throws IOException {
137+
toolBox.cleanDirectory(Path.of("."));
138+
}
139+
140+
@Test
141+
public void jarFileNotModifiedOrdinaryCompilation() throws IOException {
142+
byte[] originalJarBytes = compileTestLibJar();
143+
144+
new JavacTask(toolBox)
145+
.sources(TARGET_SOURCE)
146+
.classpath(TEST_LIB_JAR)
147+
.run()
148+
.writeAll();
149+
150+
// Assertion 1: The JAR is unchanged.
151+
assertArrayEquals(originalJarBytes, Files.readAllBytes(TEST_LIB_JAR), "Jar file was modified.");
152+
// Assertion 2: An output class file was written to the current directory.
153+
assertTrue(Files.exists(OUTPUT_CLASS_FILE), "Output class file missing.");
154+
}
155+
156+
// As above, but the JAR is added to the source path instead (with same results).
157+
@Test
158+
public void jarFileNotModifiedForSourcePath() throws IOException {
159+
byte[] originalJarBytes = compileTestLibJar();
160+
161+
new JavacTask(toolBox)
162+
.sources(TARGET_SOURCE)
163+
.sourcepath(TEST_LIB_JAR)
164+
.run()
165+
.writeAll();
166+
167+
// Assertion 1: The JAR is unchanged.
168+
assertArrayEquals(originalJarBytes, Files.readAllBytes(TEST_LIB_JAR), "Jar file was modified.");
169+
// Assertion 2: An output class file was written to the current directory.
170+
assertTrue(Files.exists(OUTPUT_CLASS_FILE), "Output class file missing.");
171+
}
172+
173+
@Test
174+
public void jarFileNotModifiedAnnotationProcessing() throws IOException {
175+
byte[] originalJarBytes = compileTestLibJar();
176+
177+
new JavacTask(toolBox)
178+
.sources(TARGET_SOURCE)
179+
.classpath(TEST_LIB_JAR)
180+
.processors(new TestAnnotationProcessor())
181+
// Use "-implicit:none" to avoid writing the library class file.
182+
.options("-implicit:none", "-g:source,lines,vars")
183+
.run()
184+
.writeAll();
185+
186+
// Assertion 1: The JAR is unchanged.
187+
assertArrayEquals(originalJarBytes, Files.readAllBytes(TEST_LIB_JAR), "Jar file was modified.");
188+
// Assertion 2: All expected output files were written to the current directory.
189+
assertDummyFile("DummySource.java");
190+
assertDummyFile("DummyClass.class");
191+
assertDummyFile("DummyResource.txt");
192+
// Assertion 3: The class file itself wasn't written (because we used "-implicit:none").
193+
assertFalse(Files.exists(OUTPUT_CLASS_FILE), "Unexpected class file in working directory.");
194+
}
195+
196+
static class TestAnnotationProcessor extends JavacTestingAbstractProcessor {
197+
@Override
198+
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) {
199+
// Only run this once (in the final pass), or else we get spurious failures about
200+
// trying to recreate file objects (not allowed during annotation processing).
201+
if (!env.processingOver()) {
202+
return false;
203+
}
204+
205+
TypeElement libClass = elements.getTypeElement(LIB_CLASS_TYPE_NAME);
206+
try {
207+
// Note: A generated Java source file must be legal Java, but a generated class
208+
// file that's unreferenced will never be loaded, so can contain any bytes.
209+
writeFileObject(
210+
filer.createSourceFile("DummySource", libClass),
211+
"DummySource.java",
212+
"class DummySource {}");
213+
writeFileObject(
214+
filer.createClassFile("DummyClass", libClass),
215+
"DummyClass.class",
216+
"<<DummyClass Bytes>>");
217+
writeFileObject(
218+
filer.createResource(CLASS_OUTPUT, "", "DummyResource.txt", libClass),
219+
"DummyResource.txt",
220+
"Dummy Resource Bytes");
221+
} catch (IOException e) {
222+
throw new RuntimeException(e);
223+
}
224+
return false;
225+
}
226+
}
227+
228+
static void writeFileObject(FileObject file, String expectedName, String contents)
229+
throws IOException {
230+
URI fileUri = file.toUri();
231+
// Check that the file URI doesn't look like it is associated with a JAR.
232+
assertTrue(fileUri.getSchemeSpecificPart().endsWith("/" + expectedName));
233+
// The JAR file system would have a scheme of "jar", not "file".
234+
assertEquals("file", fileUri.getScheme());
235+
// Testing a negative is fragile, but a JAR URI would be expected to contain "jar!".
236+
assertFalse(fileUri.getSchemeSpecificPart().contains("jar!"));
237+
// Write dummy data (which should end up in the test working directory).
238+
try (OutputStream os = file.openOutputStream()) {
239+
os.write(contents.getBytes());
240+
}
241+
}
242+
243+
static void assertDummyFile(String filename) throws IOException {
244+
Path path = Path.of(filename);
245+
assertTrue(Files.exists(path), "Output file missing: " + filename);
246+
assertTrue(Files.readString(path).contains("Dummy"), "Unexpected file contents: " + filename);
247+
}
248+
249+
// Compiles and writes the test library JAR (LIB_JAR) into the current directory.
250+
static byte[] compileTestLibJar() throws IOException {
251+
Path libDir = Path.of("lib");
252+
toolBox.createDirectories(libDir);
253+
try {
254+
toolBox.writeFile(LIB_SOURCE_FILE_NAME,
255+
"""
256+
package lib;
257+
public class LibClass {
258+
public static final String OLD_FIELD = "This will not compile with Target";
259+
}
260+
""");
261+
262+
// Compile the old (broken) source and then store the class file in the JAR.
263+
// The class file is generated in the lib/ directory, which we delete after
264+
// making the JAR. This ensures that when compiling the target class, it's
265+
// the source file being read from the JAR,
266+
new JavacTask(toolBox)
267+
.files(LIB_SOURCE_FILE_NAME)
268+
.run()
269+
.writeAll();
270+
271+
// If timestamps are equal JAR file resolution of classes is ambiguous
272+
// (currently "last one wins"), so give the source we want to be used a
273+
// newer timestamp.
274+
Instant now = Instant.now();
275+
try (OutputStream jos = Files.newOutputStream(Path.of("lib.jar"))) {
276+
JarOutputStream jar = new JarOutputStream(jos);
277+
writeEntry(jar,
278+
LIB_SOURCE_FILE_NAME,
279+
"""
280+
package lib;
281+
public class LibClass {
282+
public static final String NEW_FIELD = "This will compile with Target";
283+
}
284+
""".getBytes(StandardCharsets.UTF_8),
285+
now.plusSeconds(1));
286+
writeEntry(jar,
287+
LIB_CLASS_FILE_NAME,
288+
Files.readAllBytes(Path.of(LIB_CLASS_FILE_NAME)),
289+
now);
290+
jar.close();
291+
}
292+
// Return the JAR file bytes for comparison later.
293+
return Files.readAllBytes(TEST_LIB_JAR);
294+
} finally {
295+
toolBox.cleanDirectory(libDir);
296+
toolBox.deleteFiles(libDir);
297+
}
298+
}
299+
300+
// Note: JarOutputStream only writes modification time, not creation time, but
301+
// that's what Javac uses to determine "newness" so it's fine.
302+
private static void writeEntry(JarOutputStream jar, String name, byte[] bytes, Instant timestamp)
303+
throws IOException {
304+
ZipEntry e = new ZipEntry(name);
305+
e.setLastModifiedTime(FileTime.from(timestamp));
306+
jar.putNextEntry(e);
307+
jar.write(bytes);
308+
jar.closeEntry();
309+
}
310+
}

0 commit comments

Comments
 (0)