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 } 679