storage.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2014 Jean-Marc Martins
  3. # Copyright © 2012-2016 Guillaume Ayoub
  4. #
  5. # This library is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # (at your option) any later version.
  9. #
  10. # This library is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  17. """
  18. Storage backends.
  19. This module loads the storage backend, according to the storage configuration.
  20. Default storage uses one folder per collection and one file per collection
  21. entry.
  22. """
  23. import errno
  24. import json
  25. import os
  26. import posixpath
  27. import stat
  28. import threading
  29. import time
  30. from contextlib import contextmanager
  31. from hashlib import md5
  32. from importlib import import_module
  33. from itertools import groupby
  34. from random import getrandbits
  35. from tempfile import TemporaryDirectory
  36. from atomicwrites import AtomicWriter
  37. import vobject
  38. if os.name == "nt":
  39. import ctypes
  40. import ctypes.wintypes
  41. import msvcrt
  42. LOCKFILE_EXCLUSIVE_LOCK = 2
  43. if ctypes.sizeof(ctypes.c_void_p) == 4:
  44. ULONG_PTR = ctypes.c_uint32
  45. else:
  46. ULONG_PTR = ctypes.c_uint64
  47. class Overlapped(ctypes.Structure):
  48. _fields_ = [
  49. ("internal", ULONG_PTR),
  50. ("internal_high", ULONG_PTR),
  51. ("offset", ctypes.wintypes.DWORD),
  52. ("offset_high", ctypes.wintypes.DWORD),
  53. ("h_event", ctypes.wintypes.HANDLE)]
  54. lock_file_ex = ctypes.windll.kernel32.LockFileEx
  55. lock_file_ex.argtypes = [
  56. ctypes.wintypes.HANDLE,
  57. ctypes.wintypes.DWORD,
  58. ctypes.wintypes.DWORD,
  59. ctypes.wintypes.DWORD,
  60. ctypes.wintypes.DWORD,
  61. ctypes.POINTER(Overlapped)]
  62. lock_file_ex.restype = ctypes.wintypes.BOOL
  63. unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx
  64. unlock_file_ex.argtypes = [
  65. ctypes.wintypes.HANDLE,
  66. ctypes.wintypes.DWORD,
  67. ctypes.wintypes.DWORD,
  68. ctypes.wintypes.DWORD,
  69. ctypes.POINTER(Overlapped)]
  70. unlock_file_ex.restype = ctypes.wintypes.BOOL
  71. elif os.name == "posix":
  72. import fcntl
  73. def load(configuration, logger):
  74. """Load the storage manager chosen in configuration."""
  75. storage_type = configuration.get("storage", "type")
  76. if storage_type == "multifilesystem":
  77. collection_class = Collection
  78. else:
  79. collection_class = import_module(storage_type).Collection
  80. class CollectionCopy(collection_class):
  81. """Collection copy, avoids overriding the original class attributes."""
  82. CollectionCopy.configuration = configuration
  83. CollectionCopy.logger = logger
  84. return CollectionCopy
  85. def get_etag(text):
  86. """Etag from collection or item."""
  87. etag = md5()
  88. etag.update(text.encode("utf-8"))
  89. return '"%s"' % etag.hexdigest()
  90. def get_uid(item):
  91. """UID value of an item if defined."""
  92. return hasattr(item, "uid") and item.uid.value
  93. def sanitize_path(path):
  94. """Make path absolute with leading slash to prevent access to other data.
  95. Preserve a potential trailing slash.
  96. """
  97. trailing_slash = "/" if path.endswith("/") else ""
  98. path = posixpath.normpath(path)
  99. new_path = "/"
  100. for part in path.split("/"):
  101. if not part or part in (".", ".."):
  102. continue
  103. new_path = posixpath.join(new_path, part)
  104. trailing_slash = "" if new_path.endswith("/") else trailing_slash
  105. return new_path + trailing_slash
  106. def is_safe_path_component(path):
  107. """Check if path is a single component of a path.
  108. Check that the path is safe to join too.
  109. """
  110. return path and "/" not in path and path not in (".", "..")
  111. def is_safe_filesystem_path_component(path):
  112. """Check if path is a single component of a filesystem path.
  113. Check that the path is safe to join too.
  114. """
  115. return (
  116. path and not os.path.splitdrive(path)[0] and
  117. not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
  118. not path.startswith(".") and not path.endswith("~"))
  119. def path_to_filesystem(root, *paths):
  120. """Convert path to a local filesystem path relative to base_folder.
  121. `root` must be a secure filesystem path, it will be prepend to the path.
  122. Conversion of `paths` is done in a secure manner, or raises ``ValueError``.
  123. """
  124. paths = [sanitize_path(path).strip("/") for path in paths]
  125. safe_path = root
  126. for path in paths:
  127. if not path:
  128. continue
  129. for part in path.split("/"):
  130. if not is_safe_filesystem_path_component(part):
  131. raise UnsafePathError(part)
  132. safe_path = os.path.join(safe_path, part)
  133. return safe_path
  134. class UnsafePathError(ValueError):
  135. def __init__(self, path):
  136. message = "Can't translate name safely to filesystem: %s" % path
  137. super().__init__(message)
  138. class ComponentExistsError(ValueError):
  139. def __init__(self, path):
  140. message = "Component already exists: %s" % path
  141. super().__init__(message)
  142. class ComponentNotFoundError(ValueError):
  143. def __init__(self, path):
  144. message = "Component doesn't exist: %s" % path
  145. super().__init__(message)
  146. class EtagMismatchError(ValueError):
  147. def __init__(self, etag1, etag2):
  148. message = "ETags don't match: %s != %s" % (etag1, etag2)
  149. super().__init__(message)
  150. class _EncodedAtomicWriter(AtomicWriter):
  151. def __init__(self, path, encoding, mode="w", overwrite=True):
  152. self._encoding = encoding
  153. return super().__init__(path, mode, overwrite=True)
  154. def get_fileobject(self, **kwargs):
  155. return super().get_fileobject(
  156. encoding=self._encoding, prefix=".Radicale.tmp-", **kwargs)
  157. class Item:
  158. def __init__(self, collection, item, href, last_modified=None):
  159. self.collection = collection
  160. self.item = item
  161. self.href = href
  162. self.last_modified = last_modified
  163. def __getattr__(self, attr):
  164. return getattr(self.item, attr)
  165. @property
  166. def etag(self):
  167. return get_etag(self.serialize())
  168. class BaseCollection:
  169. # Overriden on copy by the "load" function
  170. configuration = None
  171. logger = None
  172. def __init__(self, path, principal=False):
  173. """Initialize the collection.
  174. ``path`` must be the normalized relative path of the collection, using
  175. the slash as the folder delimiter, with no leading nor trailing slash.
  176. """
  177. raise NotImplementedError
  178. @classmethod
  179. def discover(cls, path, depth="0"):
  180. """Discover a list of collections under the given ``path``.
  181. If ``depth`` is "0", only the actual object under ``path`` is
  182. returned.
  183. If ``depth`` is anything but "0", it is considered as "1" and direct
  184. children are included in the result.
  185. The ``path`` is relative.
  186. The root collection "/" must always exist.
  187. """
  188. raise NotImplementedError
  189. @classmethod
  190. def move(cls, item, to_collection, to_href):
  191. """Move an object.
  192. ``item`` is the item to move.
  193. ``to_collection`` is the target collection.
  194. ``to_href`` is the target name in ``to_collection``. An item with the
  195. same name might already exist.
  196. """
  197. if item.collection.path == to_collection.path and item.href == to_href:
  198. return
  199. if to_collection.has(to_href):
  200. to_collection.update(to_href, item.item)
  201. else:
  202. to_collection.upload(to_href, item.item)
  203. item.collection.delete(item.href)
  204. @property
  205. def etag(self):
  206. return get_etag(self.serialize())
  207. @classmethod
  208. def create_collection(cls, href, collection=None, props=None):
  209. """Create a collection.
  210. ``collection`` is a list of vobject components.
  211. ``props`` are metadata values for the collection.
  212. ``props["tag"]`` is the type of collection (VCALENDAR or
  213. VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
  214. collection.
  215. """
  216. raise NotImplementedError
  217. def list(self):
  218. """List collection items."""
  219. raise NotImplementedError
  220. def get(self, href):
  221. """Fetch a single item."""
  222. raise NotImplementedError
  223. def get_multi(self, hrefs):
  224. """Fetch multiple items. Duplicate hrefs must be ignored.
  225. Functionally similar to ``get``, but might bring performance benefits
  226. on some storages when used cleverly.
  227. """
  228. for href in set(hrefs):
  229. yield self.get(href)
  230. def pre_filtered_list(self, filters):
  231. """List collection items with optional pre filtering.
  232. This could largely improve performance of reports depending on
  233. the filters and this implementation.
  234. This returns all event by default
  235. """
  236. return [self.get(href) for href, _ in self.list()]
  237. def has(self, href):
  238. """Check if an item exists by its href.
  239. Functionally similar to ``get``, but might bring performance benefits
  240. on some storages when used cleverly.
  241. """
  242. return self.get(href) is not None
  243. def upload(self, href, vobject_item):
  244. """Upload a new item."""
  245. raise NotImplementedError
  246. def update(self, href, vobject_item, etag=None):
  247. """Update an item.
  248. Functionally similar to ``delete`` plus ``upload``, but might bring
  249. performance benefits on some storages when used cleverly.
  250. """
  251. self.delete(href, etag)
  252. self.upload(href, vobject_item)
  253. def delete(self, href=None, etag=None):
  254. """Delete an item.
  255. When ``href`` is ``None``, delete the collection.
  256. """
  257. raise NotImplementedError
  258. def get_meta(self, key):
  259. """Get metadata value for collection."""
  260. raise NotImplementedError
  261. def set_meta(self, props):
  262. """Set metadata values for collection."""
  263. raise NotImplementedError
  264. @property
  265. def last_modified(self):
  266. """Get the HTTP-datetime of when the collection was modified."""
  267. raise NotImplementedError
  268. def serialize(self):
  269. """Get the unicode string representing the whole collection."""
  270. raise NotImplementedError
  271. @classmethod
  272. @contextmanager
  273. def acquire_lock(cls, mode):
  274. """Set a context manager to lock the whole storage.
  275. ``mode`` must either be "r" for shared access or "w" for exclusive
  276. access.
  277. """
  278. raise NotImplementedError
  279. class Collection(BaseCollection):
  280. """Collection stored in several files per calendar."""
  281. def __init__(self, path, principal=False, folder=None):
  282. if not folder:
  283. folder = self._get_collection_root_folder()
  284. # Path should already be sanitized
  285. self.path = sanitize_path(path).strip("/")
  286. self.encoding = self.configuration.get("encoding", "stock")
  287. self._filesystem_path = path_to_filesystem(folder, self.path)
  288. self._props_path = os.path.join(
  289. self._filesystem_path, ".Radicale.props")
  290. split_path = self.path.split("/")
  291. self.owner = split_path[0] if len(split_path) > 1 else None
  292. self.is_principal = principal
  293. @classmethod
  294. def _get_collection_root_folder(cls):
  295. filesystem_folder = os.path.expanduser(
  296. cls.configuration.get("storage", "filesystem_folder"))
  297. return os.path.join(filesystem_folder, "collection-root")
  298. @contextmanager
  299. def _atomic_write(self, path, mode="w"):
  300. with _EncodedAtomicWriter(path, self.encoding, mode).open() as fd:
  301. yield fd
  302. def _find_available_file_name(self):
  303. # Prevent infinite loop
  304. for _ in range(10000):
  305. file_name = hex(getrandbits(32))[2:]
  306. if not self.has(file_name):
  307. return file_name
  308. raise FileExistsError(errno.EEXIST, "No usable file name found")
  309. @staticmethod
  310. def _sync_directory(path):
  311. """Sync directory to disk.
  312. This only works on POSIX and does nothing on other systems.
  313. """
  314. if os.name == "posix":
  315. fd = os.open(path, 0)
  316. try:
  317. if hasattr(fcntl, "F_FULLFSYNC"):
  318. fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
  319. else:
  320. os.fsync(fd)
  321. finally:
  322. os.close(fd)
  323. @classmethod
  324. def _makedirs_synced(cls, filesystem_path):
  325. """Recursively create a directory and its parents in a sync'ed way.
  326. This method acts silently when the folder already exists.
  327. """
  328. if os.path.isdir(filesystem_path):
  329. return
  330. parent_filesystem_path = os.path.dirname(filesystem_path)
  331. # Prevent infinite loop
  332. if filesystem_path != parent_filesystem_path:
  333. # Create parent dirs recursively
  334. cls._makedirs_synced(parent_filesystem_path)
  335. # Possible race!
  336. os.makedirs(filesystem_path, exist_ok=True)
  337. cls._sync_directory(parent_filesystem_path)
  338. @classmethod
  339. def discover(cls, path, depth="0"):
  340. if path is None:
  341. # Wrong URL
  342. return
  343. # Path should already be sanitized
  344. sane_path = sanitize_path(path).strip("/")
  345. attributes = sane_path.split("/")
  346. if not attributes[0]:
  347. attributes.pop()
  348. folder = cls._get_collection_root_folder()
  349. # Create the root collection
  350. cls._makedirs_synced(folder)
  351. try:
  352. filesystem_path = path_to_filesystem(folder, sane_path)
  353. except ValueError:
  354. # Path is unsafe
  355. return
  356. # Check if the path exists and if it leads to a collection or an item
  357. if not os.path.isdir(filesystem_path):
  358. if attributes and os.path.isfile(filesystem_path):
  359. href = attributes.pop()
  360. else:
  361. return
  362. else:
  363. href = None
  364. path = "/".join(attributes)
  365. principal = len(attributes) == 1
  366. collection = cls(path, principal)
  367. if href:
  368. yield collection.get(href)
  369. return
  370. yield collection
  371. if depth == "0":
  372. return
  373. for item in collection.list():
  374. yield collection.get(item[0])
  375. for href in os.listdir(filesystem_path):
  376. if not is_safe_filesystem_path_component(href):
  377. cls.logger.debug("Skipping collection: %s", href)
  378. continue
  379. child_filesystem_path = path_to_filesystem(filesystem_path, href)
  380. if os.path.isdir(child_filesystem_path):
  381. child_path = posixpath.join(path, href)
  382. child_principal = len(attributes) == 0
  383. yield cls(child_path, child_principal)
  384. @classmethod
  385. def create_collection(cls, href, collection=None, props=None):
  386. folder = cls._get_collection_root_folder()
  387. # Path should already be sanitized
  388. sane_path = sanitize_path(href).strip("/")
  389. attributes = sane_path.split("/")
  390. if not attributes[0]:
  391. attributes.pop()
  392. principal = len(attributes) == 1
  393. filesystem_path = path_to_filesystem(folder, sane_path)
  394. if not props:
  395. props = {}
  396. if not props.get("tag") and collection:
  397. props["tag"] = collection[0].name
  398. if not props:
  399. cls._makedirs_synced(filesystem_path)
  400. return cls(sane_path, principal=principal)
  401. parent_dir = os.path.dirname(filesystem_path)
  402. cls._makedirs_synced(parent_dir)
  403. # Create a temporary directory with an unsafe name
  404. with TemporaryDirectory(
  405. prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir:
  406. # The temporary directory itself can't be renamed
  407. tmp_filesystem_path = os.path.join(tmp_dir, "collection")
  408. os.makedirs(tmp_filesystem_path)
  409. self = cls("/", principal=principal, folder=tmp_filesystem_path)
  410. self.set_meta(props)
  411. if collection:
  412. if props.get("tag") == "VCALENDAR":
  413. collection, = collection
  414. items = []
  415. for content in ("vevent", "vtodo", "vjournal"):
  416. items.extend(
  417. getattr(collection, "%s_list" % content, []))
  418. items_by_uid = groupby(sorted(items, key=get_uid), get_uid)
  419. for uid, items in items_by_uid:
  420. new_collection = vobject.iCalendar()
  421. for item in items:
  422. new_collection.add(item)
  423. self.upload(
  424. self._find_available_file_name(), new_collection)
  425. elif props.get("tag") == "VCARD":
  426. for card in collection:
  427. self.upload(self._find_available_file_name(), card)
  428. os.rename(tmp_filesystem_path, filesystem_path)
  429. cls._sync_directory(parent_dir)
  430. return cls(sane_path, principal=principal)
  431. @classmethod
  432. def move(cls, item, to_collection, to_href):
  433. os.rename(
  434. path_to_filesystem(item.collection._filesystem_path, item.href),
  435. path_to_filesystem(to_collection._filesystem_path, to_href))
  436. cls._sync_directory(to_collection._filesystem_path)
  437. if item.collection._filesystem_path != to_collection._filesystem_path:
  438. cls._sync_directory(item.collection._filesystem_path)
  439. def list(self):
  440. try:
  441. hrefs = os.listdir(self._filesystem_path)
  442. except IOError:
  443. return
  444. for href in hrefs:
  445. if not is_safe_filesystem_path_component(href):
  446. self.logger.debug("Skipping component: %s", href)
  447. continue
  448. path = os.path.join(self._filesystem_path, href)
  449. if os.path.isfile(path):
  450. with open(path, encoding=self.encoding) as fd:
  451. yield href, get_etag(fd.read())
  452. def get(self, href):
  453. if not href:
  454. return None
  455. href = href.strip("{}").replace("/", "_")
  456. if not is_safe_filesystem_path_component(href):
  457. self.logger.debug(
  458. "Can't translate name safely to filesystem: %s", href)
  459. return None
  460. path = path_to_filesystem(self._filesystem_path, href)
  461. if not os.path.isfile(path):
  462. return None
  463. with open(path, encoding=self.encoding) as fd:
  464. text = fd.read()
  465. last_modified = time.strftime(
  466. "%a, %d %b %Y %H:%M:%S GMT",
  467. time.gmtime(os.path.getmtime(path)))
  468. return Item(self, vobject.readOne(text), href, last_modified)
  469. def has(self, href):
  470. return self.get(href) is not None
  471. def upload(self, href, vobject_item):
  472. if not is_safe_filesystem_path_component(href):
  473. raise UnsafePathError(href)
  474. path = path_to_filesystem(self._filesystem_path, href)
  475. if os.path.exists(path):
  476. raise ComponentExistsError(href)
  477. item = Item(self, vobject_item, href)
  478. with self._atomic_write(path) as fd:
  479. fd.write(item.serialize())
  480. return item
  481. def update(self, href, vobject_item, etag=None):
  482. if not is_safe_filesystem_path_component(href):
  483. raise UnsafePathError(href)
  484. path = path_to_filesystem(self._filesystem_path, href)
  485. if not os.path.isfile(path):
  486. raise ComponentNotFoundError(href)
  487. with open(path, encoding=self.encoding) as fd:
  488. text = fd.read()
  489. if etag and etag != get_etag(text):
  490. raise EtagMismatchError(etag, get_etag(text))
  491. item = Item(self, vobject_item, href)
  492. with self._atomic_write(path) as fd:
  493. fd.write(item.serialize())
  494. return item
  495. def delete(self, href=None, etag=None):
  496. if href is None:
  497. # Delete the collection
  498. if os.path.isdir(self._filesystem_path):
  499. parent_dir = os.path.dirname(self._filesystem_path)
  500. try:
  501. os.rmdir(self._filesystem_path)
  502. except OSError:
  503. with TemporaryDirectory(prefix=".Radicale.tmp-",
  504. dir=parent_dir) as tmp_dir:
  505. os.rename(self._filesystem_path, os.path.join(
  506. tmp_dir, os.path.basename(self._filesystem_path)))
  507. self._sync_directory(parent_dir)
  508. else:
  509. self._sync_directory(parent_dir)
  510. else:
  511. # Delete an item
  512. if not is_safe_filesystem_path_component(href):
  513. raise UnsafePathError(href)
  514. path = path_to_filesystem(self._filesystem_path, href)
  515. if not os.path.isfile(path):
  516. raise ComponentNotFoundError(href)
  517. with open(path, encoding=self.encoding) as fd:
  518. text = fd.read()
  519. if etag and etag != get_etag(text):
  520. raise EtagMismatchError(etag, get_etag(text))
  521. os.remove(path)
  522. self._sync_directory(os.path.dirname(path))
  523. def get_meta(self, key):
  524. if os.path.exists(self._props_path):
  525. with open(self._props_path, encoding=self.encoding) as prop:
  526. return json.load(prop).get(key)
  527. def set_meta(self, props):
  528. if os.path.exists(self._props_path):
  529. with open(self._props_path, encoding=self.encoding) as prop:
  530. old_props = json.load(prop)
  531. old_props.update(props)
  532. props = old_props
  533. props = {key: value for key, value in props.items() if value}
  534. with self._atomic_write(self._props_path, "w+") as prop:
  535. json.dump(props, prop)
  536. @property
  537. def last_modified(self):
  538. last = max([os.path.getmtime(self._filesystem_path)] + [
  539. os.path.getmtime(os.path.join(self._filesystem_path, filename))
  540. for filename in os.listdir(self._filesystem_path)] or [0])
  541. return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
  542. def serialize(self):
  543. if not os.path.exists(self._filesystem_path):
  544. return None
  545. items = []
  546. for href in os.listdir(self._filesystem_path):
  547. if not is_safe_filesystem_path_component(href):
  548. self.logger.debug("Skipping component: %s", href)
  549. continue
  550. path = os.path.join(self._filesystem_path, href)
  551. if os.path.isfile(path):
  552. with open(path, encoding=self.encoding) as fd:
  553. items.append(vobject.readOne(fd.read()))
  554. if self.get_meta("tag") == "VCALENDAR":
  555. collection = vobject.iCalendar()
  556. for item in items:
  557. for content in ("vevent", "vtodo", "vjournal"):
  558. if content in item.contents:
  559. for item_part in getattr(item, "%s_list" % content):
  560. collection.add(item_part)
  561. break
  562. return collection.serialize()
  563. elif self.get_meta("tag") == "VADDRESSBOOK":
  564. return "".join([item.serialize() for item in items])
  565. return ""
  566. _lock = threading.Lock()
  567. _waiters = []
  568. _lock_file = None
  569. _lock_file_locked = False
  570. _readers = 0
  571. _writer = False
  572. @classmethod
  573. @contextmanager
  574. def acquire_lock(cls, mode):
  575. def condition():
  576. if mode == "r":
  577. return not cls._writer
  578. else:
  579. return not cls._writer and cls._readers == 0
  580. # Use a primitive lock which only works within one process as a
  581. # precondition for inter-process file-based locking
  582. with cls._lock:
  583. if cls._waiters or not condition():
  584. # Use FIFO for access requests
  585. waiter = threading.Condition(lock=cls._lock)
  586. cls._waiters.append(waiter)
  587. while True:
  588. waiter.wait()
  589. if condition():
  590. break
  591. cls._waiters.pop(0)
  592. if mode == "r":
  593. cls._readers += 1
  594. # Notify additional potential readers
  595. if cls._waiters:
  596. cls._waiters[0].notify()
  597. else:
  598. cls._writer = True
  599. if not cls._lock_file:
  600. folder = os.path.expanduser(
  601. cls.configuration.get("storage", "filesystem_folder"))
  602. cls._makedirs_synced(folder)
  603. lock_path = os.path.join(folder, ".Radicale.lock")
  604. cls._lock_file = open(lock_path, "w+")
  605. # Set access rights to a necessary minimum to prevent locking
  606. # by arbitrary users
  607. try:
  608. os.chmod(lock_path, stat.S_IWUSR | stat.S_IRUSR)
  609. except OSError:
  610. cls.logger.debug("Failed to set permissions on lock file")
  611. if not cls._lock_file_locked:
  612. if os.name == "nt":
  613. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  614. flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0
  615. overlapped = Overlapped()
  616. if not lock_file_ex(handle, flags, 0, 1, 0, overlapped):
  617. cls.logger.debug("Locking not supported")
  618. elif os.name == "posix":
  619. _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH
  620. try:
  621. fcntl.flock(cls._lock_file.fileno(), _cmd)
  622. except OSError:
  623. cls.logger.debug("Locking not supported")
  624. cls._lock_file_locked = True
  625. try:
  626. yield
  627. finally:
  628. with cls._lock:
  629. if mode == "r":
  630. cls._readers -= 1
  631. else:
  632. cls._writer = False
  633. if cls._readers == 0:
  634. if os.name == "nt":
  635. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  636. overlapped = Overlapped()
  637. if not unlock_file_ex(handle, 0, 1, 0, overlapped):
  638. cls.logger.debug("Unlocking not supported")
  639. elif os.name == "posix":
  640. try:
  641. fcntl.flock(cls._lock_file.fileno(), fcntl.LOCK_UN)
  642. except OSError:
  643. cls.logger.debug("Unlocking not supported")
  644. cls._lock_file_locked = False
  645. if cls._waiters:
  646. cls._waiters[0].notify()