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