Directory._listdir()   B
last analyzed

Complexity

Conditions 6

Size

Total Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 23
rs 7.6949
cc 6
1
#!/usr/bin/env python
2
# -*- coding: UTF-8 -*-
3
4
import os
5
import os.path
6
import re
7
import shutil
8
import codecs
9
import string
10
import random
11
import datetime
12
import logging
13
14
from flask import current_app, send_from_directory
15
from werkzeug.utils import cached_property
16
17
from . import compat
18
from .compat import range
19
from .stream import TarFileStream
20
from .exceptions import OutsideDirectoryBase, OutsideRemovableBase, \
21
                        PathTooLongError, FilenameTooLongError
22
23
24
logger = logging.getLogger(__name__)
25
unicode_underscore = '_'.decode('utf-8') if compat.PY_LEGACY else '_'
26
underscore_replace = '%s:underscore' % __name__
27
codecs.register_error(underscore_replace,
28
                      lambda error: (unicode_underscore, error.start + 1)
29
                      )
30
binary_units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB")
31
standard_units = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
32
common_path_separators = '\\/'
33
restricted_chars = '/\0'
34
nt_restricted_chars = '/\0\\<>:"|?*' + ''.join(map(chr, range(1, 32)))
35
restricted_names = ('.', '..', '::', '/', '\\')
36
nt_device_names = (
37
    ('CON', 'PRN', 'AUX', 'NUL') +
38
    tuple(map('COM{}'.format, range(1, 10))) +
39
    tuple(map('LPT{}'.format, range(1, 10)))
40
    )
41
fs_safe_characters = string.ascii_uppercase + string.digits
42
43
44
class Node(object):
45
    '''
46
    Abstract filesystem node class.
47
48
    This represents an unspecified entity with a filesystem's path suitable for
49
    being inherited by plugins.
50
51
    When inheriting, the following attributes should be overwritten in order
52
    to specify :meth:`from_urlpath` classmethod behavior:
53
54
    * :attr:`generic`, if true, an instance of directory_class or file_class
55
      will be created instead of an instance of this class tself.
56
    * :attr:`directory_class`, class will be used for directory nodes,
57
    * :attr:`file_class`, class will be used for file nodes.
58
    '''
59
    generic = True
60
    directory_class = None  # set later at import time
61
    file_class = None  # set later at import time
62
63
    re_charset = re.compile('; charset=(?P<charset>[^;]+)')
64
    can_download = False
65
    is_root = False
66
67
    @cached_property
68
    def is_excluded(self):
69
        '''
70
        Get if current node shouldn't be shown, using :attt:`app` config's
71
        exclude_fnc.
72
73
        :returns: True if excluded, False otherwise
74
        '''
75
        exclude = self.app and self.app.config['exclude_fnc']
76
        return exclude and exclude(self.path)
77
78
    @cached_property
79
    def plugin_manager(self):
80
        '''
81
        Get current app's plugin manager.
82
83
        :returns: plugin manager instance
84
        '''
85
        return self.app.extensions['plugin_manager']
86
87
    @cached_property
88
    def widgets(self):
89
        '''
90
        List widgets with filter return True for this node (or without filter).
91
92
        Remove button is prepended if :property:can_remove returns true.
93
94
        :returns: list of widgets
95
        :rtype: list of namedtuple instances
96
        '''
97
        widgets = []
98
        if self.can_remove:
99
            widgets.append(
100
                self.plugin_manager.create_widget(
101
                    'entry-actions',
102
                    'button',
103
                    file=self,
104
                    css='remove',
105
                    endpoint='remove'
106
                    )
107
                )
108
        return widgets + self.plugin_manager.get_widgets(file=self)
109
110
    @cached_property
111
    def link(self):
112
        '''
113
        Get last widget with place "entry-link".
114
115
        :returns: widget on entry-link (ideally a link one)
116
        :rtype: namedtuple instance
117
        '''
118
        link = None
119
        for widget in self.widgets:
120
            if widget.place == 'entry-link':
121
                link = widget
122
        return link
123
124
    @cached_property
125
    def can_remove(self):
126
        '''
127
        Get if current node can be removed based on app config's
128
        directory_remove.
129
130
        :returns: True if current node can be removed, False otherwise.
131
        :rtype: bool
132
        '''
133
        dirbase = self.app.config["directory_remove"]
