1 # Copyright (C) 2007 Giampaolo Rodola' <g.rodola@gmail.com>.
\r
2 # Use of this source code is governed by MIT license that can be
\r
3 # found in the LICENSE file.
\r
10 from stat import filemode as _filemode # PY 3.3
\r
12 from tarfile import filemode as _filemode
\r
19 from os import scandir # py 3.5
\r
22 from scandir import scandir # requires "pip install scandir"
\r
26 from ._compat import PY3
\r
27 from ._compat import u
\r
28 from ._compat import unicode
\r
31 __all__ = ['FilesystemError', 'AbstractedFS']
\r
34 _months_map = {1: 'Jan', 2: 'Feb', 3: 'Mar', 4: 'Apr', 5: 'May', 6: 'Jun',
\r
35 7: 'Jul', 8: 'Aug', 9: 'Sep', 10: 'Oct', 11: 'Nov', 12: 'Dec'}
\r
39 """A simple memoize decorator for functions supporting (hashable)
\r
40 positional arguments.
\r
42 def wrapper(*args, **kwargs):
\r
43 key = (args, frozenset(sorted(kwargs.items())))
\r
47 ret = cache[key] = fun(*args, **kwargs)
\r
54 # ===================================================================
\r
55 # --- custom exceptions
\r
56 # ===================================================================
\r
58 class FilesystemError(Exception):
\r
59 """Custom class for filesystem-related exceptions.
\r
60 You can raise this from an AbstractedFS subclass in order to
\r
61 send a customized error string to the client.
\r
65 # ===================================================================
\r
67 # ===================================================================
\r
69 class AbstractedFS(object):
\r
70 """A class used to interact with the file system, providing a
\r
71 cross-platform interface compatible with both Windows and
\r
72 UNIX style filesystems where all paths use "/" separator.
\r
74 AbstractedFS distinguishes between "real" filesystem paths and
\r
75 "virtual" ftp paths emulating a UNIX chroot jail where the user
\r
76 can not escape its home directory (example: real "/home/user"
\r
77 path will be seen as "/" by the client)
\r
79 It also provides some utility methods and wraps around all os.*
\r
80 calls involving operations against the filesystem like creating
\r
81 files or removing directories.
\r
83 FilesystemError exception can be raised from within any of
\r
84 the methods below in order to send a customized error string
\r
88 def __init__(self, root, cmd_channel):
\r
90 - (str) root: the user "real" home directory (e.g. '/home/user')
\r
91 - (instance) cmd_channel: the FTPHandler class instance
\r
93 assert isinstance(root, unicode)
\r
94 # Set initial current working directory.
\r
95 # By default initial cwd is set to "/" to emulate a chroot jail.
\r
96 # If a different behavior is desired (e.g. initial cwd = root,
\r
97 # to reflect the real filesystem) users overriding this class
\r
98 # are responsible to set _cwd attribute as necessary.
\r
101 self.cmd_channel = cmd_channel
\r
105 """The user home directory."""
\r
110 """The user current working directory."""
\r
114 def root(self, path):
\r
115 assert isinstance(path, unicode), path
\r
119 def cwd(self, path):
\r
120 assert isinstance(path, unicode), path
\r
123 # --- Pathname / conversion utilities
\r
125 def ftpnorm(self, ftppath):
\r
126 """Normalize a "virtual" ftp pathname (typically the raw string
\r
127 coming from client) depending on the current working directory.
\r
129 Example (having "/foo" as current working directory):
\r
133 Note: directory separators are system independent ("/").
\r
134 Pathname returned is always absolutized.
\r
136 assert isinstance(ftppath, unicode), ftppath
\r
137 if os.path.isabs(ftppath):
\r
138 p = os.path.normpath(ftppath)
\r
140 p = os.path.normpath(os.path.join(self.cwd, ftppath))
\r
141 # normalize string in a standard web-path notation having '/'
\r
144 p = p.replace("\\", "/")
\r
145 # os.path.normpath supports UNC paths (e.g. "//a/b/c") but we
\r
146 # don't need them. In case we get an UNC path we collapse
\r
147 # redundant separators appearing at the beginning of the string
\r
148 while p[:2] == '//':
\r
150 # Anti path traversal: don't trust user input, in the event
\r
151 # that self.cwd is not absolute, return "/" as a safety measure.
\r
152 # This is for extra protection, maybe not really necessary.
\r
153 if not os.path.isabs(p):
\r
157 def ftp2fs(self, ftppath):
\r
158 """Translate a "virtual" ftp pathname (typically the raw string
\r
159 coming from client) into equivalent absolute "real" filesystem
\r
162 Example (having "/home/user" as root directory):
\r
166 Note: directory separators are system dependent.
\r
168 assert isinstance(ftppath, unicode), ftppath
\r
169 # as far as I know, it should always be path traversal safe...
\r
170 if os.path.normpath(self.root) == os.sep:
\r
171 return os.path.normpath(self.ftpnorm(ftppath))
\r
173 p = self.ftpnorm(ftppath)[1:]
\r
174 return os.path.normpath(os.path.join(self.root, p))
\r
176 def fs2ftp(self, fspath):
\r
177 """Translate a "real" filesystem pathname into equivalent
\r
178 absolute "virtual" ftp pathname depending on the user's
\r
181 Example (having "/home/user" as root directory):
\r
182 >>> fs2ftp("/home/user/foo")
\r
185 As for ftpnorm, directory separators are system independent
\r
186 ("/") and pathname returned is always absolutized.
\r
188 On invalid pathnames escaping from user's root directory
\r
189 (e.g. "/home" when root is "/home/user") always return "/".
\r
191 assert isinstance(fspath, unicode), fspath
\r
192 if os.path.isabs(fspath):
\r
193 p = os.path.normpath(fspath)
\r
195 p = os.path.normpath(os.path.join(self.root, fspath))
\r
196 if not self.validpath(p):
\r
198 p = p.replace(os.sep, "/")
\r
199 p = p[len(self.root):]
\r
200 if not p.startswith('/'):
\r
204 def validpath(self, path):
\r
205 """Check whether the path belongs to user's home directory.
\r
206 Expected argument is a "real" filesystem pathname.
\r
208 If path is a symbolic link it is resolved to check its real
\r
211 Pathnames escaping from user's root directory are considered
\r
214 assert isinstance(path, unicode), path
\r
215 root = self.realpath(self.root)
\r
216 path = self.realpath(path)
\r
217 if not root.endswith(os.sep):
\r
218 root = root + os.sep
\r
219 if not path.endswith(os.sep):
\r
220 path = path + os.sep
\r
221 if path[0:len(root)] == root:
\r
225 # --- Wrapper methods around open() and tempfile.mkstemp
\r
227 def open(self, filename, mode):
\r
228 """Open a file returning its handler."""
\r
229 assert isinstance(filename, unicode), filename
\r
230 return open(filename, mode)
\r
232 def mkstemp(self, suffix='', prefix='', dir=None, mode='wb'):
\r
233 """A wrap around tempfile.mkstemp creating a file with a unique
\r
234 name. Unlike mkstemp it returns an object with a file-like
\r
239 def __init__(self, fd, name):
\r
243 def __getattr__(self, attr):
\r
244 return getattr(self.file, attr)
\r
246 text = 'b' not in mode
\r
247 # max number of tries to find out a unique file name
\r
248 tempfile.TMP_MAX = 50
\r
249 fd, name = tempfile.mkstemp(suffix, prefix, dir, text=text)
\r
250 file = os.fdopen(fd, mode)
\r
251 return FileWrapper(file, name)
\r
253 # --- Wrapper methods around os.* calls
\r
255 def chdir(self, path):
\r
256 """Change the current directory. If this method is overridden
\r
257 it is vital that `cwd` attribute gets set.
\r
259 # note: process cwd will be reset by the caller
\r
260 assert isinstance(path, unicode), path
\r
262 self.cwd = self.fs2ftp(path)
\r
264 def mkdir(self, path):
\r
265 """Create the specified directory."""
\r
266 assert isinstance(path, unicode), path
\r
269 def listdir(self, path):
\r
270 """List the content of a directory."""
\r
271 assert isinstance(path, unicode), path
\r
272 return os.listdir(path)
\r
274 def listdirinfo(self, path):
\r
275 """List the content of a directory."""
\r
276 assert isinstance(path, unicode), path
\r
277 return os.listdir(path)
\r
279 def rmdir(self, path):
\r
280 """Remove the specified directory."""
\r
281 assert isinstance(path, unicode), path
\r
284 def remove(self, path):
\r
285 """Remove the specified file."""
\r
286 assert isinstance(path, unicode), path
\r
289 def rename(self, src, dst):
\r
290 """Rename the specified src file to the dst filename."""
\r
291 assert isinstance(src, unicode), src
\r
292 assert isinstance(dst, unicode), dst
\r
293 os.rename(src, dst)
\r
295 def chmod(self, path, mode):
\r
296 """Change file/directory mode."""
\r
297 assert isinstance(path, unicode), path
\r
298 if not hasattr(os, 'chmod'):
\r
299 raise NotImplementedError
\r
300 os.chmod(path, mode)
\r
302 def stat(self, path):
\r
303 """Perform a stat() system call on the given path."""
\r
304 # on python 2 we might also get bytes from os.lisdir()
\r
305 # assert isinstance(path, unicode), path
\r
306 return os.stat(path)
\r
308 def utime(self, path, timeval):
\r
309 """Perform a utime() call on the given path"""
\r
310 # utime expects a int/float (atime, mtime) in seconds
\r
311 # thus, setting both access and modify time to timeval
\r
312 return os.utime(path, (timeval, timeval))
\r
314 if hasattr(os, 'lstat'):
\r
315 def lstat(self, path):
\r
316 """Like stat but does not follow symbolic links."""
\r
317 # on python 2 we might also get bytes from os.lisdir()
\r
318 # assert isinstance(path, unicode), path
\r
319 return os.lstat(path)
\r
323 if hasattr(os, 'readlink'):
\r
324 def readlink(self, path):
\r
325 """Return a string representing the path to which a
\r
326 symbolic link points.
\r
328 assert isinstance(path, unicode), path
\r
329 return os.readlink(path)
\r
331 # --- Wrapper methods around os.path.* calls
\r
333 def isfile(self, path):
\r
334 """Return True if path is a file."""
\r
335 assert isinstance(path, unicode), path
\r
336 return os.path.isfile(path)
\r
338 def islink(self, path):
\r
339 """Return True if path is a symbolic link."""
\r
340 assert isinstance(path, unicode), path
\r
341 return os.path.islink(path)
\r
343 def isdir(self, path):
\r
344 """Return True if path is a directory."""
\r
345 assert isinstance(path, unicode), path
\r
346 return os.path.isdir(path)
\r
348 def getsize(self, path):
\r
349 """Return the size of the specified file in bytes."""
\r
350 assert isinstance(path, unicode), path
\r
351 return os.path.getsize(path)
\r
353 def getmtime(self, path):
\r
354 """Return the last modified time as a number of seconds since
\r
356 assert isinstance(path, unicode), path
\r
357 return os.path.getmtime(path)
\r
359 def realpath(self, path):
\r
360 """Return the canonical version of path eliminating any
\r
361 symbolic links encountered in the path (if they are
\r
362 supported by the operating system).
\r
364 assert isinstance(path, unicode), path
\r
365 return os.path.realpath(path)
\r
367 def lexists(self, path):
\r
368 """Return True if path refers to an existing path, including
\r
369 a broken or circular symbolic link.
\r
371 assert isinstance(path, unicode), path
\r
372 return os.path.lexists(path)
\r
374 if pwd is not None:
\r
375 def get_user_by_uid(self, uid):
\r
376 """Return the username associated with user id.
\r
377 If this can't be determined return raw uid instead.
\r
378 On Windows just return "owner".
\r
381 return pwd.getpwuid(uid).pw_name
\r
385 def get_user_by_uid(self, uid):
\r
388 if grp is not None:
\r
389 def get_group_by_gid(self, gid):
\r
390 """Return the groupname associated with group id.
\r
391 If this can't be determined return raw gid instead.
\r
392 On Windows just return "group".
\r
395 return grp.getgrgid(gid).gr_name
\r
399 def get_group_by_gid(self, gid):
\r
402 # --- Listing utilities
\r
404 def format_list(self, basedir, listing, ignore_err=True):
\r
405 """Return an iterator object that yields the entries of given
\r
406 directory emulating the "/bin/ls -lA" UNIX command output.
\r
408 - (str) basedir: the absolute dirname.
\r
409 - (list) listing: the names of the entries in basedir
\r
410 - (bool) ignore_err: when False raise exception if os.lstat()
\r
413 On platforms which do not support the pwd and grp modules (such
\r
414 as Windows), ownership is printed as "owner" and "group" as a
\r
415 default, and number of hard links is always "1". On UNIX
\r
416 systems, the actual owner, group, and number of links are
\r
419 This is how output appears to client:
\r
421 -rw-rw-rw- 1 owner group 7045120 Sep 02 3:47 music.mp3
\r
422 drwxrwxrwx 1 owner group 0 Aug 31 18:50 e-books
\r
423 -rw-rw-rw- 1 owner group 380 Sep 02 3:40 module.py
\r
426 def get_user_by_uid(uid):
\r
427 return self.get_user_by_uid(uid)
\r
430 def get_group_by_gid(gid):
\r
431 return self.get_group_by_gid(gid)
\r
433 assert isinstance(basedir, unicode), basedir
\r
434 if self.cmd_channel.use_gmt_times:
\r
435 timefunc = time.gmtime
\r
437 timefunc = time.localtime
\r
438 SIX_MONTHS = 180 * 24 * 60 * 60
\r
439 readlink = getattr(self, 'readlink', None)
\r
441 for basename in listing:
\r
444 file = os.path.join(basedir, basename)
\r
445 except UnicodeDecodeError:
\r
446 # (Python 2 only) might happen on filesystem not
\r
447 # supporting UTF8 meaning os.listdir() returned a list
\r
448 # of mixed bytes and unicode strings:
\r
449 # http://goo.gl/6DLHD
\r
450 # http://bugs.python.org/issue683592
\r
451 file = os.path.join(bytes(basedir), bytes(basename))
\r
452 if not isinstance(basename, unicode):
\r
453 basename = unicode(basename, 'utf8', 'ignore')
\r
455 file = os.path.join(basedir, basename)
\r
457 st = self.lstat(file)
\r
458 except (OSError, FilesystemError):
\r
463 perms = _filemode(st.st_mode) # permissions
\r
464 nlinks = st.st_nlink # number of links to inode
\r
465 if not nlinks: # non-posix system, let's use a bogus value
\r
467 size = st.st_size # file size
\r
468 uname = get_user_by_uid(st.st_uid)
\r
469 gname = get_group_by_gid(st.st_gid)
\r
470 mtime = timefunc(st.st_mtime)
\r
471 # if modification time > 6 months shows "month year"
\r
472 # else "month hh:mm"; this matches proftpd format, see:
\r
473 # https://github.com/giampaolo/pyftpdlib/issues/187
\r
474 if (now - st.st_mtime) > SIX_MONTHS:
\r
477 fmtstr = "%d %H:%M"
\r
479 mtimestr = "%s %s" % (_months_map[mtime.tm_mon],
\r
480 time.strftime(fmtstr, mtime))
\r
482 # It could be raised if last mtime happens to be too
\r
483 # old (prior to year 1900) in which case we return
\r
484 # the current time as last mtime.
\r
486 mtimestr = "%s %s" % (_months_map[mtime.tm_mon],
\r
487 time.strftime("%d %H:%M", mtime))
\r
489 # same as stat.S_ISLNK(st.st_mode) but slighlty faster
\r
490 islink = (st.st_mode & 61440) == stat.S_IFLNK
\r
491 if islink and readlink is not None:
\r
492 # if the file is a symlink, resolve it, e.g.
\r
493 # "symlink -> realfile"
\r
495 basename = basename + " -> " + readlink(file)
\r
496 except (OSError, FilesystemError):
\r
500 # formatting is matched with proftpd ls output
\r
501 line = "%s %3s %-8s %-8s %8s %s %s\r\n" % (
\r
502 perms, nlinks, uname, gname, size, mtimestr, basename)
\r
503 yield line.encode('utf8', self.cmd_channel.unicode_errors)
\r
505 def format_mlsx(self, basedir, listing, perms, facts, ignore_err=True):
\r
506 """Return an iterator object that yields the entries of a given
\r
507 directory or of a single file in a form suitable with MLSD and
\r
510 Every entry includes a list of "facts" referring the listed
\r
511 element. See RFC-3659, chapter 7, to see what every single
\r
514 - (str) basedir: the absolute dirname.
\r
515 - (list) listing: the names of the entries in basedir
\r
516 - (str) perms: the string referencing the user permissions.
\r
517 - (str) facts: the list of "facts" to be returned.
\r
518 - (bool) ignore_err: when False raise exception if os.stat()
\r
521 Note that "facts" returned may change depending on the platform
\r
522 and on what user specified by using the OPTS command.
\r
524 This is how output could appear to the client issuing
\r
527 type=file;size=156;perm=r;modify=20071029155301;unique=8012; music.mp3
\r
528 type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks
\r
529 type=file;size=211;perm=r;modify=20071103093626;unique=192; module.py
\r
531 assert isinstance(basedir, unicode), basedir
\r
532 if self.cmd_channel.use_gmt_times:
\r
533 timefunc = time.gmtime
\r
535 timefunc = time.localtime
\r
536 permdir = ''.join([x for x in perms if x not in 'arw'])
\r
537 permfile = ''.join([x for x in perms if x not in 'celmp'])
\r
538 if ('w' in perms) or ('a' in perms) or ('f' in perms):
\r
542 show_type = 'type' in facts
\r
543 show_perm = 'perm' in facts
\r
544 show_size = 'size' in facts
\r
545 show_modify = 'modify' in facts
\r
546 show_create = 'create' in facts
\r
547 show_mode = 'unix.mode' in facts
\r
548 show_uid = 'unix.uid' in facts
\r
549 show_gid = 'unix.gid' in facts
\r
550 show_unique = 'unique' in facts
\r
551 for basename in listing:
\r
555 file = os.path.join(basedir, basename)
\r
556 except UnicodeDecodeError:
\r
557 # (Python 2 only) might happen on filesystem not
\r
558 # supporting UTF8 meaning os.listdir() returned a list
\r
559 # of mixed bytes and unicode strings:
\r
560 # http://goo.gl/6DLHD
\r
561 # http://bugs.python.org/issue683592
\r
562 file = os.path.join(bytes(basedir), bytes(basename))
\r
563 if not isinstance(basename, unicode):
\r
564 basename = unicode(basename, 'utf8', 'ignore')
\r
566 file = os.path.join(basedir, basename)
\r
567 # in order to properly implement 'unique' fact (RFC-3659,
\r
568 # chapter 7.5.2) we are supposed to follow symlinks, hence
\r
569 # use os.stat() instead of os.lstat()
\r
571 st = self.stat(file)
\r
572 except (OSError, FilesystemError):
\r
577 # same as stat.S_ISDIR(st.st_mode) but slightly faster
\r
578 isdir = (st.st_mode & 61440) == stat.S_IFDIR
\r
581 if basename == '.':
\r
582 retfacts['type'] = 'cdir'
\r
583 elif basename == '..':
\r
584 retfacts['type'] = 'pdir'
\r
586 retfacts['type'] = 'dir'
\r
588 retfacts['perm'] = permdir
\r
591 retfacts['type'] = 'file'
\r
593 retfacts['perm'] = permfile
\r
595 retfacts['size'] = st.st_size # file size
\r
596 # last modification time
\r
599 retfacts['modify'] = time.strftime("%Y%m%d%H%M%S",
\r
600 timefunc(st.st_mtime))
\r
601 # it could be raised if last mtime happens to be too old
\r
602 # (prior to year 1900)
\r
606 # on Windows we can provide also the creation time
\r
608 retfacts['create'] = time.strftime("%Y%m%d%H%M%S",
\r
609 timefunc(st.st_ctime))
\r
614 retfacts['unix.mode'] = oct(st.st_mode & 511)
\r
616 retfacts['unix.uid'] = st.st_uid
\r
618 retfacts['unix.gid'] = st.st_gid
\r
620 # We provide unique fact (see RFC-3659, chapter 7.5.2) on
\r
621 # posix platforms only; we get it by mixing st_dev and
\r
622 # st_ino values which should be enough for granting an
\r
623 # uniqueness for the file listed.
\r
624 # The same approach is used by pure-ftpd.
\r
625 # Implementors who want to provide unique fact on other
\r
626 # platforms should use some platform-specific method (e.g.
\r
627 # on Windows NTFS filesystems MTF records could be used).
\r
629 retfacts['unique'] = "%xg%x" % (st.st_dev, st.st_ino)
\r
631 # facts can be in any order but we sort them by name
\r
632 factstring = "".join(["%s=%s;" % (x, retfacts[x])
\r
633 for x in sorted(retfacts.keys())])
\r
634 line = "%s %s\r\n" % (factstring, basename)
\r
635 yield line.encode('utf8', self.cmd_channel.unicode_errors)
\r
638 # ===================================================================
\r
639 # --- platform specific implementation
\r
640 # ===================================================================
\r
642 if os.name == 'posix':
\r
643 __all__.append('UnixFilesystem')
\r
645 class UnixFilesystem(AbstractedFS):
\r
646 """Represents the real UNIX filesystem.
\r
648 Differently from AbstractedFS the client will login into
\r
649 /home/<username> and will be able to escape its home directory
\r
650 and navigate the real filesystem.
\r
653 def __init__(self, root, cmd_channel):
\r
654 AbstractedFS.__init__(self, root, cmd_channel)
\r
655 # initial cwd was set to "/" to emulate a chroot jail
\r
658 def ftp2fs(self, ftppath):
\r
659 return self.ftpnorm(ftppath)
\r
661 def fs2ftp(self, fspath):
\r
664 def validpath(self, path):
\r
665 # validpath was used to check symlinks escaping user home
\r
666 # directory; this is no longer necessary.
\r