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
29package org.jf.util;
30
31import com.google.common.collect.ArrayListMultimap;
32import com.google.common.collect.Multimap;
33
34import javax.annotation.Nonnull;
35import javax.annotation.Nullable;
36import java.io.*;
37import java.nio.ByteBuffer;
38import java.nio.CharBuffer;
39import java.nio.IntBuffer;
40import java.util.Collection;
41import 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 */
52public 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
68    public ClassFileNameHandler(File path, String fileExtension) {
69        this.top = new DirectoryEntry(path);
70        this.fileExtension = fileExtension;
71        this.modifyWindowsReservedFilenames = isWindows();
72    }
73
74    // for testing
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
83    private int getMaxFilenameLength() {
84        return MAX_FILENAME_LENGTH - NUMERIC_SUFFIX_RESERVE;
85    }
86
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
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
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
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
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
232    private static boolean isWindows() {
233        return System.getProperty("os.name").startsWith("Windows");
234    }
235
236    private static Pattern reservedFileNameRegex = Pattern.compile("^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\\..*)?$",
237            Pattern.CASE_INSENSITIVE);
238    private static boolean isReservedFileName(String className) {
239        return reservedFileNameRegex.matcher(className).matches();
240    }
241
242    private abstract class FileSystemEntry {
243        @Nullable public final DirectoryEntry parent;
244        @Nonnull public final String logicalName;
245        @Nullable protected String physicalName = null;
246
247        private FileSystemEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) {
248            this.parent = parent;
249            this.logicalName = logicalName;
250        }
251
252        @Nonnull public String getNormalizedName(boolean preserveCase) {
253            String elementName = logicalName;
254            if (!preserveCase && parent != null && !parent.isCaseSensitive()) {
255                elementName = elementName.toLowerCase();
256            }
257
258            if (modifyWindowsReservedFilenames && isReservedFileName(elementName)) {
259                elementName = addSuffixBeforeExtension(elementName, "#");
260            }
261
262            int utf8Length = utf8Length(elementName);
263            if (utf8Length > getMaxFilenameLength()) {
264                elementName = shortenPathComponent(elementName, utf8Length - getMaxFilenameLength());
265            }
266            return elementName;
267        }
268
269        @Nullable
270        public String getPhysicalName() {
271            return physicalName;
272        }
273
274        public void setSuffix(int suffix) {
275            if (suffix < 0 || suffix > 99999) {
276                throw new IllegalArgumentException("suffix must be in [0, 100000)");
277            }
278
279            if (this.physicalName != null) {
280                throw new IllegalStateException("The suffix can only be set once");
281            }
282            this.physicalName = makePhysicalName(suffix);
283        }
284
285        protected abstract String makePhysicalName(int suffix);
286    }
287
288    private class DirectoryEntry extends FileSystemEntry {
289        @Nullable private File file = null;
290        private int caseSensitivity = forcedCaseSensitivity;
291
292        // maps a normalized (but not suffixed) entry name to 1 or more FileSystemEntries.
293        // Each FileSystemEntry asociated with a normalized entry name must have a distinct
294        // physical name
295        private final Multimap<String, FileSystemEntry> children = ArrayListMultimap.create();
296
297        public DirectoryEntry(@Nonnull File path) {
298            super(null, path.getName());
299            file = path;
300            physicalName = file.getName();
301        }
302
303        public DirectoryEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) {
304            super(parent, logicalName);
305        }
306
307        public synchronized FileSystemEntry addChild(FileSystemEntry entry) {
308            String normalizedChildName = entry.getNormalizedName(false);
309            Collection<FileSystemEntry> entries = children.get(normalizedChildName);
310            if (entry instanceof DirectoryEntry) {
311                for (FileSystemEntry childEntry: entries) {
312                    if (childEntry.logicalName.equals(entry.logicalName)) {
313                        return childEntry;
314                    }
315                }
316            }
317            entry.setSuffix(entries.size());
318            entries.add(entry);
319            return entry;
320        }
321
322        @Override
323        protected String makePhysicalName(int suffix) {
324            if (suffix > 0) {
325                return getNormalizedName(true) + "." + Integer.toString(suffix);
326            }
327            return getNormalizedName(true);
328        }
329
330        @Override
331        public void setSuffix(int suffix) {
332            super.setSuffix(suffix);
333            String physicalName = getPhysicalName();
334            if (parent != null && physicalName != null) {
335                file = new File(parent.file, physicalName);
336            }
337        }
338
339        protected boolean isCaseSensitive() {
340            if (getPhysicalName() == null || file == null) {
341                throw new IllegalStateException("Must call setSuffix() first");
342            }
343
344            if (caseSensitivity != NO_VALUE) {
345                return caseSensitivity == CASE_SENSITIVE;
346            }
347
348            File path = file;
349            if (path.exists() && path.isFile()) {
350                if (!path.delete()) {
351                    throw new ExceptionWithContext("Can't delete %s to make it into a directory",
352                            path.getAbsolutePath());
353                }
354            }
355
356            if (!path.exists() && !path.mkdirs()) {
357                throw new ExceptionWithContext("Couldn't create directory %s", path.getAbsolutePath());
358            }
359
360            try {
361                boolean result = testCaseSensitivity(path);
362                caseSensitivity = result?CASE_SENSITIVE:CASE_INSENSITIVE;
363                return result;
364            } catch (IOException ex) {
365                return false;
366            }
367        }
368
369        private boolean testCaseSensitivity(File path) throws IOException {
370            int num = 1;
371            File f, f2;
372            do {
373                f = new File(path, "test." + num);
374                f2 = new File(path, "TEST." + num++);
375            } while(f.exists() || f2.exists());
376
377            try {
378                try {
379                    FileWriter writer = new FileWriter(f);
380                    writer.write("test");
381                    writer.flush();
382                    writer.close();
383                } catch (IOException ex) {
384                    try {f.delete();} catch (Exception ex2) {}
385                    throw ex;
386                }
387
388                if (f2.exists()) {
389                    return false;
390                }
391
392                if (f2.createNewFile()) {
393                    return true;
394                }
395
396                //the above 2 tests should catch almost all cases. But maybe there was a failure while creating f2
397                //that isn't related to case sensitivity. Let's see if we can open the file we just created using
398                //f2
399                try {
400                    CharBuffer buf = CharBuffer.allocate(32);
401                    FileReader reader = new FileReader(f2);
402
403                    while (reader.read(buf) != -1 && buf.length() < 4);
404                    if (buf.length() == 4 && buf.toString().equals("test")) {
405                        return false;
406                    } else {
407                        //we probably shouldn't get here. If the filesystem was case-sensetive, creating a new
408                        //FileReader should have thrown a FileNotFoundException. Otherwise, we should have opened
409                        //the file and read in the string "test". It's remotely possible that someone else modified
410                        //the file after we created it. Let's be safe and return false here as well
411                        assert(false);
412                        return false;
413                    }
414                } catch (FileNotFoundException ex) {
415                    return true;
416                }
417            } finally {
418                try { f.delete(); } catch (Exception ex) {}
419                try { f2.delete(); } catch (Exception ex) {}
420            }
421        }
422    }
423
424    private class FileEntry extends FileSystemEntry {
425        private FileEntry(@Nullable DirectoryEntry parent, @Nonnull String logicalName) {
426            super(parent, logicalName);
427        }
428
429        @Override
430        protected String makePhysicalName(int suffix) {
431            if (suffix > 0) {
432                return addSuffixBeforeExtension(getNormalizedName(true), '.' + Integer.toString(suffix));
433            }
434            return getNormalizedName(true);
435        }
436    }
437
438    private static String addSuffixBeforeExtension(String pathElement, String suffix) {
439        int extensionStart = pathElement.lastIndexOf('.');
440
441        StringBuilder newName = new StringBuilder(pathElement.length() + suffix.length() + 1);
442        if (extensionStart < 0) {
443            newName.append(pathElement);
444            newName.append(suffix);
445        } else {
446            newName.append(pathElement.subSequence(0, extensionStart));
447            newName.append(suffix);
448            newName.append(pathElement.subSequence(extensionStart, pathElement.length()));
449        }
450        return newName.toString();
451    }
452}
453