/* * [The "BSD licence"] * Copyright (c) 2010 Ben Gruver * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * 3. The name of the author may not be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package org.jf.util; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.*; import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.nio.IntBuffer; import java.util.Collection; import java.util.regex.Pattern; /** * This class handles the complexities of translating a class name into a file name. i.e. dealing with case insensitive * file systems, windows reserved filenames, class names with extremely long package/class elements, etc. * * The types of transformations this class does include: * - append a '#123' style numeric suffix if 2 physical representations collide * - replace some number of characters in the middle with a '#' character name if an individual path element is too long * - append a '#' if an individual path element would otherwise be considered a reserved filename */ public class ClassFileNameHandler { private static final int MAX_FILENAME_LENGTH = 255; // How many characters to reserve in the physical filename for numeric suffixes // Dex files can currently only have 64k classes, so 5 digits plus 1 for an '#' should // be sufficient to handle the case when every class has a conflicting name private static final int NUMERIC_SUFFIX_RESERVE = 6; private final int NO_VALUE = -1; private final int CASE_INSENSITIVE = 0; private final int CASE_SENSITIVE = 1; private int forcedCaseSensitivity = NO_VALUE; private DirectoryEntry top; private String fileExtension; private boolean modifyWindowsReservedFilenames; public ClassFileNameHandler(File path, String fileExtension) { this.top = new DirectoryEntry(path); this.fileExtension = fileExtension; this.modifyWindowsReservedFilenames = isWindows(); } // for testing public ClassFileNameHandler(File path, String fileExtension, boolean caseSensitive, boolean modifyWindowsReservedFilenames) { this.top = new DirectoryEntry(path); this.fileExtension = fileExtension; this.forcedCaseSensitivity = caseSensitive?CASE_SENSITIVE:CASE_INSENSITIVE; this.modifyWindowsReservedFilenames = modifyWindowsReservedFilenames; } private int getMaxFilenameLength() { return MAX_FILENAME_LENGTH - NUMERIC_SUFFIX_RESERVE; } public File getUniqueFilenameForClass(String className) { //class names should be passed in the normal dalvik style, with a leading L, a trailing ;, and using //'/' as a separator. if (className.charAt(0) != 'L' || className.charAt(className.length()-1) != ';') { throw new RuntimeException("Not a valid dalvik class name"); } int packageElementCount = 1; for (int i=1; i<className.length()-1; i++) { if (className.charAt(i) == '/') { packageElementCount++; } } String[] packageElements = new String[packageElementCount]; int elementIndex = 0; int elementStart = 1; for (int i=1; i<className.length()-1; i++) { if (className.charAt(i) == '/') { //if the first char after the initial L is a '/', or if there are //two consecutive '/' if (i-elementStart==0) { throw new RuntimeException("Not a valid dalvik class name"); } packageElements[elementIndex++] = className.substring(elementStart, i); elementStart = ++i; } } //at this point, we have added all the package elements to packageElements, but still need to add //the final class name. elementStart should point to the beginning of the class name //this will be true if the class ends in a '/', i.e. Lsome/package/className/; if (elementStart >= className.length()-1) { throw new RuntimeException("Not a valid dalvik class name"); } packageElements[elementIndex] = className.substring(elementStart, className.length()-1); return addUniqueChild(top, packageElements, 0); } @Nonnull private File addUniqueChild(@Nonnull DirectoryEntry parent, @Nonnull String[] packageElements, int packageElementIndex) { if (packageElementIndex == packageElements.length - 1) { FileEntry fileEntry = new FileEntry(parent, packageElements[packageElementIndex] + fileExtension); parent.addChild(fileEntry); String physicalName = fileEntry.getPhysicalName(); // the physical name should be set when adding it as a child to the parent assert physicalName != null; return new File(parent.file, physicalName); } else { DirectoryEntry directoryEntry = new DirectoryEntry(parent, packageElements[packageElementIndex]); directoryEntry = (DirectoryEntry)parent.addChild(directoryEntry); return addUniqueChild(directoryEntry, packageElements, packageElementIndex+1); } } private static int utf8Length(String str) { int utf8Length = 0; int i=0; while (i<str.length()) { int c = str.codePointAt(i); utf8Length += utf8Length(c); i += Character.charCount(c); } return utf8Length; } private static int utf8Length(int codePoint) { if (codePoint < 0x80) { return 1; } else if (codePoint < 0x800) { return 2; } else if (codePoint < 0x10000) { return 3; } else { return 4; } } /** * Shortens an individual file/directory name, removing the necessary number of code points * from the middle of the string such that the utf-8 encoding of the string is at least * bytesToRemove bytes shorter than the original. * * The removed codePoints in the middle of the string will be replaced with a # character. */ @Nonnull static String shortenPathComponent(@Nonnull String pathComponent, int bytesToRemove) { // We replace the removed part with a #, so we need to remove 1 extra char bytesToRemove++; int[] codePoints; try { IntBuffer intBuffer = ByteBuffer.wrap(pathComponent.getBytes("UTF-32BE")).asIntBuffer(); codePoints = new int[intBuffer.limit()]; intBuffer.get(codePoints); } catch (UnsupportedEncodingException ex) { throw new RuntimeException(ex); } int midPoint = codePoints.length/2; int firstEnd = midPoint; // exclusive int secondStart = midPoint+1; // inclusive int bytesRemoved = utf8Length(codePoints[midPoint]); // if we have an even number of codepoints, start by removing both middle characters, // unless just removing the first already removes enough bytes if (((codePoints.length % 2) == 0) && bytesRemoved < bytesToRemove) { bytesRemoved += utf8Length(codePoints[secondStart]); secondStart++; } while ((bytesRemoved < bytesToRemove) && (firstEnd > 0 || secondStart < codePoints.length)) { if (firstEnd > 0) { firstEnd--; bytesRemoved += utf8Length(codePoints[firstEnd]); } if (bytesRemoved < bytesToRemove && secondStart < codePoints.length) { bytesRemoved += utf8Length(codePoints[secondStart]); secondStart++; } } StringBuilder sb = new StringBuilder(); for (int i=0; i<firstEnd; i++) { sb.appendCodePoint(codePoints[i]); } sb.append('#'); for (int i=secondStart; i<codePoints.length; i++) { sb.appendCodePoint(codePoints[i]); } return sb.toString(); } private static boolean isWindows() { return System.getProperty("os.name").startsWith("Windows"); } private static Pattern reservedFileNameRegex = Pattern.compile("^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\\..*)?$", Pattern.CASE_INSENSITIVE); private static boolean isReservedFileName(String className) { return reservedFileNameRegex.matcher(className).matches(); } private abstract class FileSystemEntry { @Nullable public final DirectoryEntry parent; @Nonnull public final String logicalName; @Nullable protected String physicalName = null; private FileSystemEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) { this.parent = parent; this.logicalName = logicalName; } @Nonnull public String getNormalizedName(boolean preserveCase) { String elementName = logicalName; if (!preserveCase && parent != null && !parent.isCaseSensitive()) { elementName = elementName.toLowerCase(); } if (modifyWindowsReservedFilenames && isReservedFileName(elementName)) { elementName = addSuffixBeforeExtension(elementName, "#"); } int utf8Length = utf8Length(elementName); if (utf8Length > getMaxFilenameLength()) { elementName = shortenPathComponent(elementName, utf8Length - getMaxFilenameLength()); } return elementName; } @Nullable public String getPhysicalName() { return physicalName; } public void setSuffix(int suffix) { if (suffix < 0 || suffix > 99999) { throw new IllegalArgumentException("suffix must be in [0, 100000)"); } if (this.physicalName != null) { throw new IllegalStateException("The suffix can only be set once"); } this.physicalName = makePhysicalName(suffix); } protected abstract String makePhysicalName(int suffix); } private class DirectoryEntry extends FileSystemEntry { @Nullable private File file = null; private int caseSensitivity = forcedCaseSensitivity; // maps a normalized (but not suffixed) entry name to 1 or more FileSystemEntries. // Each FileSystemEntry asociated with a normalized entry name must have a distinct // physical name private final Multimap<String, FileSystemEntry> children = ArrayListMultimap.create(); public DirectoryEntry(@Nonnull File path) { super(null, path.getName()); file = path; physicalName = file.getName(); } public DirectoryEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) { super(parent, logicalName); } public synchronized FileSystemEntry addChild(FileSystemEntry entry) { String normalizedChildName = entry.getNormalizedName(false); Collection<FileSystemEntry> entries = children.get(normalizedChildName); if (entry instanceof DirectoryEntry) { for (FileSystemEntry childEntry: entries) { if (childEntry.logicalName.equals(entry.logicalName)) { return childEntry; } } } entry.setSuffix(entries.size()); entries.add(entry); return entry; } @Override protected String makePhysicalName(int suffix) { if (suffix > 0) { return getNormalizedName(true) + "." + Integer.toString(suffix); } return getNormalizedName(true); } @Override public void setSuffix(int suffix) { super.setSuffix(suffix); String physicalName = getPhysicalName(); if (parent != null && physicalName != null) { file = new File(parent.file, physicalName); } } protected boolean isCaseSensitive() { if (getPhysicalName() == null || file == null) { throw new IllegalStateException("Must call setSuffix() first"); } if (caseSensitivity != NO_VALUE) { return caseSensitivity == CASE_SENSITIVE; } File path = file; if (path.exists() && path.isFile()) { if (!path.delete()) { throw new ExceptionWithContext("Can't delete %s to make it into a directory", path.getAbsolutePath()); } } if (!path.exists() && !path.mkdirs()) { throw new ExceptionWithContext("Couldn't create directory %s", path.getAbsolutePath()); } try { boolean result = testCaseSensitivity(path); caseSensitivity = result?CASE_SENSITIVE:CASE_INSENSITIVE; return result; } catch (IOException ex) { return false; } } private boolean testCaseSensitivity(File path) throws IOException { int num = 1; File f, f2; do { f = new File(path, "test." + num); f2 = new File(path, "TEST." + num++); } while(f.exists() || f2.exists()); try { try { FileWriter writer = new FileWriter(f); writer.write("test"); writer.flush(); writer.close(); } catch (IOException ex) { try {f.delete();} catch (Exception ex2) {} throw ex; } if (f2.exists()) { return false; } if (f2.createNewFile()) { return true; } //the above 2 tests should catch almost all cases. But maybe there was a failure while creating f2 //that isn't related to case sensitivity. Let's see if we can open the file we just created using //f2 try { CharBuffer buf = CharBuffer.allocate(32); FileReader reader = new FileReader(f2); while (reader.read(buf) != -1 && buf.length() < 4); if (buf.length() == 4 && buf.toString().equals("test")) { return false; } else { //we probably shouldn't get here. If the filesystem was case-sensetive, creating a new //FileReader should have thrown a FileNotFoundException. Otherwise, we should have opened //the file and read in the string "test". It's remotely possible that someone else modified //the file after we created it. Let's be safe and return false here as well assert(false); return false; } } catch (FileNotFoundException ex) { return true; } } finally { try { f.delete(); } catch (Exception ex) {} try { f2.delete(); } catch (Exception ex) {} } } } private class FileEntry extends FileSystemEntry { private FileEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) { super(parent, logicalName); } @Override protected String makePhysicalName(int suffix) { if (suffix > 0) { return addSuffixBeforeExtension(getNormalizedName(true), '.' + Integer.toString(suffix)); } return getNormalizedName(true); } } private static String addSuffixBeforeExtension(String pathElement, String suffix) { int extensionStart = pathElement.lastIndexOf('.'); StringBuilder newName = new StringBuilder(pathElement.length() + suffix.length() + 1); if (extensionStart < 0) { newName.append(pathElement); newName.append(suffix); } else { newName.append(pathElement.subSequence(0, extensionStart)); newName.append(suffix); newName.append(pathElement.subSequence(extensionStart, pathElement.length())); } return newName.toString(); } }