pathutils.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2014 Jean-Marc Martins
  3. # Copyright © 2012-2017 Guillaume Ayoub
  4. # Copyright © 2017-2018 Unrud <unrud@outlook.com>
  5. #
  6. # This library is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. Helper functions for working with the file system.
  20. """
  21. import contextlib
  22. import os
  23. import posixpath
  24. import sys
  25. import threading
  26. from tempfile import TemporaryDirectory
  27. from typing import Type, Union
  28. if os.name == "nt":
  29. import ctypes
  30. import ctypes.wintypes
  31. import msvcrt
  32. LOCKFILE_EXCLUSIVE_LOCK = 2
  33. ULONG_PTR: Union[Type[ctypes.c_uint32], Type[ctypes.c_uint64]]
  34. if ctypes.sizeof(ctypes.c_void_p) == 4:
  35. ULONG_PTR = ctypes.c_uint32
  36. else:
  37. ULONG_PTR = ctypes.c_uint64
  38. class Overlapped(ctypes.Structure):
  39. _fields_ = [
  40. ("internal", ULONG_PTR),
  41. ("internal_high", ULONG_PTR),
  42. ("offset", ctypes.wintypes.DWORD),
  43. ("offset_high", ctypes.wintypes.DWORD),
  44. ("h_event", ctypes.wintypes.HANDLE)]
  45. kernel32 = ctypes.WinDLL( # type: ignore[attr-defined]
  46. "kernel32", use_last_error=True)
  47. lock_file_ex = kernel32.LockFileEx
  48. lock_file_ex.argtypes = [
  49. ctypes.wintypes.HANDLE,
  50. ctypes.wintypes.DWORD,
  51. ctypes.wintypes.DWORD,
  52. ctypes.wintypes.DWORD,
  53. ctypes.wintypes.DWORD,
  54. ctypes.POINTER(Overlapped)]
  55. lock_file_ex.restype = ctypes.wintypes.BOOL
  56. unlock_file_ex = kernel32.UnlockFileEx
  57. unlock_file_ex.argtypes = [
  58. ctypes.wintypes.HANDLE,
  59. ctypes.wintypes.DWORD,
  60. ctypes.wintypes.DWORD,
  61. ctypes.wintypes.DWORD,
  62. ctypes.POINTER(Overlapped)]
  63. unlock_file_ex.restype = ctypes.wintypes.BOOL
  64. elif os.name == "posix":
  65. import fcntl
  66. HAVE_RENAMEAT2 = False
  67. if sys.platform == "linux":
  68. import ctypes
  69. RENAME_EXCHANGE = 2
  70. try:
  71. renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
  72. except AttributeError:
  73. pass
  74. else:
  75. HAVE_RENAMEAT2 = True
  76. renameat2.argtypes = [
  77. ctypes.c_int, ctypes.c_char_p,
  78. ctypes.c_int, ctypes.c_char_p,
  79. ctypes.c_uint]
  80. renameat2.restype = ctypes.c_int
  81. class RwLock:
  82. """A readers-Writer lock that locks a file."""
  83. def __init__(self, path):
  84. self._path = path
  85. self._readers = 0
  86. self._writer = False
  87. self._lock = threading.Lock()
  88. @property
  89. def locked(self):
  90. with self._lock:
  91. if self._readers > 0:
  92. return "r"
  93. if self._writer:
  94. return "w"
  95. return ""
  96. @contextlib.contextmanager
  97. def acquire(self, mode):
  98. if mode not in "rw":
  99. raise ValueError("Invalid mode: %r" % mode)
  100. with open(self._path, "w+") as lock_file:
  101. if os.name == "nt":
  102. handle = msvcrt.get_osfhandle(lock_file.fileno())
  103. flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0
  104. overlapped = Overlapped()
  105. try:
  106. if not lock_file_ex(handle, flags, 0, 1, 0, overlapped):
  107. raise ctypes.WinError()
  108. except OSError as e:
  109. raise RuntimeError("Locking the storage failed: %s" %
  110. e) from e
  111. elif os.name == "posix":
  112. _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH
  113. try:
  114. fcntl.flock(lock_file.fileno(), _cmd)
  115. except OSError as e:
  116. raise RuntimeError("Locking the storage failed: %s" %
  117. e) from e
  118. else:
  119. raise RuntimeError("Locking the storage failed: "
  120. "Unsupported operating system")
  121. with self._lock:
  122. if self._writer or mode == "w" and self._readers != 0:
  123. raise RuntimeError("Locking the storage failed: "
  124. "Guarantees failed")
  125. if mode == "r":
  126. self._readers += 1
  127. else:
  128. self._writer = True
  129. try:
  130. yield
  131. finally:
  132. with self._lock:
  133. if mode == "r":
  134. self._readers -= 1
  135. self._writer = False
  136. def rename_exchange(src, dst):
  137. """Exchange the files or directories `src` and `dst`.
  138. Both `src` and `dst` must exist but may be of different types.
  139. On Linux with renameat2 the operation is atomic.
  140. On other platforms it's not atomic.
  141. """
  142. src_dir, src_base = os.path.split(src)
  143. dst_dir, dst_base = os.path.split(dst)
  144. src_dir = src_dir or os.curdir
  145. dst_dir = dst_dir or os.curdir
  146. if not src_base or not dst_base:
  147. raise ValueError("Invalid arguments: %r -> %r" % (src, dst))
  148. if HAVE_RENAMEAT2:
  149. src_base_bytes = os.fsencode(src_base)
  150. dst_base_bytes = os.fsencode(dst_base)
  151. src_dir_fd = os.open(src_dir, 0)
  152. try:
  153. dst_dir_fd = os.open(dst_dir, 0)
  154. try:
  155. if renameat2(src_dir_fd, src_base_bytes,
  156. dst_dir_fd, dst_base_bytes,
  157. RENAME_EXCHANGE) != 0:
  158. errno = ctypes.get_errno()
  159. raise OSError(errno, os.strerror(errno))
  160. finally:
  161. os.close(dst_dir_fd)
  162. finally:
  163. os.close(src_dir_fd)
  164. else:
  165. with TemporaryDirectory(
  166. prefix=".Radicale.tmp-", dir=src_dir) as tmp_dir:
  167. os.rename(dst, os.path.join(tmp_dir, "interim"))
  168. os.rename(src, dst)
  169. os.rename(os.path.join(tmp_dir, "interim"), src)
  170. def fsync(fd):
  171. if os.name == "posix" and hasattr(fcntl, "F_FULLFSYNC"):
  172. fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
  173. else:
  174. os.fsync(fd)
  175. def strip_path(path):
  176. assert sanitize_path(path) == path
  177. return path.strip("/")
  178. def unstrip_path(stripped_path, trailing_slash=False):
  179. assert strip_path(sanitize_path(stripped_path)) == stripped_path
  180. assert stripped_path or trailing_slash
  181. path = "/%s" % stripped_path
  182. if trailing_slash and not path.endswith("/"):
  183. path += "/"
  184. return path
  185. def sanitize_path(path):
  186. """Make path absolute with leading slash to prevent access to other data.
  187. Preserve potential trailing slash.
  188. """
  189. trailing_slash = "/" if path.endswith("/") else ""
  190. path = posixpath.normpath(path)
  191. new_path = "/"
  192. for part in path.split("/"):
  193. if not is_safe_path_component(part):
  194. continue
  195. new_path = posixpath.join(new_path, part)
  196. trailing_slash = "" if new_path.endswith("/") else trailing_slash
  197. return new_path + trailing_slash
  198. def is_safe_path_component(path):
  199. """Check if path is a single component of a path.
  200. Check that the path is safe to join too.
  201. """
  202. return path and "/" not in path and path not in (".", "..")
  203. def is_safe_filesystem_path_component(path):
  204. """Check if path is a single component of a local and posix filesystem
  205. path.
  206. Check that the path is safe to join too.
  207. """
  208. return (
  209. path and not os.path.splitdrive(path)[0] and
  210. not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
  211. not path.startswith(".") and not path.endswith("~") and
  212. is_safe_path_component(path))
  213. def path_to_filesystem(root, sane_path):
  214. """Convert `sane_path` to a local filesystem path relative to `root`.
  215. `root` must be a secure filesystem path, it will be prepend to the path.
  216. `sane_path` must be a sanitized path without leading or trailing ``/``.
  217. Conversion of `sane_path` is done in a secure manner,
  218. or raises ``ValueError``.
  219. """
  220. assert sane_path == strip_path(sanitize_path(sane_path))
  221. safe_path = root
  222. parts = sane_path.split("/") if sane_path else []
  223. for part in parts:
  224. if not is_safe_filesystem_path_component(part):
  225. raise UnsafePathError(part)
  226. safe_path_parent = safe_path
  227. safe_path = os.path.join(safe_path, part)
  228. # Check for conflicting files (e.g. case-insensitive file systems
  229. # or short names on Windows file systems)
  230. if (os.path.lexists(safe_path) and
  231. part not in (e.name for e in
  232. os.scandir(safe_path_parent))):
  233. raise CollidingPathError(part)
  234. return safe_path
  235. class UnsafePathError(ValueError):
  236. def __init__(self, path):
  237. message = "Can't translate name safely to filesystem: %r" % path
  238. super().__init__(message)
  239. class CollidingPathError(ValueError):
  240. def __init__(self, path):
  241. message = "File name collision: %r" % path
  242. super().__init__(message)
  243. def name_from_path(path, collection):
  244. """Return Radicale item name from ``path``."""
  245. assert sanitize_path(path) == path
  246. start = unstrip_path(collection.path, True)
  247. if not (path + "/").startswith(start):
  248. raise ValueError("%r doesn't start with %r" % (path, start))
  249. name = path[len(start):]
  250. if name and not is_safe_path_component(name):
  251. raise ValueError("%r is not a component in collection %r" %
  252. (name, collection.path))
  253. return name