134
        return bool(dirbase and check_under_base(self.path, dirbase))
135
136
    @cached_property
137
    def stats(self):
138
        '''
139
        Get current stats object as returned by os.stat function.
140
141
        :returns: stats object
142
        :rtype: posix.stat_result or nt.stat_result
143
        '''
144
        return os.stat(self.path)
145
146
    @cached_property
147
    def pathconf(self):
148
        '''
149
        Get filesystem config for current path.
150
        See :func:`compat.pathconf`.
151
152
        :returns: fs config
153
        :rtype: dict
154
        '''
155
        return compat.pathconf(self.path)
156
157
    @cached_property
158
    def parent(self):
159
        '''
160
        Get parent node if available based on app config's directory_base.
161
162
        :returns: parent object if available
163
        :rtype: Node instance or None
164
        '''
165
        if check_path(self.path, self.app.config['directory_base']):
166
            return None
167
        parent = os.path.dirname(self.path) if self.path else None
168
        return self.directory_class(parent, self.app) if parent else None
169
170
    @cached_property
171
    def ancestors(self):
172
        '''
173
        Get list of ancestors until app config's directory_base is reached.
174
175
        :returns: list of ancestors starting from nearest.
176
        :rtype: list of Node objects
177
        '''
178
        ancestors = []
179
        parent = self.parent
180
        while parent:
181
            ancestors.append(parent)
182
            parent = parent.parent
183
        return ancestors
184
185
    @property
186
    def modified(self):
187
        '''
188
        Get human-readable last modification date-time.
189
190
        :returns: iso9008-like date-time string (without timezone)
191
        :rtype: str
192
        '''
193
        try:
194
            dt = datetime.datetime.fromtimestamp(self.stats.st_mtime)
195
            return dt.strftime('%Y.%m.%d %H:%M:%S')
196
        except OSError:
197
            return None
198
199
    @property
200
    def urlpath(self):
201
        '''
202
        Get the url substring corresponding to this node for those endpoints
203
        accepting a 'path' parameter, suitable for :meth:`from_urlpath`.
204
205
        :returns: relative-url-like for node's path
206
        :rtype: str
207
        '''
208
        return abspath_to_urlpath(self.path, self.app.config['directory_base'])
209
210
    @property
211
    def name(self):
212
        '''
213
        Get the basename portion of node's path.
214
215
        :returns: filename
216
        :rtype: str
217
        '''
218
        return os.path.basename(self.path)
219
220
    @property
221
    def type(self):
222
        '''
223
        Get the mime portion of node's mimetype (without the encoding part).
224
225
        :returns: mimetype
226
        :rtype: str
227
        '''
228
        return self.mimetype.split(";", 1)[0]
229
230
    @property
231
    def category(self):
232
        '''
233
        Get mimetype category (first portion of mimetype before the slash).
234
235
        :returns: mimetype category
236
        :rtype: str
237
238
        As of 2016-11-03's revision of RFC2046 it could be one of the
239
        following:
240
            * application
241
            * audio
242
            * example
243
            * image
244
            * message
245
            * model
246
            * multipart
247
            * text
248
            * video
249
        '''
250
        return self.type.split('/', 1)[0]
251
252
    def __init__(self, path=None, app=None, **defaults):
253
        '''
254
        :param path: local path
255
        :type path: str
256
        :param path: optional app instance
257
        :type path: flask.app
258
        :param **defaults: attributes will be set to object
259
        '''
260
        self.path = compat.fsdecode(path) if path else None
261
        self.app = current_app if app is None else app
262
        self.__dict__.update(defaults)  # only for attr and cached_property
263
264
    def remove(self):
265
        '''
266
        Does nothing except raising if can_remove property returns False.
267
268
        :raises: OutsideRemovableBase if :property:`can_remove` returns false
269
        '''
270
        if not self.can_remove:
271
            raise OutsideRemovableBase("File outside removable base")
272
273
    @classmethod
274
    def from_urlpath(cls, path, app=None):
275
        '''
276
        Alternative constructor which accepts a path as taken from URL and uses
277
        the given app or the current app config to get the real path.
278
279
        If class has attribute `generic` set to True, `directory_class` or
280
        `file_class` will be used as type.
281
282
        :param path: relative path as from URL
283
        :param app: optional, flask application
284
        :return: file object pointing to path
285
        :rtype: File
286
        '''
