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 ds.tree.RadixTree;
32import ds.tree.RadixTreeImpl;
33
34import javax.annotation.Nonnull;
35import java.io.*;
36import java.nio.CharBuffer;
37import java.util.regex.Pattern;
38
39/**
40 * This class checks for case-insensitive file systems, and generates file names based on a given class name, that are
41 * guaranteed to be unique. When "colliding" class names are found, it appends a numeric identifier to the end of the
42 * class name to distinguish it from another class with a name that differes only by case. i.e. a.smali and a_2.smali
43 */
44public class ClassFileNameHandler {
45    // we leave an extra 10 characters to allow for a numeric suffix to be added, if it's needed
46    private static final int MAX_FILENAME_LENGTH = 245;
47
48    private PackageNameEntry top;
49    private String fileExtension;
50    private boolean modifyWindowsReservedFilenames;
51
52    public ClassFileNameHandler(File path, String fileExtension) {
53        this.top = new PackageNameEntry(path);
54        this.fileExtension = fileExtension;
55        this.modifyWindowsReservedFilenames = testForWindowsReservedFileNames(path);
56    }
57
58    public File getUniqueFilenameForClass(String className) {
59        //class names should be passed in the normal dalvik style, with a leading L, a trailing ;, and using
60        //'/' as a separator.
61        if (className.charAt(0) != 'L' || className.charAt(className.length()-1) != ';') {
62            throw new RuntimeException("Not a valid dalvik class name");
63        }
64
65        int packageElementCount = 1;
66        for (int i=1; i<className.length()-1; i++) {
67            if (className.charAt(i) == '/') {
68                packageElementCount++;
69            }
70        }
71
72        String packageElement;
73        String[] packageElements = new String[packageElementCount];
74        int elementIndex = 0;
75        int elementStart = 1;
76        for (int i=1; i<className.length()-1; i++) {
77            if (className.charAt(i) == '/') {
78                //if the first char after the initial L is a '/', or if there are
79                //two consecutive '/'
80                if (i-elementStart==0) {
81                    throw new RuntimeException("Not a valid dalvik class name");
82                }
83
84                packageElement = className.substring(elementStart, i);
85
86                if (modifyWindowsReservedFilenames && isReservedFileName(packageElement)) {
87                    packageElement += "#";
88                }
89
90                if (packageElement.length() > MAX_FILENAME_LENGTH) {
91                    packageElement = shortenPathComponent(packageElement, MAX_FILENAME_LENGTH);
92                }
93
94                packageElements[elementIndex++] = packageElement;
95                elementStart = ++i;
96            }
97        }
98
99        //at this point, we have added all the package elements to packageElements, but still need to add
100        //the final class name. elementStart should point to the beginning of the class name
101
102        //this will be true if the class ends in a '/', i.e. Lsome/package/className/;
103        if (elementStart >= className.length()-1) {
104            throw new RuntimeException("Not a valid dalvik class name");
105        }
106
107        packageElement = className.substring(elementStart, className.length()-1);
108        if (modifyWindowsReservedFilenames && isReservedFileName(packageElement)) {
109            packageElement += "#";
110        }
111
112        if ((packageElement.length() + fileExtension.length()) > MAX_FILENAME_LENGTH) {
113            packageElement = shortenPathComponent(packageElement, MAX_FILENAME_LENGTH - fileExtension.length());
114        }
115
116        packageElements[elementIndex] = packageElement;
117
118        return top.addUniqueChild(packageElements, 0);
119    }
120
121    @Nonnull
122    static String shortenPathComponent(@Nonnull String pathComponent, int maxLength) {
123        int toRemove = pathComponent.length() - maxLength + 1;
124
125        int firstIndex = (pathComponent.length()/2) - (toRemove/2);
126        return pathComponent.substring(0, firstIndex) + "#" + pathComponent.substring(firstIndex+toRemove);
127    }
128
129    private static boolean testForWindowsReservedFileNames(File path) {
130        String[] reservedNames = new String[]{"aux", "con", "com1", "com9", "lpt1", "com9"};
131
132        for (String reservedName: reservedNames) {
133            File f = new File(path, reservedName + ".smali");
134            if (f.exists()) {
135                continue;
136            }
137
138            try {
139                FileWriter writer = new FileWriter(f);
140                writer.write("test");
141                writer.flush();
142                writer.close();
143                f.delete(); //doesn't throw IOException
144            } catch (IOException ex) {
145                //if an exception occurred, it's likely that we're on a windows system.
146                return true;
147            }
148        }
149        return false;
150    }
151
152    private static Pattern reservedFileNameRegex = Pattern.compile("^CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]$",
153            Pattern.CASE_INSENSITIVE);
154    private static boolean isReservedFileName(String className) {
155        return reservedFileNameRegex.matcher(className).matches();
156    }
157
158    private abstract class FileSystemEntry {
159        public final File file;
160
161        public FileSystemEntry(File file) {
162            this.file = file;
163        }
164
165        public abstract File addUniqueChild(String[] pathElements, int pathElementsIndex);
166
167        public FileSystemEntry makeVirtual(File parent) {
168            return new VirtualGroupEntry(this, parent);
169        }
170    }
171
172    private class PackageNameEntry extends FileSystemEntry {
173        //this contains the FileSystemEntries for all of this package's children
174        //the associated keys are all lowercase
175        private RadixTree<FileSystemEntry> children = new RadixTreeImpl<FileSystemEntry>();
176
177        public PackageNameEntry(File parent, String name) {
178            super(new File(parent, name));
179        }
180
181        public PackageNameEntry(File path) {
182            super(path);
183        }
184
185        @Override
186        public synchronized File addUniqueChild(String[] pathElements, int pathElementsIndex) {
187            String elementName;
188            String elementNameLower;
189
190            if (pathElementsIndex == pathElements.length - 1) {
191                elementName = pathElements[pathElementsIndex];
192                elementName += fileExtension;
193            } else {
194                elementName = pathElements[pathElementsIndex];
195            }
196            elementNameLower = elementName.toLowerCase();
197
198            FileSystemEntry existingEntry = children.find(elementNameLower);
199            if (existingEntry != null) {
200                FileSystemEntry virtualEntry = existingEntry;
201                //if there is already another entry with the same name but different case, we need to
202                //add a virtual group, and then add the existing entry and the new entry to that group
203                if (!(existingEntry instanceof VirtualGroupEntry)) {
204                    if (existingEntry.file.getName().equals(elementName)) {
205                        if (pathElementsIndex == pathElements.length - 1) {
206                            return existingEntry.file;
207                        } else {
208                            return existingEntry.addUniqueChild(pathElements, pathElementsIndex + 1);
209                        }
210                    } else {
211                        virtualEntry = existingEntry.makeVirtual(file);
212                        children.replace(elementNameLower, virtualEntry);
213                    }
214                }
215
216                return virtualEntry.addUniqueChild(pathElements, pathElementsIndex);
217            }
218
219            if (pathElementsIndex == pathElements.length - 1) {
220                ClassNameEntry classNameEntry = new ClassNameEntry(file, elementName);
221                children.insert(elementNameLower, classNameEntry);
222                return classNameEntry.file;
223            } else {
224                PackageNameEntry packageNameEntry = new PackageNameEntry(file, elementName);
225                children.insert(elementNameLower, packageNameEntry);
226                return packageNameEntry.addUniqueChild(pathElements, pathElementsIndex + 1);
227            }
228        }
229    }
230
231    /**
232     * A virtual group that groups together file system entries with the same name, differing only in case
233     */
234    private class VirtualGroupEntry extends FileSystemEntry {
235        //this contains the FileSystemEntries for all of the files/directories in this group
236        //the key is the unmodified name of the entry, before it is modified to be made unique (if needed).
237        private RadixTree<FileSystemEntry> groupEntries = new RadixTreeImpl<FileSystemEntry>();
238
239        //whether the containing directory is case sensitive or not.
240        //-1 = unset
241        //0 = false;
242        //1 = true;
243        private int isCaseSensitive = -1;
244
245        public VirtualGroupEntry(FileSystemEntry firstChild, File parent) {
246            super(parent);
247
248            //use the name of the first child in the group as-is
249            groupEntries.insert(firstChild.file.getName(), firstChild);
250        }
251
252        @Override
253        public File addUniqueChild(String[] pathElements, int pathElementsIndex) {
254            String elementName = pathElements[pathElementsIndex];
255
256            if (pathElementsIndex == pathElements.length - 1) {
257                elementName = elementName + fileExtension;
258            }
259
260            FileSystemEntry existingEntry = groupEntries.find(elementName);
261            if (existingEntry != null) {
262                if (pathElementsIndex == pathElements.length - 1) {
263                    return existingEntry.file;
264                } else {
265                    return existingEntry.addUniqueChild(pathElements, pathElementsIndex+1);
266                }
267            }
268
269            if (pathElementsIndex == pathElements.length - 1) {
270                String fileName;
271                if (!isCaseSensitive()) {
272                    fileName = pathElements[pathElementsIndex] + "." + (groupEntries.getSize()+1) + fileExtension;
273                } else {
274                    fileName = elementName;
275                }
276
277                ClassNameEntry classNameEntry = new ClassNameEntry(file, fileName);
278                groupEntries.insert(elementName, classNameEntry);
279                return classNameEntry.file;
280            } else {
281                String fileName;
282                if (!isCaseSensitive()) {
283                    fileName = pathElements[pathElementsIndex] + "." + (groupEntries.getSize()+1);
284                } else {
285                    fileName = elementName;
286                }
287
288                PackageNameEntry packageNameEntry = new PackageNameEntry(file, fileName);
289                groupEntries.insert(elementName, packageNameEntry);
290                return packageNameEntry.addUniqueChild(pathElements, pathElementsIndex + 1);
291            }
292        }
293
294        private boolean isCaseSensitive() {
295            if (isCaseSensitive != -1) {
296                return isCaseSensitive == 1;
297            }
298
299            File path = file;
300
301            if (path.exists() && path.isFile()) {
302                path = path.getParentFile();
303            }
304
305            if ((!file.exists() && !file.mkdirs())) {
306                return false;
307            }
308
309            try {
310                boolean result = testCaseSensitivity(path);
311                isCaseSensitive = result?1:0;
312                return result;
313            } catch (IOException ex) {
314                return false;
315            }
316        }
317
318        private boolean testCaseSensitivity(File path) throws IOException {
319            int num = 1;
320            File f, f2;
321            do {
322                f = new File(path, "test." + num);
323                f2 = new File(path, "TEST." + num++);
324            } while(f.exists() || f2.exists());
325
326            try {
327                try {
328                    FileWriter writer = new FileWriter(f);
329                    writer.write("test");
330                    writer.flush();
331                    writer.close();
332                } catch (IOException ex) {
333                    try {f.delete();} catch (Exception ex2) {}
334                    throw ex;
335                }
336
337                if (f2.exists()) {
338                    return false;
339                }
340
341                if (f2.createNewFile()) {
342                    return true;
343                }
344
345                //the above 2 tests should catch almost all cases. But maybe there was a failure while creating f2
346                //that isn't related to case sensitivity. Let's see if we can open the file we just created using
347                //f2
348                try {
349                    CharBuffer buf = CharBuffer.allocate(32);
350                    FileReader reader = new FileReader(f2);
351
352                    while (reader.read(buf) != -1 && buf.length() < 4);
353                    if (buf.length() == 4 && buf.toString().equals("test")) {
354                        return false;
355                    } else {
356                        //we probably shouldn't get here. If the filesystem was case-sensetive, creating a new
357                        //FileReader should have thrown a FileNotFoundException. Otherwise, we should have opened
358                        //the file and read in the string "test". It's remotely possible that someone else modified
359                        //the file after we created it. Let's be safe and return false here as well
360                        assert(false);
361                        return false;
362                    }
363                } catch (FileNotFoundException ex) {
364                    return true;
365                }
366            } finally {
367                try { f.delete(); } catch (Exception ex) {}
368                try { f2.delete(); } catch (Exception ex) {}
369            }
370        }
371
372        @Override
373        public FileSystemEntry makeVirtual(File parent) {
374            return this;
375        }
376    }
377
378    private class ClassNameEntry extends FileSystemEntry {
379        public ClassNameEntry(File parent, String name) {
380            super(new File(parent, name));
381        }
382
383        @Override
384        public File addUniqueChild(String[] pathElements, int pathElementsIndex) {
385            assert false;
386            return file;
387        }
388    }
389}
390