Always check against canonical paths.
[android/platform/packages/providers/DownloadProvider.git] / src / com / android / providers / downloads / Helpers.java
1 /*
2  * Copyright (C) 2008 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17 package com.android.providers.downloads;
18
19 import static com.android.providers.downloads.Constants.TAG;
20
21 import android.content.Context;
22 import android.net.Uri;
23 import android.os.Environment;
24 import android.os.SystemClock;
25 import android.provider.Downloads;
26 import android.util.Log;
27 import android.webkit.MimeTypeMap;
28
29 import java.io.File;
30 import java.io.IOException;
31 import java.util.Random;
32 import java.util.Set;
33 import java.util.regex.Matcher;
34 import java.util.regex.Pattern;
35
36 /**
37  * Some helper functions for the download manager
38  */
39 public class Helpers {
40     public static Random sRandom = new Random(SystemClock.uptimeMillis());
41
42     /** Regex used to parse content-disposition headers */
43     private static final Pattern CONTENT_DISPOSITION_PATTERN =
44             Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\"");
45
46     private static final Object sUniqueLock = new Object();
47
48     private Helpers() {
49     }
50
51     /*
52      * Parse the Content-Disposition HTTP Header. The format of the header
53      * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html
54      * This header provides a filename for content that is going to be
55      * downloaded to the file system. We only support the attachment type.
56      */
57     private static String parseContentDisposition(String contentDisposition) {
58         try {
59             Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
60             if (m.find()) {
61                 return m.group(1);
62             }
63         } catch (IllegalStateException ex) {
64              // This function is defined as returning null when it can't parse the header
65         }
66         return null;
67     }
68
69     /**
70      * Creates a filename (where the file should be saved) from info about a download.
71      */
72     static String generateSaveFile(
73             Context context,
74             String url,
75             String hint,
76             String contentDisposition,
77             String contentLocation,
78             String mimeType,
79             int destination,
80             long contentLength,
81             StorageManager storageManager) throws StopRequestException {
82         if (contentLength < 0) {
83             contentLength = 0;
84         }
85         String path;
86         File base = null;
87         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
88             path = Uri.parse(hint).getPath();
89         } else {
90             base = storageManager.locateDestinationDirectory(mimeType, destination,
91                     contentLength);
92             path = chooseFilename(url, hint, contentDisposition, contentLocation,
93                                              destination);
94         }
95         storageManager.verifySpace(destination, path, contentLength);
96         if (DownloadDrmHelper.isDrmConvertNeeded(mimeType)) {
97             path = DownloadDrmHelper.modifyDrmFwLockFileExtension(path);
98         }
99         path = getFullPath(path, mimeType, destination, base);
100         return path;
101     }
102
103     static String getFullPath(String filename, String mimeType, int destination, File base)
104             throws StopRequestException {
105         String extension = null;
106         int dotIndex = filename.lastIndexOf('.');
107         boolean missingExtension = dotIndex < 0 || dotIndex < filename.lastIndexOf('/');
108         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
109             // Destination is explicitly set - do not change the extension
110             if (missingExtension) {
111                 extension = "";
112             } else {
113                 extension = filename.substring(dotIndex);
114                 filename = filename.substring(0, dotIndex);
115             }
116         } else {
117             // Split filename between base and extension
118             // Add an extension if filename does not have one
119             if (missingExtension) {
120                 extension = chooseExtensionFromMimeType(mimeType, true);
121             } else {
122                 extension = chooseExtensionFromFilename(mimeType, destination, filename, dotIndex);
123                 filename = filename.substring(0, dotIndex);
124             }
125         }
126
127         boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension);
128
129         if (base != null) {
130             filename = base.getPath() + File.separator + filename;
131         }
132
133         if (Constants.LOGVV) {
134             Log.v(Constants.TAG, "target file: " + filename + extension);
135         }
136
137         synchronized (sUniqueLock) {
138             final String path = chooseUniqueFilenameLocked(
139                     destination, filename, extension, recoveryDir);
140
141             // Claim this filename inside lock to prevent other threads from
142             // clobbering us. We're not paranoid enough to use O_EXCL.
143             try {
144                 new File(path).createNewFile();
145             } catch (IOException e) {
146                 throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR,
147                         "Failed to create target file " + path, e);
148             }
149             return path;
150         }
151     }
152
153     private static String chooseFilename(String url, String hint, String contentDisposition,
154             String contentLocation, int destination) {
155         String filename = null;
156
157         // First, try to use the hint from the application, if there's one
158         if (filename == null && hint != null && !hint.endsWith("/")) {
159             if (Constants.LOGVV) {
160                 Log.v(Constants.TAG, "getting filename from hint");
161             }
162             int index = hint.lastIndexOf('/') + 1;
163             if (index > 0) {
164                 filename = hint.substring(index);
165             } else {
166                 filename = hint;
167             }
168         }
169
170         // If we couldn't do anything with the hint, move toward the content disposition
171         if (filename == null && contentDisposition != null) {
172             filename = parseContentDisposition(contentDisposition);
173             if (filename != null) {
174                 if (Constants.LOGVV) {
175                     Log.v(Constants.TAG, "getting filename from content-disposition");
176                 }
177                 int index = filename.lastIndexOf('/') + 1;
178                 if (index > 0) {
179                     filename = filename.substring(index);
180                 }
181             }
182         }
183
184         // If we still have nothing at this point, try the content location
185         if (filename == null && contentLocation != null) {
186             String decodedContentLocation = Uri.decode(contentLocation);
187             if (decodedContentLocation != null
188                     && !decodedContentLocation.endsWith("/")
189                     && decodedContentLocation.indexOf('?') < 0) {
190                 if (Constants.LOGVV) {
191                     Log.v(Constants.TAG, "getting filename from content-location");
192                 }
193                 int index = decodedContentLocation.lastIndexOf('/') + 1;
194                 if (index > 0) {
195                     filename = decodedContentLocation.substring(index);
196                 } else {
197                     filename = decodedContentLocation;
198                 }
199             }
200         }
201
202         // If all the other http-related approaches failed, use the plain uri
203         if (filename == null) {
204             String decodedUrl = Uri.decode(url);
205             if (decodedUrl != null
206                     && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) {
207                 int index = decodedUrl.lastIndexOf('/') + 1;
208                 if (index > 0) {
209                     if (Constants.LOGVV) {
210                         Log.v(Constants.TAG, "getting filename from uri");
211                     }
212                     filename = decodedUrl.substring(index);
213                 }
214             }
215         }
216
217         // Finally, if couldn't get filename from URI, get a generic filename
218         if (filename == null) {
219             if (Constants.LOGVV) {
220                 Log.v(Constants.TAG, "using default filename");
221             }
222             filename = Constants.DEFAULT_DL_FILENAME;
223         }
224
225         // The VFAT file system is assumed as target for downloads.
226         // Replace invalid characters according to the specifications of VFAT.
227         filename = replaceInvalidVfatCharacters(filename);
228
229         return filename;
230     }
231
232     private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) {
233         String extension = null;
234         if (mimeType != null) {
235             extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
236             if (extension != null) {
237                 if (Constants.LOGVV) {
238                     Log.v(Constants.TAG, "adding extension from type");
239                 }
240                 extension = "." + extension;
241             } else {
242                 if (Constants.LOGVV) {
243                     Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
244                 }
245             }
246         }
247         if (extension == null) {
248             if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) {
249                 if (mimeType.equalsIgnoreCase("text/html")) {
250                     if (Constants.LOGVV) {
251                         Log.v(Constants.TAG, "adding default html extension");
252                     }
253                     extension = Constants.DEFAULT_DL_HTML_EXTENSION;
254                 } else if (useDefaults) {
255                     if (Constants.LOGVV) {
256                         Log.v(Constants.TAG, "adding default text extension");
257                     }
258                     extension = Constants.DEFAULT_DL_TEXT_EXTENSION;
259                 }
260             } else if (useDefaults) {
261                 if (Constants.LOGVV) {
262                     Log.v(Constants.TAG, "adding default binary extension");
263                 }
264                 extension = Constants.DEFAULT_DL_BINARY_EXTENSION;
265             }
266         }
267         return extension;
268     }
269
270     private static String chooseExtensionFromFilename(String mimeType, int destination,
271             String filename, int lastDotIndex) {
272         String extension = null;
273         if (mimeType != null) {
274             // Compare the last segment of the extension against the mime type.
275             // If there's a mismatch, discard the entire extension.
276             String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
277                     filename.substring(lastDotIndex + 1));
278             if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) {
279                 extension = chooseExtensionFromMimeType(mimeType, false);
280                 if (extension != null) {
281                     if (Constants.LOGVV) {
282                         Log.v(Constants.TAG, "substituting extension from type");
283                     }
284                 } else {
285                     if (Constants.LOGVV) {
286                         Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
287                     }
288                 }
289             }
290         }
291         if (extension == null) {
292             if (Constants.LOGVV) {
293                 Log.v(Constants.TAG, "keeping extension");
294             }
295             extension = filename.substring(lastDotIndex);
296         }
297         return extension;
298     }
299
300     private static String chooseUniqueFilenameLocked(int destination, String filename,
301             String extension, boolean recoveryDir) throws StopRequestException {
302         String fullFilename = filename + extension;
303         if (!new File(fullFilename).exists()
304                 && (!recoveryDir ||
305                 (destination != Downloads.Impl.DESTINATION_CACHE_PARTITION &&
306                         destination != Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION &&
307                         destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE &&
308                         destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING))) {
309             return fullFilename;
310         }
311         filename = filename + Constants.FILENAME_SEQUENCE_SEPARATOR;
312         /*
313         * This number is used to generate partially randomized filenames to avoid
314         * collisions.
315         * It starts at 1.
316         * The next 9 iterations increment it by 1 at a time (up to 10).
317         * The next 9 iterations increment it by 1 to 10 (random) at a time.
318         * The next 9 iterations increment it by 1 to 100 (random) at a time.
319         * ... Up to the point where it increases by 100000000 at a time.
320         * (the maximum value that can be reached is 1000000000)
321         * As soon as a number is reached that generates a filename that doesn't exist,
322         *     that filename is used.
323         * If the filename coming in is [base].[ext], the generated filenames are
324         *     [base]-[sequence].[ext].
325         */
326         int sequence = 1;
327         for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) {
328             for (int iteration = 0; iteration < 9; ++iteration) {
329                 fullFilename = filename + sequence + extension;
330                 if (!new File(fullFilename).exists()) {
331                     return fullFilename;
332                 }
333                 if (Constants.LOGVV) {
334                     Log.v(Constants.TAG, "file with sequence number " + sequence + " exists");
335                 }
336                 sequence += sRandom.nextInt(magnitude) + 1;
337             }
338         }
339         throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR,
340                 "failed to generate an unused filename on internal download storage");
341     }
342
343     /**
344      * Checks whether the filename looks legitimate
345      */
346     static boolean isFilenameValid(String filename, File downloadsDataDir) {
347         final String[] whitelist;
348         try {
349             filename = new File(filename).getCanonicalPath();
350             whitelist = new String[] {
351                     downloadsDataDir.getCanonicalPath(),
352                     Environment.getDownloadCacheDirectory().getCanonicalPath(),
353                     Environment.getExternalStorageDirectory().getCanonicalPath(),
354             };
355         } catch (IOException e) {
356             Log.w(TAG, "Failed to resolve canonical path: " + e);
357             return false;
358         }
359
360         for (String test : whitelist) {
361             if (filename.startsWith(test)) {
362                 return true;
363             }
364         }
365
366         return false;
367     }
368
369     /**
370      * Checks whether this looks like a legitimate selection parameter
371      */
372     public static void validateSelection(String selection, Set<String> allowedColumns) {
373         try {
374             if (selection == null || selection.isEmpty()) {
375                 return;
376             }
377             Lexer lexer = new Lexer(selection, allowedColumns);
378             parseExpression(lexer);
379             if (lexer.currentToken() != Lexer.TOKEN_END) {
380                 throw new IllegalArgumentException("syntax error");
381             }
382         } catch (RuntimeException ex) {
383             if (Constants.LOGV) {
384                 Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex);
385             } else if (false) {
386                 Log.d(Constants.TAG, "invalid selection triggered " + ex);
387             }
388             throw ex;
389         }
390
391     }
392
393     // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] *
394     //             | statement [AND_OR expression]*
395     private static void parseExpression(Lexer lexer) {
396         for (;;) {
397             // ( expression )
398             if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) {
399                 lexer.advance();
400                 parseExpression(lexer);
401                 if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) {
402                     throw new IllegalArgumentException("syntax error, unmatched parenthese");
403                 }
404                 lexer.advance();
405             } else {
406                 // statement
407                 parseStatement(lexer);
408             }
409             if (lexer.currentToken() != Lexer.TOKEN_AND_OR) {
410                 break;
411             }
412             lexer.advance();
413         }
414     }
415
416     // statement <- COLUMN COMPARE VALUE
417     //            | COLUMN IS NULL
418     private static void parseStatement(Lexer lexer) {
419         // both possibilities start with COLUMN
420         if (lexer.currentToken() != Lexer.TOKEN_COLUMN) {
421             throw new IllegalArgumentException("syntax error, expected column name");
422         }
423         lexer.advance();
424
425         // statement <- COLUMN COMPARE VALUE
426         if (lexer.currentToken() == Lexer.TOKEN_COMPARE) {
427             lexer.advance();
428             if (lexer.currentToken() != Lexer.TOKEN_VALUE) {
429                 throw new IllegalArgumentException("syntax error, expected quoted string");
430             }
431             lexer.advance();
432             return;
433         }
434
435         // statement <- COLUMN IS NULL
436         if (lexer.currentToken() == Lexer.TOKEN_IS) {
437             lexer.advance();
438             if (lexer.currentToken() != Lexer.TOKEN_NULL) {
439                 throw new IllegalArgumentException("syntax error, expected NULL");
440             }
441             lexer.advance();
442             return;
443         }
444
445         // didn't get anything good after COLUMN
446         throw new IllegalArgumentException("syntax error after column name");
447     }
448
449     /**
450      * A simple lexer that recognizes the words of our restricted subset of SQL where clauses
451      */
452     private static class Lexer {
453         public static final int TOKEN_START = 0;
454         public static final int TOKEN_OPEN_PAREN = 1;
455         public static final int TOKEN_CLOSE_PAREN = 2;
456         public static final int TOKEN_AND_OR = 3;
457         public static final int TOKEN_COLUMN = 4;
458         public static final int TOKEN_COMPARE = 5;
459         public static final int TOKEN_VALUE = 6;
460         public static final int TOKEN_IS = 7;
461         public static final int TOKEN_NULL = 8;
462         public static final int TOKEN_END = 9;
463
464         private final String mSelection;
465         private final Set<String> mAllowedColumns;
466         private int mOffset = 0;
467         private int mCurrentToken = TOKEN_START;
468         private final char[] mChars;
469
470         public Lexer(String selection, Set<String> allowedColumns) {
471             mSelection = selection;
472             mAllowedColumns = allowedColumns;
473             mChars = new char[mSelection.length()];
474             mSelection.getChars(0, mChars.length, mChars, 0);
475             advance();
476         }
477
478         public int currentToken() {
479             return mCurrentToken;
480         }
481
482         public void advance() {
483             char[] chars = mChars;
484
485             // consume whitespace
486             while (mOffset < chars.length && chars[mOffset] == ' ') {
487                 ++mOffset;
488             }
489
490             // end of input
491             if (mOffset == chars.length) {
492                 mCurrentToken = TOKEN_END;
493                 return;
494             }
495
496             // "("
497             if (chars[mOffset] == '(') {
498                 ++mOffset;
499                 mCurrentToken = TOKEN_OPEN_PAREN;
500                 return;
501             }
502
503             // ")"
504             if (chars[mOffset] == ')') {
505                 ++mOffset;
506                 mCurrentToken = TOKEN_CLOSE_PAREN;
507                 return;
508             }
509
510             // "?"
511             if (chars[mOffset] == '?') {
512                 ++mOffset;
513                 mCurrentToken = TOKEN_VALUE;
514                 return;
515             }
516
517             // "=" and "=="
518             if (chars[mOffset] == '=') {
519                 ++mOffset;
520                 mCurrentToken = TOKEN_COMPARE;
521                 if (mOffset < chars.length && chars[mOffset] == '=') {
522                     ++mOffset;
523                 }
524                 return;
525             }
526
527             // ">" and ">="
528             if (chars[mOffset] == '>') {
529                 ++mOffset;
530                 mCurrentToken = TOKEN_COMPARE;
531                 if (mOffset < chars.length && chars[mOffset] == '=') {
532                     ++mOffset;
533                 }
534                 return;
535             }
536
537             // "<", "<=" and "<>"
538             if (chars[mOffset] == '<') {
539                 ++mOffset;
540                 mCurrentToken = TOKEN_COMPARE;
541                 if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) {
542                     ++mOffset;
543                 }
544                 return;
545             }
546
547             // "!="
548             if (chars[mOffset] == '!') {
549                 ++mOffset;
550                 mCurrentToken = TOKEN_COMPARE;
551                 if (mOffset < chars.length && chars[mOffset] == '=') {
552                     ++mOffset;
553                     return;
554                 }
555                 throw new IllegalArgumentException("Unexpected character after !");
556             }
557
558             // columns and keywords
559             // first look for anything that looks like an identifier or a keyword
560             //     and then recognize the individual words.
561             // no attempt is made at discarding sequences of underscores with no alphanumeric
562             //     characters, even though it's not clear that they'd be legal column names.
563             if (isIdentifierStart(chars[mOffset])) {
564                 int startOffset = mOffset;
565                 ++mOffset;
566                 while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) {
567                     ++mOffset;
568                 }
569                 String word = mSelection.substring(startOffset, mOffset);
570                 if (mOffset - startOffset <= 4) {
571                     if (word.equals("IS")) {
572                         mCurrentToken = TOKEN_IS;
573                         return;
574                     }
575                     if (word.equals("OR") || word.equals("AND")) {
576                         mCurrentToken = TOKEN_AND_OR;
577                         return;
578                     }
579                     if (word.equals("NULL")) {
580                         mCurrentToken = TOKEN_NULL;
581                         return;
582                     }
583                 }
584                 if (mAllowedColumns.contains(word)) {
585                     mCurrentToken = TOKEN_COLUMN;
586                     return;
587                 }
588                 throw new IllegalArgumentException("unrecognized column or keyword");
589             }
590
591             // quoted strings
592             if (chars[mOffset] == '\'') {
593                 ++mOffset;
594                 while (mOffset < chars.length) {
595                     if (chars[mOffset] == '\'') {
596                         if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') {
597                             ++mOffset;
598                         } else {
599                             break;
600                         }
601                     }
602                     ++mOffset;
603                 }
604                 if (mOffset == chars.length) {
605                     throw new IllegalArgumentException("unterminated string");
606                 }
607                 ++mOffset;
608                 mCurrentToken = TOKEN_VALUE;
609                 return;
610             }
611
612             // anything we don't recognize
613             throw new IllegalArgumentException("illegal character: " + chars[mOffset]);
614         }
615
616         private static final boolean isIdentifierStart(char c) {
617             return c == '_' ||
618                     (c >= 'A' && c <= 'Z') ||
619                     (c >= 'a' && c <= 'z');
620         }
621
622         private static final boolean isIdentifierChar(char c) {
623             return c == '_' ||
624                     (c >= 'A' && c <= 'Z') ||
625                     (c >= 'a' && c <= 'z') ||
626                     (c >= '0' && c <= '9');
627         }
628     }
629
630     /**
631      * Replace invalid filename characters according to
632      * specifications of the VFAT.
633      * @note Package-private due to testing.
634      */
635     private static String replaceInvalidVfatCharacters(String filename) {
636         final char START_CTRLCODE = 0x00;
637         final char END_CTRLCODE = 0x1f;
638         final char QUOTEDBL = 0x22;
639         final char ASTERISK = 0x2A;
640         final char SLASH = 0x2F;
641         final char COLON = 0x3A;
642         final char LESS = 0x3C;
643         final char GREATER = 0x3E;
644         final char QUESTION = 0x3F;
645         final char BACKSLASH = 0x5C;
646         final char BAR = 0x7C;
647         final char DEL = 0x7F;
648         final char UNDERSCORE = 0x5F;
649
650         StringBuffer sb = new StringBuffer();
651         char ch;
652         boolean isRepetition = false;
653         for (int i = 0; i < filename.length(); i++) {
654             ch = filename.charAt(i);
655             if ((START_CTRLCODE <= ch &&
656                 ch <= END_CTRLCODE) ||
657                 ch == QUOTEDBL ||
658                 ch == ASTERISK ||
659                 ch == SLASH ||
660                 ch == COLON ||
661                 ch == LESS ||
662                 ch == GREATER ||
663                 ch == QUESTION ||
664                 ch == BACKSLASH ||
665                 ch == BAR ||
666                 ch == DEL){
667                 if (!isRepetition) {
668                     sb.append(UNDERSCORE);
669                     isRepetition = true;
670                 }
671             } else {
672                 sb.append(ch);
673                 isRepetition = false;
674             }
675         }
676         return sb.toString();
677     }
678 }