287
        app = app or current_app
288
        base = app.config['directory_base']
289
        path = urlpath_to_abspath(path, base)
290
        if not cls.generic:
291
            kls = cls
292
        elif os.path.isdir(path):
293
            kls = cls.directory_class
294
        else:
295
            kls = cls.file_class
296
        return kls(path=path, app=app)
297
298
    @classmethod
299
    def register_file_class(cls, kls):
300
        '''
301
        Convenience method for setting current class file_class property.
302
303
        :param kls: class to set
304
        :type kls: type
305
        :returns: given class (enabling using this as decorator)
306
        :rtype: type
307
        '''
308
        cls.file_class = kls
309
        return kls
310
311
    @classmethod
312
    def register_directory_class(cls, kls):
313
        '''
314
        Convenience method for setting current class directory_class property.
315
316
        :param kls: class to set
317
        :type kls: type
318
        :returns: given class (enabling using this as decorator)
319
        :rtype: type
320
        '''
321
        cls.directory_class = kls
322
        return kls
323
324
325
@Node.register_file_class
326
class File(Node):
327
    '''
328
    Filesystem file class.
329
330
    Some notes:
331
332
    * :attr:`can_download` is fixed to True, so Files can be downloaded
333
      inconditionaly.
334
    * :attr:`can_upload` is fixed to False, so nothing can be uploaded to
335
      file path.
336
    * :attr:`is_directory` is fixed to False, so no further checks are
337
      performed.
338
    * :attr:`generic` is set to False, so static method :meth:`from_urlpath`
339
      will always return instances of this class.
340
    '''
341
    can_download = True
342
    can_upload = False
343
    is_directory = False
344
    generic = False
345
346
    @cached_property
347
    def widgets(self):
348
        '''
349
        List widgets with filter return True for this file (or without filter).
350
351
        Entry link is prepended.
352
        Download button is prepended if :property:can_download returns true.
353
        Remove button is prepended if :property:can_remove returns true.
354
355
        :returns: list of widgets
356
        :rtype: list of namedtuple instances
357
        '''
358
        widgets = [
359
            self.plugin_manager.create_widget(
360
                'entry-link',
361
                'link',
362
                file=self,
363
                endpoint='open'
364
                )
365
            ]
366
        if self.can_download:
367
            widgets.append(
368
                self.plugin_manager.create_widget(
369
                    'entry-actions',
370
                    'button',
371
                    file=self,
372
                    css='download',
373
                    endpoint='download_file'
374
                    )
375
                )
376
        return widgets + super(File, self).widgets
377
378
    @cached_property
379
    def mimetype(self):
380
        '''
381
        Get full mimetype, with encoding if available.
382
383
        :returns: mimetype
384
        :rtype: str
385
        '''
386
        return self.plugin_manager.get_mimetype(self.path)
387
388
    @cached_property
389
    def is_file(self):
390
        '''
391
        Get if node is file.
392
393
        :returns: True if file, False otherwise
394
        :rtype: bool
395
        '''
396
        return os.path.isfile(self.path)
397
398
    @property
399
    def size(self):
400
        '''
401
        Get human-readable node size in bytes.
402
        If directory, this will corresponds with own inode size.
403
404
        :returns: fuzzy size with unit
405
        :rtype: str
406
        '''
407
        try:
408
            size, unit = fmt_size(
409
                self.stats.st_size,
410
                self.app.config['use_binary_multiples'] if self.app else False
411
                )
412
        except OSError:
413
            return None
414
        if unit == binary_units[0]:
415
            return "%d %s" % (size, unit)
416
        return "%.2f %s" % (size, unit)
417
418
    @property
419
    def encoding(self):
420
        '''
421
        Get encoding part of mimetype, or "default" if not available.
422
423
        :returns: file conding as returned by mimetype function or "default"
424
        :rtype: str
425
        '''
426
        if ";" in self.mimetype:
427
            match = self.re_charset.search(self.mimetype)
428
            gdict = match.groupdict() if match else {}
429
            return gdict.get("charset") or "default"
430
        return "default"
431
432
    def remove(self):
433
        '''
434
        Remove file.
435
        :raises OutsideRemovableBase: when not under removable base directory
436
        '''
437
        super(File, self).remove()
