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