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
17package com.android.providers.downloads;
18
19import static com.android.providers.downloads.Constants.TAG;
20
21import android.content.Context;
22import android.net.Uri;
23import android.os.Environment;
24import android.os.FileUtils;
25import android.os.SystemClock;
26import android.provider.Downloads;
27import android.util.Log;
28import android.webkit.MimeTypeMap;
29
30import java.io.File;
31import java.io.IOException;
32import java.util.Random;
33import java.util.Set;
34import java.util.regex.Matcher;
35import java.util.regex.Pattern;
36
37/**
38 * Some helper functions for the download manager
39 */
40public 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
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     */
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     */
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 = replaceInvalidVfatCharacters(filename);
218
219        return filename;
220    }
221
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
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
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
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     */
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
365    public static File getRunningDestinationDirectory(Context context, int destination)
366            throws IOException {
367        return getDestinationDirectory(context, destination, true);
368    }
369
370    public static File getSuccessDestinationDirectory(Context context, int destination)
371            throws IOException {
372        return getDestinationDirectory(context, destination, false);
373    }
374
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     */
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]*
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
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
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
517        public int currentToken() {
518            return mCurrentToken;
519        }
520
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
655        private static final boolean isIdentifierStart(char c) {
656            return c == '_' ||
657                    (c >= 'A' && c <= 'Z') ||
658                    (c >= 'a' && c <= 'z');
659        }
660
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    /**
670     * Replace invalid filename characters according to
671     * specifications of the VFAT.
672     * @note Package-private due to testing.
673     */
674    private static String replaceInvalidVfatCharacters(String filename) {
675        final char START_CTRLCODE = 0x00;
676        final char END_CTRLCODE = 0x1f;
677        final char QUOTEDBL = 0x22;
678        final char ASTERISK = 0x2A;
679        final char SLASH = 0x2F;
680        final char COLON = 0x3A;
681        final char LESS = 0x3C;
682        final char GREATER = 0x3E;
683        final char QUESTION = 0x3F;
684        final char BACKSLASH = 0x5C;
685        final char BAR = 0x7C;
686        final char DEL = 0x7F;
687        final char UNDERSCORE = 0x5F;
688
689        StringBuffer sb = new StringBuffer();
690        char ch;
691        boolean isRepetition = false;
692        for (int i = 0; i < filename.length(); i++) {
693            ch = filename.charAt(i);
694            if ((START_CTRLCODE <= ch &&
695                ch <= END_CTRLCODE) ||
696                ch == QUOTEDBL ||
697                ch == ASTERISK ||
698                ch == SLASH ||
699                ch == COLON ||
700                ch == LESS ||
701                ch == GREATER ||
702                ch == QUESTION ||
703                ch == BACKSLASH ||
704                ch == BAR ||
705                ch == DEL){
706                if (!isRepetition) {
707                    sb.append(UNDERSCORE);
708                    isRepetition = true;
709                }
710            } else {
711                sb.append(ch);
712                isRepetition = false;
713            }
714        }
715        return sb.toString();
716    }
717}
718