438
        os.unlink(self.path)
439
440
    def download(self):
441
        '''
442
        Get a Flask's send_file Response object pointing to this file.
443
444
        :returns: Response object as returned by flask's send_file
445
        :rtype: flask.Response
446
        '''
447
        directory, name = os.path.split(self.path)
448
        return send_from_directory(directory, name, as_attachment=True)
449
450
451
@Node.register_directory_class
452
class Directory(Node):
453
    '''
454
    Filesystem directory class.
455
456
    Some notes:
457
458
    * :attr:`mimetype` is fixed to 'inode/directory', so mimetype detection
459
      functions won't be called in this case.
460
    * :attr:`is_file` is fixed to False, so no further checks are needed.
461
    * :attr:`size` is fixed to 0 (zero), so stats are not required for this.
462
    * :attr:`encoding` is fixed to 'default'.
463
    * :attr:`generic` is set to False, so static method :meth:`from_urlpath`
464
      will always return instances of this class.
465
    '''
466
    _listdir_cache = None
467
    mimetype = 'inode/directory'
468
    is_file = False
469
    size = None
470
    encoding = 'default'
471
    generic = False
472
473
    @property
474
    def name(self):
475
        '''
476
        Get the basename portion of directory's path.
477
478
        :returns: filename
479
        :rtype: str
480
        '''
481
        return super(Directory, self).name or self.path
482
483
    @cached_property
484
    def widgets(self):
485
        '''
486
        List widgets with filter return True for this dir (or without filter).
487
488
        Entry link is prepended.
489
        Upload scripts and widget are added if :property:can_upload is true.
490
        Download button is prepended if :property:can_download returns true.
491
        Remove button is prepended if :property:can_remove returns true.
492
493
        :returns: list of widgets
494
        :rtype: list of namedtuple instances
495
        '''
496
        widgets = [
497
            self.plugin_manager.create_widget(
498
                'entry-link',
499
                'link',
500
                file=self,
501
                endpoint='browse'
502
                )
503
            ]
504
        if self.can_upload:
505
            widgets.extend((
506
                self.plugin_manager.create_widget(
507
                    'head',
508
                    'script',
509
                    file=self,
510
                    endpoint='static',
511
                    filename='browse.directory.head.js'
512
                ),
513
                self.plugin_manager.create_widget(
514
                    'scripts',
515
                    'script',
516
                    file=self,
517
                    endpoint='static',
518
                    filename='browse.directory.body.js'
519
                ),
520
                self.plugin_manager.create_widget(
521
                    'header',
522
                    'upload',
523
                    file=self,
524
                    text='Upload',
525
                    endpoint='upload'
526
                    )
527
                ))
528
        if self.can_download:
529
            widgets.append(
530
                self.plugin_manager.create_widget(
531
                    'entry-actions',
532
                    'button',
533
                    file=self,
534
                    css='download',
535
                    endpoint='download_directory'
536
                    )
537
                )
538
        return widgets + super(Directory, self).widgets
539
540
    @cached_property
541
    def is_directory(self):
542
        '''
543
        Get if path points to a real directory.
544
545
        :returns: True if real directory, False otherwise
546
        :rtype: bool
547
        '''
548
        return os.path.isdir(self.path)
549
550
    @cached_property
551
    def is_root(self):
552
        '''
553
        Get if directory is filesystem's root
554
555
        :returns: True if FS root, False otherwise
556
        :rtype: bool
557
        '''
558
        return check_path(os.path.dirname(self.path), self.path)
559
560
    @cached_property
561
    def can_download(self):
562
        '''
563
        Get if path is downloadable (if app's `directory_downloadable` config
564
        property is True).
565
566
        :returns: True if downloadable, False otherwise
567
        :rtype: bool
568
        '''
569
        return self.app.config['directory_downloadable']
570
571
    @cached_property
572
    def can_upload(self):
573
        '''
574
        Get if a file can be uploaded to path (if directory path is under app's
575
        `directory_upload` config property).
576
577
        :returns: True if a file can be upload to directory, False otherwise
578
        :rtype: bool
579
        '''
580
        dirbase = self.app.config["directory_upload"]
581
        return dirbase and check_base(self.path, dirbase)
582
583
    @cached_property
584
    def can_remove(self):
