Completed
Push — 0.5.3 ( d5f94f...eba748 )
by Felipe A.
01:10
created

Directory._listdir()   F

Complexity

Conditions 9

Size

Total Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

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