plat_other.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. # Copyright 2017 Virgil Dupras
  2. # This software is licensed under the "BSD" License as described in the "LICENSE" file,
  3. # which should be included with this package. The terms are also available at
  4. # http://www.hardcoded.net/licenses/bsd_license
  5. # This is a reimplementation of plat_other.py with reference to the
  6. # freedesktop.org trash specification:
  7. # [1] http://www.freedesktop.org/wiki/Specifications/trash-spec
  8. # [2] http://www.ramendik.ru/docs/trashspec.html
  9. # See also:
  10. # [3] http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
  11. #
  12. # For external volumes this implementation will raise an exception if it can't
  13. # find or create the user's trash directory.
  14. from __future__ import unicode_literals
  15. import errno
  16. import shutil
  17. import os
  18. import os.path as op
  19. from datetime import datetime
  20. import stat
  21. from urllib.parse import quote
  22. from send2trash.util import preprocess_paths
  23. from send2trash.exceptions import TrashPermissionError
  24. FILES_DIR = b"files"
  25. INFO_DIR = b"info"
  26. INFO_SUFFIX = b".trashinfo"
  27. # Default of ~/.local/share [3]
  28. XDG_DATA_HOME = op.expanduser(os.environb.get(b"XDG_DATA_HOME", b"~/.local/share"))
  29. HOMETRASH_B = op.join(XDG_DATA_HOME, b"Trash")
  30. HOMETRASH = os.fsdecode(HOMETRASH_B)
  31. uid = os.getuid()
  32. TOPDIR_TRASH = b".Trash"
  33. TOPDIR_FALLBACK = b".Trash-" + str(uid).encode("ascii")
  34. def is_parent(parent, path):
  35. path = op.realpath(path) # In case it's a symlink
  36. if isinstance(path, str):
  37. path = os.fsencode(path)
  38. parent = op.realpath(parent)
  39. if isinstance(parent, str):
  40. parent = os.fsencode(parent)
  41. return path.startswith(parent)
  42. def format_date(date):
  43. return date.strftime("%Y-%m-%dT%H:%M:%S")
  44. def info_for(src, topdir):
  45. # ...it MUST not include a ".." directory, and for files not "under" that
  46. # directory, absolute pathnames must be used. [2]
  47. if topdir is None or not is_parent(topdir, src):
  48. src = op.abspath(src)
  49. else:
  50. src = op.relpath(src, topdir)
  51. info = "[Trash Info]\n"
  52. info += "Path=" + quote(src) + "\n"
  53. info += "DeletionDate=" + format_date(datetime.now()) + "\n"
  54. return info
  55. def check_create(folder):
  56. # use 0700 for paths [3]
  57. if not op.exists(folder):
  58. os.makedirs(folder, 0o700)
  59. def trash_move(src, dst, topdir=None, cross_dev=False):
  60. file_name = op.basename(src)
  61. files_path = op.join(dst, FILES_DIR)
  62. info_path = op.join(dst, INFO_DIR)
  63. base_name, ext = op.splitext(file_name)
  64. counter = 0
  65. dest_name = file_name
  66. while op.exists(op.join(files_path, dest_name)) or op.exists(op.join(info_path, dest_name + INFO_SUFFIX)):
  67. counter += 1
  68. dest_name = base_name + b" " + str(counter).encode("ascii") + ext
  69. check_create(files_path)
  70. check_create(info_path)
  71. with open(op.join(info_path, dest_name + INFO_SUFFIX), "w") as f:
  72. f.write(info_for(src, topdir))
  73. dest_path = op.join(files_path, dest_name)
  74. if cross_dev:
  75. shutil.move(os.fsdecode(src), os.fsdecode(dest_path))
  76. else:
  77. os.rename(src, dest_path)
  78. def find_mount_point(path):
  79. # Even if something's wrong, "/" is a mount point, so the loop will exit.
  80. # Use realpath in case it's a symlink
  81. path = op.realpath(path) # Required to avoid infinite loop
  82. while not op.ismount(path): # Note ismount() does not always detect mounts
  83. path = op.split(path)[0]
  84. return path
  85. def find_ext_volume_global_trash(volume_root):
  86. # from [2] Trash directories (1) check for a .Trash dir with the right
  87. # permissions set.
  88. trash_dir = op.join(volume_root, TOPDIR_TRASH)
  89. if not op.exists(trash_dir):
  90. return None
  91. mode = os.lstat(trash_dir).st_mode
  92. # vol/.Trash must be a directory, cannot be a symlink, and must have the
  93. # sticky bit set.
  94. if not op.isdir(trash_dir) or op.islink(trash_dir) or not mode & stat.S_ISVTX:
  95. return None
  96. trash_dir = op.join(trash_dir, str(uid).encode("ascii"))
  97. try:
  98. check_create(trash_dir)
  99. except OSError:
  100. return None
  101. return trash_dir
  102. def find_ext_volume_fallback_trash(volume_root):
  103. # from [2] Trash directories (1) create a .Trash-$uid dir.
  104. trash_dir = op.join(volume_root, TOPDIR_FALLBACK)
  105. # Try to make the directory, if we lack permission, raise TrashPermissionError
  106. try:
  107. check_create(trash_dir)
  108. except OSError as e:
  109. if e.errno == errno.EACCES:
  110. raise TrashPermissionError(e.filename) from e
  111. raise
  112. return trash_dir
  113. def find_ext_volume_trash(volume_root):
  114. trash_dir = find_ext_volume_global_trash(volume_root)
  115. if trash_dir is None:
  116. trash_dir = find_ext_volume_fallback_trash(volume_root)
  117. return trash_dir
  118. # Pull this out so it's easy to stub (to avoid stubbing lstat itself)
  119. def get_dev(path):
  120. return os.lstat(path).st_dev
  121. def send2trash(paths):
  122. paths = preprocess_paths(paths)
  123. for path in paths:
  124. if isinstance(path, str):
  125. path_b = os.fsencode(path)
  126. elif isinstance(path, bytes):
  127. path_b = path
  128. else:
  129. raise TypeError(f"str, bytes or PathLike expected, not {type(path)}")
  130. if not op.exists(path_b):
  131. raise OSError(errno.ENOENT, f"File not found: {path}")
  132. # ...should check whether the user has the necessary permissions to delete
  133. # it, before starting the trashing operation itself. [2]
  134. if not os.access(path_b, os.W_OK):
  135. raise OSError(errno.EACCES, f"Permission denied: {path}")
  136. path_dev = get_dev(path_b)
  137. # If XDG_DATA_HOME or HOMETRASH do not yet exist we need to stat the
  138. # home directory, and these paths will be created further on if needed.
  139. trash_dev = get_dev(op.expanduser(b"~"))
  140. # if the file to be trashed is on the same device as HOMETRASH we
  141. # want to move it there.
  142. if path_dev == trash_dev:
  143. topdir = XDG_DATA_HOME
  144. dest_trash = HOMETRASH_B
  145. else:
  146. topdir = find_mount_point(path_b)
  147. trash_dev = get_dev(topdir)
  148. if trash_dev != path_dev:
  149. raise OSError(f"Couldn't find mount point for {path}")
  150. dest_trash = find_ext_volume_trash(topdir)
  151. try:
  152. trash_move(path_b, dest_trash, topdir)
  153. except OSError as error:
  154. # Cross link errors default back to HOMETRASH
  155. if error.errno == errno.EXDEV:
  156. trash_move(path_b, HOMETRASH_B, XDG_DATA_HOME, cross_dev=True)
  157. else:
  158. raise