585
        '''
586
        Get if current node can be removed based on app config's
587
        directory_remove.
588
589
        :returns: True if current node can be removed, False otherwise.
590
        :rtype: bool
591
        '''
592
        return self.parent and super(Directory, self).can_remove
593
594
    @cached_property
595
    def is_empty(self):
596
        '''
597
        Get if directory is empty (based on :meth:`_listdir`).
598
599
        :returns: True if this directory has no entries, False otherwise.
600
        :rtype: bool
601
        '''
602
        if self._listdir_cache is not None:
603
            return not bool(self._listdir_cache)
604
        for entry in self._listdir():
605
            return False
606
        return True
607
608
    def remove(self):
609
        '''
610
        Remove directory tree.
611
612
        :raises OutsideRemovableBase: when not under removable base directory
613
        '''
614
        super(Directory, self).remove()
615
        shutil.rmtree(self.path)
616
617
    def download(self):
618
        '''
619
        Get a Flask Response object streaming a tarball of this directory.
620
621
        :returns: Response object
622
        :rtype: flask.Response
623
        '''
624
        return self.app.response_class(
625
            TarFileStream(
626
                self.path,
627
                self.app.config['directory_tar_buffsize'],
628
                self.app.config['exclude_fnc'],
629
                ),
630
            mimetype="application/octet-stream"
631
            )
632
633
    def contains(self, filename):
634
        '''
635
        Check if directory contains an entry with given filename.
636
637
        :param filename: filename will be check
638
        :type filename: str
639
        :returns: True if exists, False otherwise.
640
        :rtype: bool
641
        '''
642
        return os.path.exists(os.path.join(self.path, filename))
643
644
    def choose_filename(self, filename, attempts=999):
645
        '''
646
        Get a new filename which does not colide with any entry on directory,
647
        based on given filename.
648
649
        :param filename: base filename
650
        :type filename: str
651
        :param attempts: number of attempts, defaults to 999
652
        :type attempts: int
653
        :returns: filename
654
        :rtype: str
655
656
        :raises FilenameTooLong: when filesystem filename size limit is reached
657
        :raises PathTooLong: when OS or filesystem path size limit is reached
658
        '''
659
        new_filename = filename
660
        for attempt in range(2, attempts + 1):
661
            if not self.contains(new_filename):
662
                break
663
            new_filename = alternative_filename(filename, attempt)
664
        else:
665
            while self.contains(new_filename):
666
                new_filename = alternative_filename(filename)
667
668
        limit = self.pathconf.get('PC_NAME_MAX', 0)
669
        if limit and limit < len(filename):
670
            raise FilenameTooLongError(
671
                path=self.path, filename=filename, limit=limit)
672
673
        abspath = os.path.join(self.path, filename)
674
        limit = self.pathconf.get('PC_PATH_MAX', 0)
675
        if limit and limit < len(abspath):
676
            raise PathTooLongError(path=abspath, limit=limit)
677
678
        return new_filename
679
680
    def _listdir(self, precomputed_stats=(os.name == 'nt')):
681
        '''
682
        Iter unsorted entries on this directory.
683
684
        :yields: Directory or File instance for each entry in directory
685
        :ytype: Node
686
        '''
687
        for entry in scandir(self.path, self.app):
688
            kwargs = {
689
                'path': entry.path,
690
                'app': self.app,
691
                'parent': self,
692
                'is_excluded': False
693
                }
694
            try:
695
                if precomputed_stats and not entry.is_symlink():
696
                    kwargs['stats'] = entry.stat()
697
                if entry.is_dir(follow_symlinks=True):
698
                    yield self.directory_class(**kwargs)
699
                else:
700
                    yield self.file_class(**kwargs)
701
            except OSError as e:
702
                logger.exception(e)
703
704
    def listdir(self, sortkey=None, reverse=False):
705
        '''
706
        Get sorted list (by given sortkey and reverse params) of File objects.
707
708
        :return: sorted list of File instances
709
        :rtype: list of File instances
710
        '''
711
        if self._listdir_cache is None:
712
            self._listdir_cache = tuple(self._listdir())
713
        if sortkey:
714
            return sorted(self._listdir_cache, key=sortkey, reverse=reverse)
715
        data = list(self._listdir_cache)
716
        if reverse:
717
            data.reverse()
718
        return data
