1 /*
2  * [The "BSD licence"]
3  * Copyright (c) 2010 Ben Gruver
4  * All rights reserved.
5  *
6  * Redistribution and use in source and binary forms, with or without
7  * modification, are permitted provided that the following conditions
8  * are met:
9  * 1. Redistributions of source code must retain the above copyright
10  *    notice, this list of conditions and the following disclaimer.
11  * 2. Redistributions in binary form must reproduce the above copyright
12  *    notice, this list of conditions and the following disclaimer in the
13  *    documentation and/or other materials provided with the distribution.
14  * 3. The name of the author may not be used to endorse or promote products
15  *    derived from this software without specific prior written permission.
16  *
17  * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
18  * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
19  * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
20  * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
21  * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
22  * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25  * INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
26  * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27  */
28 
29 package org.jf.util;
30 
31 import com.google.common.collect.ArrayListMultimap;
32 import com.google.common.collect.Multimap;
33 
34 import javax.annotation.Nonnull;
35 import javax.annotation.Nullable;
36 import java.io.*;
37 import java.nio.ByteBuffer;
38 import java.nio.CharBuffer;
39 import java.nio.IntBuffer;
40 import java.util.Collection;
41 import java.util.regex.Pattern;
42 
43 /**
44  * This class handles the complexities of translating a class name into a file name. i.e. dealing with case insensitive
45  * file systems, windows reserved filenames, class names with extremely long package/class elements, etc.
46  *
47  * The types of transformations this class does include:
48  * - append a '#123' style numeric suffix if 2 physical representations collide
49  * - replace some number of characters in the middle with a '#' character name if an individual path element is too long
50  * - append a '#' if an individual path element would otherwise be considered a reserved filename
51  */
52 public class ClassFileNameHandler {
53     private static final int MAX_FILENAME_LENGTH = 255;
54     // How many characters to reserve in the physical filename for numeric suffixes
55     // Dex files can currently only have 64k classes, so 5 digits plus 1 for an '#' should
56     // be sufficient to handle the case when every class has a conflicting name
57     private static final int NUMERIC_SUFFIX_RESERVE = 6;
58 
59     private final int NO_VALUE = -1;
60     private final int CASE_INSENSITIVE = 0;
61     private final int CASE_SENSITIVE = 1;
62     private int forcedCaseSensitivity = NO_VALUE;
63 
64     private DirectoryEntry top;
65     private String fileExtension;
66     private boolean modifyWindowsReservedFilenames;
67 
ClassFileNameHandler(File path, String fileExtension)68     public ClassFileNameHandler(File path, String fileExtension) {
69         this.top = new DirectoryEntry(path);
70         this.fileExtension = fileExtension;
71         this.modifyWindowsReservedFilenames = testForWindowsReservedFileNames(path);
72     }
73 
74     // for testing
ClassFileNameHandler(File path, String fileExtension, boolean caseSensitive, boolean modifyWindowsReservedFilenames)75     public ClassFileNameHandler(File path, String fileExtension, boolean caseSensitive,
76                                 boolean modifyWindowsReservedFilenames) {
77         this.top = new DirectoryEntry(path);
78         this.fileExtension = fileExtension;
79         this.forcedCaseSensitivity = caseSensitive?CASE_SENSITIVE:CASE_INSENSITIVE;
80         this.modifyWindowsReservedFilenames = modifyWindowsReservedFilenames;
81     }
82 
getMaxFilenameLength()83     private int getMaxFilenameLength() {
84         return MAX_FILENAME_LENGTH - NUMERIC_SUFFIX_RESERVE;
85     }
86 
getUniqueFilenameForClass(String className)87     public File getUniqueFilenameForClass(String className) {
88         //class names should be passed in the normal dalvik style, with a leading L, a trailing ;, and using
89         //'/' as a separator.
90         if (className.charAt(0) != 'L' || className.charAt(className.length()-1) != ';') {
91             throw new RuntimeException("Not a valid dalvik class name");
92         }
93 
94         int packageElementCount = 1;
95         for (int i=1; i<className.length()-1; i++) {
96             if (className.charAt(i) == '/') {
97                 packageElementCount++;
98             }
99         }
100 
101         String[] packageElements = new String[packageElementCount];
102         int elementIndex = 0;
103         int elementStart = 1;
104         for (int i=1; i<className.length()-1; i++) {
105             if (className.charAt(i) == '/') {
106                 //if the first char after the initial L is a '/', or if there are
107                 //two consecutive '/'
108                 if (i-elementStart==0) {
109                     throw new RuntimeException("Not a valid dalvik class name");
110                 }
111 
112                 packageElements[elementIndex++] = className.substring(elementStart, i);
113                 elementStart = ++i;
114             }
115         }
116 
117         //at this point, we have added all the package elements to packageElements, but still need to add
118         //the final class name. elementStart should point to the beginning of the class name
119 
120         //this will be true if the class ends in a '/', i.e. Lsome/package/className/;
121         if (elementStart >= className.length()-1) {
122             throw new RuntimeException("Not a valid dalvik class name");
123         }
124 
125         packageElements[elementIndex] = className.substring(elementStart, className.length()-1);
126 
127         return addUniqueChild(top, packageElements, 0);
128     }
129 
130     @Nonnull
addUniqueChild(@onnull DirectoryEntry parent, @Nonnull String[] packageElements, int packageElementIndex)131     private File addUniqueChild(@Nonnull DirectoryEntry parent, @Nonnull String[] packageElements,
132                                 int packageElementIndex) {
133         if (packageElementIndex == packageElements.length - 1) {
134             FileEntry fileEntry = new FileEntry(parent, packageElements[packageElementIndex] + fileExtension);
135             parent.addChild(fileEntry);
136 
137             String physicalName = fileEntry.getPhysicalName();
138 
139             // the physical name should be set when adding it as a child to the parent
140             assert  physicalName != null;
141 
142             return new File(parent.file, physicalName);
143         } else {
144             DirectoryEntry directoryEntry = new DirectoryEntry(parent, packageElements[packageElementIndex]);
145             directoryEntry = (DirectoryEntry)parent.addChild(directoryEntry);
146             return addUniqueChild(directoryEntry, packageElements, packageElementIndex+1);
147         }
148     }
149 
utf8Length(String str)150     private static int utf8Length(String str) {
151         int utf8Length = 0;
152         int i=0;
153         while (i<str.length()) {
154             int c = str.codePointAt(i);
155             utf8Length += utf8Length(c);
156             i += Character.charCount(c);
157         }
158         return utf8Length;
159     }
160 
utf8Length(int codePoint)161     private static int utf8Length(int codePoint) {
162         if (codePoint < 0x80) {
163             return 1;
164         } else if (codePoint < 0x800) {
165             return 2;
166         } else if (codePoint < 0x10000) {
167             return 3;
168         } else {
169             return 4;
170         }
171     }
172 
173     /**
174      * Shortens an individual file/directory name, removing the necessary number of code points
175      * from the middle of the string such that the utf-8 encoding of the string is at least
176      * bytesToRemove bytes shorter than the original.
177      *
178      * The removed codePoints in the middle of the string will be replaced with a # character.
179      */
180     @Nonnull
shortenPathComponent(@onnull String pathComponent, int bytesToRemove)181     static String shortenPathComponent(@Nonnull String pathComponent, int bytesToRemove) {
182         // We replace the removed part with a #, so we need to remove 1 extra char
183         bytesToRemove++;
184 
185         int[] codePoints;
186         try {
187             IntBuffer intBuffer = ByteBuffer.wrap(pathComponent.getBytes("UTF-32BE")).asIntBuffer();
188             codePoints = new int[intBuffer.limit()];
189             intBuffer.get(codePoints);
190         } catch (UnsupportedEncodingException ex) {
191             throw new RuntimeException(ex);
192         }
193 
194         int midPoint = codePoints.length/2;
195 
196         int firstEnd = midPoint; // exclusive
197         int secondStart = midPoint+1; // inclusive
198         int bytesRemoved = utf8Length(codePoints[midPoint]);
199 
200         // if we have an even number of codepoints, start by removing both middle characters,
201         // unless just removing the first already removes enough bytes
202         if (((codePoints.length % 2) == 0) && bytesRemoved < bytesToRemove) {
203             bytesRemoved += utf8Length(codePoints[secondStart]);
204             secondStart++;
205         }
206 
207         while ((bytesRemoved < bytesToRemove) &&
208                 (firstEnd > 0 || secondStart < codePoints.length)) {
209             if (firstEnd > 0) {
210                 firstEnd--;
211                 bytesRemoved += utf8Length(codePoints[firstEnd]);
212             }
213 
214             if (bytesRemoved < bytesToRemove && secondStart < codePoints.length) {
215                 bytesRemoved += utf8Length(codePoints[secondStart]);
216                 secondStart++;
217             }
218         }
219 
220         StringBuilder sb = new StringBuilder();
221         for (int i=0; i<firstEnd; i++) {
222             sb.appendCodePoint(codePoints[i]);
223         }
224         sb.append('#');
225         for (int i=secondStart; i<codePoints.length; i++) {
226             sb.appendCodePoint(codePoints[i]);
227         }
228 
229         return sb.toString();
230     }
231 
testForWindowsReservedFileNames(File path)232     private static boolean testForWindowsReservedFileNames(File path) {
233         String[] reservedNames = new String[]{"aux", "con", "com1", "com9", "lpt1", "com9"};
234 
235         for (String reservedName: reservedNames) {
236             File f = new File(path, reservedName + ".smali");
237             if (f.exists()) {
238                 continue;
239             }
240 
241             try {
242                 FileWriter writer = new FileWriter(f);
243                 writer.write("test");
244                 writer.flush();
245                 writer.close();
246                 f.delete(); //doesn't throw IOException
247             } catch (IOException ex) {
248                 //if an exception occurred, it's likely that we're on a windows system.
249                 return true;
250             }
251         }
252         return false;
253     }
254 
255     private static Pattern reservedFileNameRegex = Pattern.compile("^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\\..*)?$",
256             Pattern.CASE_INSENSITIVE);
isReservedFileName(String className)257     private static boolean isReservedFileName(String className) {
258         return reservedFileNameRegex.matcher(className).matches();
259     }
260 
261     private abstract class FileSystemEntry {
262         @Nullable public final DirectoryEntry parent;
263         @Nonnull public final String logicalName;
264         @Nullable protected String physicalName = null;
265 
FileSystemEntry(@ullable DirectoryEntry parent, @Nonnull String logicalName)266         private FileSystemEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) {
267             this.parent = parent;
268             this.logicalName = logicalName;
269         }
270 
getNormalizedName(boolean preserveCase)271         @Nonnull public String getNormalizedName(boolean preserveCase) {
272             String elementName = logicalName;
273             if (!preserveCase && parent != null && !parent.isCaseSensitive()) {
274                 elementName = elementName.toLowerCase();
275             }
276 
277             if (modifyWindowsReservedFilenames && isReservedFileName(elementName)) {
278                 elementName = addSuffixBeforeExtension(elementName, "#");
279             }
280 
281             int utf8Length = utf8Length(elementName);
282             if (utf8Length > getMaxFilenameLength()) {
283                 elementName = shortenPathComponent(elementName, utf8Length - getMaxFilenameLength());
284             }
285             return elementName;
286         }
287 
288         @Nullable
getPhysicalName()289         public String getPhysicalName() {
290             return physicalName;
291         }
292 
setSuffix(int suffix)293         public void setSuffix(int suffix) {
294             if (suffix < 0 || suffix > 99999) {
295                 throw new IllegalArgumentException("suffix must be in [0, 100000)");
296             }
297 
298             if (this.physicalName != null) {
299                 throw new IllegalStateException("The suffix can only be set once");
300             }
301             this.physicalName = makePhysicalName(suffix);
302         }
303 
makePhysicalName(int suffix)304         protected abstract String makePhysicalName(int suffix);
305     }
306 
307     private class DirectoryEntry extends FileSystemEntry {
308         @Nullable private File file = null;
309         private int caseSensitivity = forcedCaseSensitivity;
310 
311         // maps a normalized (but not suffixed) entry name to 1 or more FileSystemEntries.
312         // Each FileSystemEntry asociated with a normalized entry name must have a distinct
313         // physical name
314         private final Multimap<String, FileSystemEntry> children = ArrayListMultimap.create();
315 
DirectoryEntry(@onnull File path)316         public DirectoryEntry(@Nonnull File path) {
317             super(null, path.getName());
318             file = path;
319             physicalName = file.getName();
320         }
321 
DirectoryEntry(@ullable DirectoryEntry parent, @Nonnull String logicalName)322         public DirectoryEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) {
323             super(parent, logicalName);
324         }
325 
addChild(FileSystemEntry entry)326         public synchronized FileSystemEntry addChild(FileSystemEntry entry) {
327             String normalizedChildName = entry.getNormalizedName(false);
328             Collection<FileSystemEntry> entries = children.get(normalizedChildName);
329             if (entry instanceof DirectoryEntry) {
330                 for (FileSystemEntry childEntry: entries) {
331                     if (childEntry.logicalName.equals(entry.logicalName)) {
332                         return childEntry;
333                     }
334                 }
335             }
336             entry.setSuffix(entries.size());
337             entries.add(entry);
338             return entry;
339         }
340 
341         @Override
makePhysicalName(int suffix)342         protected String makePhysicalName(int suffix) {
343             if (suffix > 0) {
344                 return getNormalizedName(true) + "." + Integer.toString(suffix);
345             }
346             return getNormalizedName(true);
347         }
348 
349         @Override
setSuffix(int suffix)350         public void setSuffix(int suffix) {
351             super.setSuffix(suffix);
352             String physicalName = getPhysicalName();
353             if (parent != null && physicalName != null) {
354                 file = new File(parent.file, physicalName);
355             }
356         }
357 
isCaseSensitive()358         protected boolean isCaseSensitive() {
359             if (getPhysicalName() == null || file == null) {
360                 throw new IllegalStateException("Must call setSuffix() first");
361             }
362 
363             if (caseSensitivity != NO_VALUE) {
364                 return caseSensitivity == CASE_SENSITIVE;
365             }
366 
367             File path = file;
368             if (path.exists() && path.isFile()) {
369                 if (!path.delete()) {
370                     throw new ExceptionWithContext("Can't delete %s to make it into a directory",
371                             path.getAbsolutePath());
372                 }
373             }
374 
375             if (!path.exists() && !path.mkdirs()) {
376                 throw new ExceptionWithContext("Couldn't create directory %s", path.getAbsolutePath());
377             }
378 
379             try {
380                 boolean result = testCaseSensitivity(path);
381                 caseSensitivity = result?CASE_SENSITIVE:CASE_INSENSITIVE;
382                 return result;
383             } catch (IOException ex) {
384                 return false;
385             }
386         }
387 
testCaseSensitivity(File path)388         private boolean testCaseSensitivity(File path) throws IOException {
389             int num = 1;
390             File f, f2;
391             do {
392                 f = new File(path, "test." + num);
393                 f2 = new File(path, "TEST." + num++);
394             } while(f.exists() || f2.exists());
395 
396             try {
397                 try {
398                     FileWriter writer = new FileWriter(f);
399                     writer.write("test");
400                     writer.flush();
401                     writer.close();
402                 } catch (IOException ex) {
403                     try {f.delete();} catch (Exception ex2) {}
404                     throw ex;
405                 }
406 
407                 if (f2.exists()) {
408                     return false;
409                 }
410 
411                 if (f2.createNewFile()) {
412                     return true;
413                 }
414 
415                 //the above 2 tests should catch almost all cases. But maybe there was a failure while creating f2
416                 //that isn't related to case sensitivity. Let's see if we can open the file we just created using
417                 //f2
418                 try {
419                     CharBuffer buf = CharBuffer.allocate(32);
420                     FileReader reader = new FileReader(f2);
421 
422                     while (reader.read(buf) != -1 && buf.length() < 4);
423                     if (buf.length() == 4 && buf.toString().equals("test")) {
424                         return false;
425                     } else {
426                         //we probably shouldn't get here. If the filesystem was case-sensetive, creating a new
427                         //FileReader should have thrown a FileNotFoundException. Otherwise, we should have opened
428                         //the file and read in the string "test". It's remotely possible that someone else modified
429                         //the file after we created it. Let's be safe and return false here as well
430                         assert(false);
431                         return false;
432                     }
433                 } catch (FileNotFoundException ex) {
434                     return true;
435                 }
436             } finally {
437                 try { f.delete(); } catch (Exception ex) {}
438                 try { f2.delete(); } catch (Exception ex) {}
439             }
440         }
441     }
442 
443     private class FileEntry extends FileSystemEntry {
FileEntry(@ullable DirectoryEntry parent, @Nonnull String logicalName)444         private FileEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) {
445             super(parent, logicalName);
446         }
447 
448         @Override
makePhysicalName(int suffix)449         protected String makePhysicalName(int suffix) {
450             if (suffix > 0) {
451                 return addSuffixBeforeExtension(getNormalizedName(true), '.' + Integer.toString(suffix));
452             }
453             return getNormalizedName(true);
454         }
455     }
456 
addSuffixBeforeExtension(String pathElement, String suffix)457     private static String addSuffixBeforeExtension(String pathElement, String suffix) {
458         int extensionStart = pathElement.lastIndexOf('.');
459 
460         StringBuilder newName = new StringBuilder(pathElement.length() + suffix.length() + 1);
461         if (extensionStart < 0) {
462             newName.append(pathElement);
463             newName.append(suffix);
464         } else {
465             newName.append(pathElement.subSequence(0, extensionStart));
466             newName.append(suffix);
467             newName.append(pathElement.subSequence(extensionStart, pathElement.length()));
468         }
469         return newName.toString();
470     }
471 }
472