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