719
720
721
def fmt_size(size, binary=True):
722
    '''
723
    Get size and unit.
724
725
    :param size: size in bytes
726
    :type size: int
727
    :param binary: whether use binary or standard units, defaults to True
728
    :type binary: bool
729
    :return: size and unit
730
    :rtype: tuple of int and unit as str
731
    '''
732
    if binary:
733
        fmt_sizes = binary_units
734
        fmt_divider = 1024.
735
    else:
736
        fmt_sizes = standard_units
737
        fmt_divider = 1000.
738
    for fmt in fmt_sizes[:-1]:
739
        if size < 1000:
740
            return (size, fmt)
741
        size /= fmt_divider
742
    return size, fmt_sizes[-1]
743
744
745
def relativize_path(path, base, os_sep=os.sep):
746
    '''
747
    Make absolute path relative to an absolute base.
748
749
    :param path: absolute path
750
    :type path: str
751
    :param base: absolute base path
752
    :type base: str
753
    :param os_sep: path component separator, defaults to current OS separator
754
    :type os_sep: str
755
    :return: relative path
756
    :rtype: str or unicode
757
    :raises OutsideDirectoryBase: if path is not below base
758
    '''
759
    if not check_base(path, base, os_sep):
760
        raise OutsideDirectoryBase("%r is not under %r" % (path, base))
761
    prefix_len = len(base)
762
    if not base.endswith(os_sep):
763
        prefix_len += len(os_sep)
764
    return path[prefix_len:]
765
766
767
def abspath_to_urlpath(path, base, os_sep=os.sep):
768
    '''
769
    Make filesystem absolute path uri relative using given absolute base path.
770
771
    :param path: absolute path
772
    :param base: absolute base path
773
    :param os_sep: path component separator, defaults to current OS separator
774
    :return: relative uri
775
    :rtype: str or unicode
776
    :raises OutsideDirectoryBase: if resulting path is not below base
777
    '''
778
    return relativize_path(path, base, os_sep).replace(os_sep, '/')
779
780
781
def urlpath_to_abspath(path, base, os_sep=os.sep):
782
    '''
783
    Make uri relative path fs absolute using a given absolute base path.
784
785
    :param path: relative path
786
    :param base: absolute base path
787
    :param os_sep: path component separator, defaults to current OS separator
788
    :return: absolute path
789
    :rtype: str or unicode
790
    :raises OutsideDirectoryBase: if resulting path is not below base
791
    '''
792
    prefix = base if base.endswith(os_sep) else base + os_sep
793
    realpath = os.path.abspath(prefix + path.replace('/', os_sep))
794
    if check_path(base, realpath) or check_under_base(realpath, base):
795
        return realpath
796
    raise OutsideDirectoryBase("%r is not under %r" % (realpath, base))
797
798
799
def generic_filename(path):
800
    '''
801
    Extract filename of given path os-indepently, taking care of known path
802
    separators.
803
804
    :param path: path
805
    :return: filename
806
    :rtype: str or unicode (depending on given path)
807
    '''
808
809
    for sep in common_path_separators:
810
        if sep in path:
811
            _, path = path.rsplit(sep, 1)
812
    return path
813
814
815
def clean_restricted_chars(path, restricted_chars=restricted_chars):
816
    '''
817
    Get path without restricted characters.
818
819
    :param path: path
820
    :return: path without restricted characters
821
    :rtype: str or unicode (depending on given path)
822
    '''
823
    for character in restricted_chars:
824
        path = path.replace(character, '_')
825
    return path
826
827
828
def check_forbidden_filename(filename,
829
                             destiny_os=os.name,
830
                             restricted_names=restricted_names):
831
    '''
832
    Get if given filename is forbidden for current OS or filesystem.
833
834
    :param filename:
835
    :param destiny_os: destination operative system
836
    :param fs_encoding: destination filesystem filename encoding
837
    :return: wether is forbidden on given OS (or filesystem) or not
838
    :rtype: bool
839
    '''
840
    return (
841
      filename in restricted_names or
842
      destiny_os == 'nt' and
843
      filename.split('.', 1)[0].upper() in nt_device_names
844
      )
845
846
847
def check_path(path, base, os_sep=os.sep):
848
    '''
849
    Check if both given paths are equal.
850
851
    :param path: absolute path
852
    :type path: str
853
    :param base: absolute base path
854
    :type base: str
855
    :param os_sep: path separator, defaults to os.sep
856
    :type base: str
857
    :return: wether two path are equal or not
858
    :rtype: bool
859
    '''
