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