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