860
    base = base[:-len(os_sep)] if base.endswith(os_sep) else base
861
    return os.path.normcase(path) == os.path.normcase(base)
862
863
864
def check_base(path, base, os_sep=os.sep):
865
    '''
866
    Check if given absolute path is under or given base.
867
868
    :param path: absolute path
869
    :type path: str
870
    :param base: absolute base path
871
    :type base: str
872
    :param os_sep: path separator, defaults to os.sep
873
    :return: wether path is under given base or not
874
    :rtype: bool
875
    '''
876
    return (
877
        check_path(path, base, os_sep) or
878
        check_under_base(path, base, os_sep)
879
        )
880
881
882
def check_under_base(path, base, os_sep=os.sep):
883
    '''
884
    Check if given absolute path is under given base.
885
886
    :param path: absolute path
887
    :type path: str
888
    :param base: absolute base path
889
    :type base: str
890
    :param os_sep: path separator, defaults to os.sep
891
    :return: wether file is under given base or not
892
    :rtype: bool
893
    '''
894
    prefix = base if base.endswith(os_sep) else base + os_sep
895
    return os.path.normcase(path).startswith(os.path.normcase(prefix))
896
897
898
def secure_filename(path, destiny_os=os.name, fs_encoding=compat.FS_ENCODING):
899
    '''
900
    Get rid of parent path components and special filenames.
901
902
    If path is invalid or protected, return empty string.
903
904
    :param path: unsafe path, only basename will be used
905
    :type: str
906
    :param destiny_os: destination operative system (defaults to os.name)
907
    :type destiny_os: str
908
    :param fs_encoding: fs path encoding (defaults to detected)
909
    :type fs_encoding: str
910
    :return: filename or empty string
911
    :rtype: str
912
    '''
913
    path = generic_filename(path)
914
    path = clean_restricted_chars(
915
        path,
916
        restricted_chars=(
917
            nt_restricted_chars
918
            if destiny_os == 'nt' else
919
            restricted_chars
920
            ))
921
    path = path.strip(' .')  # required by nt, recommended for others
922
923
    if check_forbidden_filename(path, destiny_os=destiny_os):
924
        return ''
925
926
    if isinstance(path, bytes):
927
        path = path.decode('latin-1', errors=underscore_replace)
928
929
    # Decode and recover from filesystem encoding in order to strip unwanted
930
    # characters out
931
    kwargs = {
932
        'os_name': destiny_os,
933
        'fs_encoding': fs_encoding,
934
        'errors': underscore_replace,
935
        }
936
    fs_encoded_path = compat.fsencode(path, **kwargs)
937
    fs_decoded_path = compat.fsdecode(fs_encoded_path, **kwargs)
938
    return fs_decoded_path
939
940
941
def alternative_filename(filename, attempt=None):
942
    '''
943
    Generates an alternative version of given filename.
944
945
    If an number attempt parameter is given, will be used on the alternative
946
    name, a random value will be used otherwise.
947
948
    :param filename: original filename
949
    :param attempt: optional attempt number, defaults to null
950
    :return: new filename
951
    :rtype: str or unicode
952
    '''
953
    filename_parts = filename.rsplit(u'.', 2)
954
    name = filename_parts[0]
955
    ext = ''.join(u'.%s' % ext for ext in filename_parts[1:])
956
    if attempt is None:
957
        choose = random.choice
958
        extra = u' %s' % ''.join(choose(fs_safe_characters) for i in range(8))
959
    else:
960
        extra = u' (%d)' % attempt
961
    return u'%s%s%s' % (name, extra, ext)
962
963
964
def scandir(path, app=None):
965
    '''
966
    Config-aware scandir. Currently, only aware of ``exclude_fnc``.
967
968
    :param path: absolute path
969
    :type path: str
970
    :param app: flask application
971
    :type app: flask.Flask or None
972
    :returns: filtered scandir entries
973
    :rtype: iterator
974
    '''
975
    exclude = app and app.config.get('exclude_fnc')
976
    if exclude:
977
        return (
978
            item
979
            for item in compat.scandir(path)
980
            if not exclude(item.path)
981
            )
982
    return compat.scandir(path)
983