storage.py 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2014 Jean-Marc Martins
  3. # Copyright © 2012-2017 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 binascii
  24. import contextlib
  25. import json
  26. import os
  27. import pickle
  28. import posixpath
  29. import shlex
  30. import stat
  31. import subprocess
  32. import sys
  33. import threading
  34. import time
  35. from contextlib import contextmanager
  36. from hashlib import md5
  37. from importlib import import_module
  38. from itertools import chain, groupby
  39. from random import getrandbits
  40. from tempfile import NamedTemporaryFile, TemporaryDirectory
  41. import vobject
  42. if sys.version_info >= (3, 5):
  43. # HACK: Avoid import cycle for Python < 3.5
  44. from . import xmlutils
  45. if os.name == "nt":
  46. import ctypes
  47. import ctypes.wintypes
  48. import msvcrt
  49. LOCKFILE_EXCLUSIVE_LOCK = 2
  50. if ctypes.sizeof(ctypes.c_void_p) == 4:
  51. ULONG_PTR = ctypes.c_uint32
  52. else:
  53. ULONG_PTR = ctypes.c_uint64
  54. class Overlapped(ctypes.Structure):
  55. _fields_ = [
  56. ("internal", ULONG_PTR),
  57. ("internal_high", ULONG_PTR),
  58. ("offset", ctypes.wintypes.DWORD),
  59. ("offset_high", ctypes.wintypes.DWORD),
  60. ("h_event", ctypes.wintypes.HANDLE)]
  61. lock_file_ex = ctypes.windll.kernel32.LockFileEx
  62. lock_file_ex.argtypes = [
  63. ctypes.wintypes.HANDLE,
  64. ctypes.wintypes.DWORD,
  65. ctypes.wintypes.DWORD,
  66. ctypes.wintypes.DWORD,
  67. ctypes.wintypes.DWORD,
  68. ctypes.POINTER(Overlapped)]
  69. lock_file_ex.restype = ctypes.wintypes.BOOL
  70. unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx
  71. unlock_file_ex.argtypes = [
  72. ctypes.wintypes.HANDLE,
  73. ctypes.wintypes.DWORD,
  74. ctypes.wintypes.DWORD,
  75. ctypes.wintypes.DWORD,
  76. ctypes.POINTER(Overlapped)]
  77. unlock_file_ex.restype = ctypes.wintypes.BOOL
  78. elif os.name == "posix":
  79. import fcntl
  80. def load(configuration, logger):
  81. """Load the storage manager chosen in configuration."""
  82. if sys.version_info < (3, 5):
  83. # HACK: Avoid import cycle for Python < 3.5
  84. global xmlutils
  85. from . import xmlutils
  86. storage_type = configuration.get("storage", "type")
  87. if storage_type == "multifilesystem":
  88. collection_class = Collection
  89. else:
  90. try:
  91. collection_class = import_module(storage_type).Collection
  92. except ImportError as e:
  93. raise RuntimeError("Storage module %r not found" %
  94. storage_type) from e
  95. logger.info("Storage type is %r", storage_type)
  96. class CollectionCopy(collection_class):
  97. """Collection copy, avoids overriding the original class attributes."""
  98. CollectionCopy.configuration = configuration
  99. CollectionCopy.logger = logger
  100. return CollectionCopy
  101. def check_item(vobject_item):
  102. """Check vobject items for common errors."""
  103. if vobject_item.name == "VCALENDAR":
  104. for component in vobject_item.components():
  105. if (component.name in ("VTODO", "VEVENT", "VJOURNAL") and
  106. not get_uid(component)):
  107. raise ValueError("UID in %s is missing" % component.name)
  108. elif vobject_item.name == "VCARD":
  109. if not get_uid(vobject_item):
  110. raise ValueError("UID in VCARD is missing")
  111. else:
  112. raise ValueError("Unknown item type: %r" % vobject_item.name)
  113. def scandir(path, only_dirs=False, only_files=False):
  114. """Iterator for directory elements. (For compatibility with Python < 3.5)
  115. ``only_dirs`` only return directories
  116. ``only_files`` only return files
  117. """
  118. if sys.version_info >= (3, 5):
  119. for entry in os.scandir(path):
  120. if ((not only_files or entry.is_file()) and
  121. (not only_dirs or entry.is_dir())):
  122. yield entry.name
  123. else:
  124. for name in os.listdir(path):
  125. p = os.path.join(path, name)
  126. if ((not only_files or os.path.isfile(p)) and
  127. (not only_dirs or os.path.isdir(p))):
  128. yield name
  129. def get_etag(text):
  130. """Etag from collection or item.
  131. Encoded as quoted-string (see RFC 2616).
  132. """
  133. etag = md5()
  134. etag.update(text.encode("utf-8"))
  135. return '"%s"' % etag.hexdigest()
  136. def get_uid(item):
  137. """UID value of an item if defined."""
  138. return (hasattr(item, "uid") or None) and item.uid.value
  139. def sanitize_path(path):
  140. """Make path absolute with leading slash to prevent access to other data.
  141. Preserve a potential trailing slash.
  142. """
  143. trailing_slash = "/" if path.endswith("/") else ""
  144. path = posixpath.normpath(path)
  145. new_path = "/"
  146. for part in path.split("/"):
  147. if not is_safe_path_component(part):
  148. continue
  149. new_path = posixpath.join(new_path, part)
  150. trailing_slash = "" if new_path.endswith("/") else trailing_slash
  151. return new_path + trailing_slash
  152. def is_safe_path_component(path):
  153. """Check if path is a single component of a path.
  154. Check that the path is safe to join too.
  155. """
  156. return path and "/" not in path and path not in (".", "..")
  157. def is_safe_filesystem_path_component(path):
  158. """Check if path is a single component of a local and posix filesystem
  159. path.
  160. Check that the path is safe to join too.
  161. """
  162. return (
  163. path and not os.path.splitdrive(path)[0] and
  164. not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and
  165. not path.startswith(".") and not path.endswith("~") and
  166. is_safe_path_component(path))
  167. def path_to_filesystem(root, *paths):
  168. """Convert path to a local filesystem path relative to base_folder.
  169. `root` must be a secure filesystem path, it will be prepend to the path.
  170. Conversion of `paths` is done in a secure manner, or raises ``ValueError``.
  171. """
  172. paths = [sanitize_path(path).strip("/") for path in paths]
  173. safe_path = root
  174. for path in paths:
  175. if not path:
  176. continue
  177. for part in path.split("/"):
  178. if not is_safe_filesystem_path_component(part):
  179. raise UnsafePathError(part)
  180. safe_path_parent = safe_path
  181. safe_path = os.path.join(safe_path, part)
  182. # Check for conflicting files (e.g. case-insensitive file systems
  183. # or short names on Windows file systems)
  184. if (os.path.lexists(safe_path) and
  185. part not in scandir(safe_path_parent)):
  186. raise CollidingPathError(part)
  187. return safe_path
  188. class UnsafePathError(ValueError):
  189. def __init__(self, path):
  190. message = "Can't translate name safely to filesystem: %r" % path
  191. super().__init__(message)
  192. class CollidingPathError(ValueError):
  193. def __init__(self, path):
  194. message = "File name collision: %r" % path
  195. super().__init__(message)
  196. class ComponentExistsError(ValueError):
  197. def __init__(self, path):
  198. message = "Component already exists: %r" % path
  199. super().__init__(message)
  200. class ComponentNotFoundError(ValueError):
  201. def __init__(self, path):
  202. message = "Component doesn't exist: %r" % path
  203. super().__init__(message)
  204. class Item:
  205. def __init__(self, collection, item=None, href=None, last_modified=None,
  206. text=None, etag=None):
  207. """Initialize an item.
  208. ``collection`` the parent collection.
  209. ``href`` the href of the item.
  210. ``last_modified`` the HTTP-datetime of when the item was modified.
  211. ``text`` the text representation of the item (optional if ``item`` is
  212. set).
  213. ``item`` the vobject item (optional if ``text`` is set).
  214. ``etag`` the etag of the item (optional). See ``get_etag``.
  215. """
  216. if text is None and item is None:
  217. raise ValueError("at least one of 'text' or 'item' must be set")
  218. self.collection = collection
  219. self.href = href
  220. self.last_modified = last_modified
  221. self._text = text
  222. self._item = item
  223. self._etag = etag
  224. def __getattr__(self, attr):
  225. return getattr(self.item, attr)
  226. def serialize(self):
  227. if self._text is None:
  228. self._text = self.item.serialize()
  229. return self._text
  230. @property
  231. def item(self):
  232. if self._item is None:
  233. try:
  234. self._item = vobject.readOne(self._text)
  235. except Exception as e:
  236. raise RuntimeError("Failed to parse item %r in %r: %s" %
  237. (self.href, self.collection.path, e)) from e
  238. return self._item
  239. @property
  240. def etag(self):
  241. """Encoded as quoted-string (see RFC 2616)."""
  242. if self._etag is None:
  243. self._etag = get_etag(self.serialize())
  244. return self._etag
  245. class BaseCollection:
  246. # Overriden on copy by the "load" function
  247. configuration = None
  248. logger = None
  249. def __init__(self, path, principal=False):
  250. """Initialize the collection.
  251. ``path`` must be the normalized relative path of the collection, using
  252. the slash as the folder delimiter, with no leading nor trailing slash.
  253. """
  254. raise NotImplementedError
  255. @classmethod
  256. def discover(cls, path, depth="0"):
  257. """Discover a list of collections under the given ``path``.
  258. If ``depth`` is "0", only the actual object under ``path`` is
  259. returned.
  260. If ``depth`` is anything but "0", it is considered as "1" and direct
  261. children are included in the result.
  262. The ``path`` is relative.
  263. The root collection "/" must always exist.
  264. """
  265. raise NotImplementedError
  266. @classmethod
  267. def move(cls, item, to_collection, to_href):
  268. """Move an object.
  269. ``item`` is the item to move.
  270. ``to_collection`` is the target collection.
  271. ``to_href`` is the target name in ``to_collection``. An item with the
  272. same name might already exist.
  273. """
  274. if item.collection.path == to_collection.path and item.href == to_href:
  275. return
  276. to_collection.upload(to_href, item.item)
  277. item.collection.delete(item.href)
  278. @property
  279. def etag(self):
  280. """Encoded as quoted-string (see RFC 2616)."""
  281. return get_etag(self.serialize())
  282. @classmethod
  283. def create_collection(cls, href, collection=None, props=None):
  284. """Create a collection.
  285. If the collection already exists and neither ``collection`` nor
  286. ``props`` are set, this method shouldn't do anything. Otherwise the
  287. existing collection must be replaced.
  288. ``collection`` is a list of vobject components.
  289. ``props`` are metadata values for the collection.
  290. ``props["tag"]`` is the type of collection (VCALENDAR or
  291. VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
  292. collection.
  293. """
  294. raise NotImplementedError
  295. def sync(self, old_token=None):
  296. """Get the current sync token and changed items for synchronization.
  297. ``old_token`` an old sync token which is used as the base of the
  298. delta update. If sync token is missing, all items are returned.
  299. ValueError is raised for invalid or old tokens.
  300. WARNING: This simple default implementation treats all sync-token as
  301. invalid. It adheres to the specification but some clients
  302. (e.g. InfCloud) don't like it. Subclasses should provide a
  303. more sophisticated implementation.
  304. """
  305. token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"")
  306. if old_token:
  307. raise ValueError("Sync token are not supported")
  308. return token, self.list()
  309. def list(self):
  310. """List collection items."""
  311. raise NotImplementedError
  312. def get(self, href):
  313. """Fetch a single item."""
  314. raise NotImplementedError
  315. def get_multi(self, hrefs):
  316. """Fetch multiple items. Duplicate hrefs must be ignored.
  317. DEPRECATED: use ``get_multi2`` instead
  318. """
  319. return (self.get(href) for href in set(hrefs))
  320. def get_multi2(self, hrefs):
  321. """Fetch multiple items.
  322. Functionally similar to ``get``, but might bring performance benefits
  323. on some storages when used cleverly. It's not required to return the
  324. requested items in the correct order. Duplicated hrefs can be ignored.
  325. Returns tuples with the href and the item or None if the item doesn't
  326. exist.
  327. """
  328. return ((href, self.get(href)) for href in hrefs)
  329. def get_all(self):
  330. """Fetch all items.
  331. Functionally similar to ``get``, but might bring performance benefits
  332. on some storages when used cleverly.
  333. """
  334. return map(self.get, self.list())
  335. def get_all_filtered(self, filters):
  336. """Fetch all items with optional filtering.
  337. This can largely improve performance of reports depending on
  338. the filters and this implementation.
  339. Returns tuples in the form ``(item, filters_matched)``.
  340. ``filters_matched`` is a bool that indicates if ``filters`` are fully
  341. matched.
  342. This returns all events by default
  343. """
  344. return ((item, False) for item in self.get_all())
  345. def pre_filtered_list(self, filters):
  346. """List collection items with optional pre filtering.
  347. DEPRECATED: use ``get_all_filtered`` instead
  348. """
  349. return self.get_all()
  350. def has(self, href):
  351. """Check if an item exists by its href.
  352. Functionally similar to ``get``, but might bring performance benefits
  353. on some storages when used cleverly.
  354. """
  355. return self.get(href) is not None
  356. def upload(self, href, vobject_item):
  357. """Upload a new or replace an existing item."""
  358. raise NotImplementedError
  359. def delete(self, href=None):
  360. """Delete an item.
  361. When ``href`` is ``None``, delete the collection.
  362. """
  363. raise NotImplementedError
  364. def get_meta(self, key):
  365. """Get metadata value for collection."""
  366. raise NotImplementedError
  367. def set_meta(self, props):
  368. """Set metadata values for collection."""
  369. raise NotImplementedError
  370. @property
  371. def last_modified(self):
  372. """Get the HTTP-datetime of when the collection was modified."""
  373. raise NotImplementedError
  374. def serialize(self):
  375. """Get the unicode string representing the whole collection."""
  376. raise NotImplementedError
  377. @classmethod
  378. @contextmanager
  379. def acquire_lock(cls, mode, user=None):
  380. """Set a context manager to lock the whole storage.
  381. ``mode`` must either be "r" for shared access or "w" for exclusive
  382. access.
  383. ``user`` is the name of the logged in user or empty.
  384. """
  385. raise NotImplementedError
  386. class Collection(BaseCollection):
  387. """Collection stored in several files per calendar."""
  388. def __init__(self, path, principal=False, folder=None):
  389. if not folder:
  390. folder = self._get_collection_root_folder()
  391. # Path should already be sanitized
  392. self.path = sanitize_path(path).strip("/")
  393. self.encoding = self.configuration.get("encoding", "stock")
  394. self._filesystem_path = path_to_filesystem(folder, self.path)
  395. self._props_path = os.path.join(
  396. self._filesystem_path, ".Radicale.props")
  397. split_path = self.path.split("/")
  398. self.owner = split_path[0] if len(split_path) > 1 else None
  399. self.is_principal = principal
  400. self._meta = None
  401. self._etag = None
  402. @classmethod
  403. def _get_collection_root_folder(cls):
  404. filesystem_folder = os.path.expanduser(
  405. cls.configuration.get("storage", "filesystem_folder"))
  406. return os.path.join(filesystem_folder, "collection-root")
  407. @contextmanager
  408. def _atomic_write(self, path, mode="w", newline=None):
  409. directory = os.path.dirname(path)
  410. tmp = NamedTemporaryFile(
  411. mode=mode, dir=directory, delete=False, prefix=".Radicale.tmp-",
  412. newline=newline, encoding=None if "b" in mode else self.encoding)
  413. try:
  414. yield tmp
  415. self._fsync(tmp.fileno())
  416. tmp.close()
  417. os.replace(tmp.name, path)
  418. except:
  419. tmp.close()
  420. os.remove(tmp.name)
  421. raise
  422. self._sync_directory(directory)
  423. @staticmethod
  424. def _find_available_file_name(exists_fn, suffix=""):
  425. # Prevent infinite loop
  426. for _ in range(1000):
  427. file_name = "%016x" % getrandbits(64) + suffix
  428. if not exists_fn(file_name):
  429. return file_name
  430. # something is wrong with the PRNG
  431. raise RuntimeError("No unique random sequence found")
  432. @classmethod
  433. def _fsync(cls, fd):
  434. if cls.configuration.getboolean("storage", "filesystem_fsync"):
  435. if os.name == "posix" and hasattr(fcntl, "F_FULLFSYNC"):
  436. fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
  437. else:
  438. os.fsync(fd)
  439. @classmethod
  440. def _sync_directory(cls, path):
  441. """Sync directory to disk.
  442. This only works on POSIX and does nothing on other systems.
  443. """
  444. if not cls.configuration.getboolean("storage", "filesystem_fsync"):
  445. return
  446. if os.name == "posix":
  447. fd = os.open(path, 0)
  448. try:
  449. cls._fsync(fd)
  450. finally:
  451. os.close(fd)
  452. @classmethod
  453. def _makedirs_synced(cls, filesystem_path):
  454. """Recursively create a directory and its parents in a sync'ed way.
  455. This method acts silently when the folder already exists.
  456. """
  457. if os.path.isdir(filesystem_path):
  458. return
  459. parent_filesystem_path = os.path.dirname(filesystem_path)
  460. # Prevent infinite loop
  461. if filesystem_path != parent_filesystem_path:
  462. # Create parent dirs recursively
  463. cls._makedirs_synced(parent_filesystem_path)
  464. # Possible race!
  465. os.makedirs(filesystem_path, exist_ok=True)
  466. cls._sync_directory(parent_filesystem_path)
  467. @classmethod
  468. def discover(cls, path, depth="0"):
  469. # Path should already be sanitized
  470. sane_path = sanitize_path(path).strip("/")
  471. attributes = sane_path.split("/")
  472. if not attributes[0]:
  473. attributes.pop()
  474. folder = cls._get_collection_root_folder()
  475. # Create the root collection
  476. cls._makedirs_synced(folder)
  477. try:
  478. filesystem_path = path_to_filesystem(folder, sane_path)
  479. except ValueError as e:
  480. # Path is unsafe
  481. cls.logger.debug("Collection with unsafe path %r requested: %s",
  482. sane_path, e, exc_info=True)
  483. return
  484. # Check if the path exists and if it leads to a collection or an item
  485. if not os.path.isdir(filesystem_path):
  486. if attributes and os.path.isfile(filesystem_path):
  487. href = attributes.pop()
  488. else:
  489. return
  490. else:
  491. href = None
  492. path = "/".join(attributes)
  493. principal = len(attributes) == 1
  494. collection = cls(path, principal)
  495. if href:
  496. yield collection.get(href)
  497. return
  498. yield collection
  499. if depth == "0":
  500. return
  501. for item in collection.list():
  502. yield collection.get(item)
  503. for href in scandir(filesystem_path, only_dirs=True):
  504. if not is_safe_filesystem_path_component(href):
  505. if not href.startswith(".Radicale"):
  506. cls.logger.debug("Skipping collection %r in %r", href,
  507. path)
  508. continue
  509. child_path = posixpath.join(path, href)
  510. child_principal = len(attributes) == 0
  511. yield cls(child_path, child_principal)
  512. @classmethod
  513. def create_collection(cls, href, collection=None, props=None):
  514. folder = cls._get_collection_root_folder()
  515. # Path should already be sanitized
  516. sane_path = sanitize_path(href).strip("/")
  517. attributes = sane_path.split("/")
  518. if not attributes[0]:
  519. attributes.pop()
  520. principal = len(attributes) == 1
  521. filesystem_path = path_to_filesystem(folder, sane_path)
  522. if not props:
  523. props = {}
  524. if not props.get("tag") and collection:
  525. props["tag"] = collection[0].name
  526. if not props:
  527. cls._makedirs_synced(filesystem_path)
  528. return cls(sane_path, principal=principal)
  529. parent_dir = os.path.dirname(filesystem_path)
  530. cls._makedirs_synced(parent_dir)
  531. # Create a temporary directory with an unsafe name
  532. with TemporaryDirectory(
  533. prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir:
  534. # The temporary directory itself can't be renamed
  535. tmp_filesystem_path = os.path.join(tmp_dir, "collection")
  536. os.makedirs(tmp_filesystem_path)
  537. self = cls("/", principal=principal, folder=tmp_filesystem_path)
  538. self.set_meta(props)
  539. if collection:
  540. if props.get("tag") == "VCALENDAR":
  541. collection, = collection
  542. items = []
  543. for content in ("vevent", "vtodo", "vjournal"):
  544. items.extend(
  545. getattr(collection, "%s_list" % content, []))
  546. items_by_uid = groupby(sorted(items, key=get_uid), get_uid)
  547. vobject_items = {}
  548. for uid, items in items_by_uid:
  549. new_collection = vobject.iCalendar()
  550. for item in items:
  551. new_collection.add(item)
  552. # href must comply to is_safe_filesystem_path_component
  553. # and no file name collisions must exist between hrefs
  554. href = self._find_available_file_name(
  555. vobject_items.get, suffix=".ics")
  556. vobject_items[href] = new_collection
  557. self.upload_all_nonatomic(vobject_items)
  558. elif props.get("tag") == "VCARD":
  559. vobject_items = {}
  560. for card in collection:
  561. # href must comply to is_safe_filesystem_path_component
  562. # and no file name collisions must exist between hrefs
  563. href = self._find_available_file_name(
  564. vobject_items.get, suffix=".vcf")
  565. vobject_items[href] = card
  566. self.upload_all_nonatomic(vobject_items)
  567. # This operation is not atomic on the filesystem level but it's
  568. # very unlikely that one rename operations succeeds while the
  569. # other fails or that only one gets written to disk.
  570. if os.path.exists(filesystem_path):
  571. os.rename(filesystem_path, os.path.join(tmp_dir, "delete"))
  572. os.rename(tmp_filesystem_path, filesystem_path)
  573. cls._sync_directory(parent_dir)
  574. return cls(sane_path, principal=principal)
  575. def upload_all_nonatomic(self, vobject_items):
  576. """Upload a new set of items.
  577. This takes a mapping of href and vobject items and
  578. uploads them nonatomic and without existence checks.
  579. """
  580. with contextlib.ExitStack() as stack:
  581. fs = []
  582. for href, item in vobject_items.items():
  583. if not is_safe_filesystem_path_component(href):
  584. raise UnsafePathError(href)
  585. path = path_to_filesystem(self._filesystem_path, href)
  586. fs.append(stack.enter_context(
  587. open(path, "w", encoding=self.encoding, newline="")))
  588. fs[-1].write(item.serialize())
  589. # sync everything at once because it's slightly faster.
  590. for f in fs:
  591. self._fsync(f.fileno())
  592. self._sync_directory(self._filesystem_path)
  593. @classmethod
  594. def move(cls, item, to_collection, to_href):
  595. if not is_safe_filesystem_path_component(to_href):
  596. raise UnsafePathError(to_href)
  597. os.replace(
  598. path_to_filesystem(item.collection._filesystem_path, item.href),
  599. path_to_filesystem(to_collection._filesystem_path, to_href))
  600. cls._sync_directory(to_collection._filesystem_path)
  601. if item.collection._filesystem_path != to_collection._filesystem_path:
  602. cls._sync_directory(item.collection._filesystem_path)
  603. # Track the change
  604. to_collection._update_history_etag(to_href, item)
  605. item.collection._update_history_etag(item.href, None)
  606. to_collection._clean_history_cache()
  607. if item.collection._filesystem_path != to_collection._filesystem_path:
  608. item.collection._clean_history_cache()
  609. @classmethod
  610. def _clean_cache(cls, folder, names, max_age=None):
  611. """Delete all ``names`` in ``folder`` that are older than ``max_age``.
  612. """
  613. age_limit = time.time() - max_age if max_age is not None else None
  614. modified = False
  615. for name in names:
  616. if not is_safe_filesystem_path_component(name):
  617. continue
  618. if age_limit is not None:
  619. try:
  620. # Race: Another process might have deleted the file.
  621. mtime = os.path.getmtime(os.path.join(folder, name))
  622. except FileNotFoundError:
  623. continue
  624. if mtime > age_limit:
  625. continue
  626. cls.logger.debug("Found expired item in cache: %r", name)
  627. # Race: Another process might have deleted or locked the
  628. # file.
  629. try:
  630. os.remove(os.path.join(folder, name))
  631. except (FileNotFoundError, PermissionError):
  632. continue
  633. modified = True
  634. if modified:
  635. cls._sync_directory(folder)
  636. def _update_history_etag(self, href, item):
  637. """Updates and retrieves the history etag from the history cache.
  638. The history cache contains a file for each current and deleted item
  639. of the collection. These files contain the etag of the item (empty
  640. string for deleted items) and a history etag, which is a hash over
  641. the previous history etag and the etag separated by "/".
  642. """
  643. history_folder = os.path.join(self._filesystem_path,
  644. ".Radicale.cache", "history")
  645. try:
  646. with open(os.path.join(history_folder, href), "rb") as f:
  647. cache_etag, history_etag = pickle.load(f)
  648. except (FileNotFoundError, pickle.UnpicklingError, ValueError) as e:
  649. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  650. self.logger.warning(
  651. "Failed to load history cache entry %r in %r: %s",
  652. href, self.path, e, exc_info=True)
  653. cache_etag = ""
  654. # Initialize with random data to prevent collisions with cleaned
  655. # expired items.
  656. history_etag = binascii.hexlify(os.urandom(16)).decode("ascii")
  657. etag = item.etag if item else ""
  658. if etag != cache_etag:
  659. self._makedirs_synced(history_folder)
  660. history_etag = get_etag(history_etag + "/" + etag).strip("\"")
  661. try:
  662. # Race: Other processes might have created and locked the file.
  663. with self._atomic_write(os.path.join(history_folder, href),
  664. "wb") as f:
  665. pickle.dump([etag, history_etag], f)
  666. except PermissionError:
  667. pass
  668. return history_etag
  669. def _get_deleted_history_hrefs(self):
  670. """Returns the hrefs of all deleted items that are still in the
  671. history cache."""
  672. history_folder = os.path.join(self._filesystem_path,
  673. ".Radicale.cache", "history")
  674. try:
  675. for href in scandir(history_folder):
  676. if not is_safe_filesystem_path_component(href):
  677. continue
  678. if os.path.isfile(os.path.join(self._filesystem_path, href)):
  679. continue
  680. yield href
  681. except FileNotFoundError:
  682. pass
  683. def _clean_history_cache(self):
  684. # Delete all expired cache entries of deleted items.
  685. history_folder = os.path.join(self._filesystem_path,
  686. ".Radicale.cache", "history")
  687. self._clean_cache(history_folder, self._get_deleted_history_hrefs(),
  688. max_age=self.configuration.getint(
  689. "storage", "max_sync_token_age"))
  690. def sync(self, old_token=None):
  691. # The sync token has the form http://radicale.org/ns/sync/TOKEN_NAME
  692. # where TOKEN_NAME is the md5 hash of all history etags of present and
  693. # past items of the collection.
  694. def check_token_name(token_name):
  695. if len(token_name) != 32:
  696. return False
  697. for c in token_name:
  698. if c not in "0123456789abcdef":
  699. return False
  700. return True
  701. old_token_name = None
  702. if old_token:
  703. # Extract the token name from the sync token
  704. if not old_token.startswith("http://radicale.org/ns/sync/"):
  705. raise ValueError("Malformed token: %r" % old_token)
  706. old_token_name = old_token[len("http://radicale.org/ns/sync/"):]
  707. if not check_token_name(old_token_name):
  708. raise ValueError("Malformed token: %r" % old_token)
  709. # Get the current state and sync-token of the collection.
  710. state = {}
  711. token_name_hash = md5()
  712. # Find the history of all existing and deleted items
  713. for href, item in chain(
  714. ((item.href, item) for item in self.get_all()),
  715. ((href, None) for href in self._get_deleted_history_hrefs())):
  716. history_etag = self._update_history_etag(href, item)
  717. state[href] = history_etag
  718. token_name_hash.update((href + "/" + history_etag).encode("utf-8"))
  719. token_name = token_name_hash.hexdigest()
  720. token = "http://radicale.org/ns/sync/%s" % token_name
  721. if token_name == old_token_name:
  722. # Nothing changed
  723. return token, ()
  724. token_folder = os.path.join(self._filesystem_path,
  725. ".Radicale.cache", "sync-token")
  726. token_path = os.path.join(token_folder, token_name)
  727. old_state = {}
  728. if old_token_name:
  729. # load the old token state
  730. old_token_path = os.path.join(token_folder, old_token_name)
  731. try:
  732. # Race: Another process might have deleted the file.
  733. with open(old_token_path, "rb") as f:
  734. old_state = pickle.load(f)
  735. except (FileNotFoundError, pickle.UnpicklingError,
  736. ValueError) as e:
  737. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  738. self.logger.warning(
  739. "Failed to load stored sync token %r in %r: %s",
  740. old_token_name, self.path, e, exc_info=True)
  741. # Delete the damaged file
  742. try:
  743. os.remove(old_token_path)
  744. except (FileNotFoundError, PermissionError):
  745. pass
  746. raise ValueError("Token not found: %r" % old_token)
  747. # write the new token state or update the modification time of
  748. # existing token state
  749. if not os.path.exists(token_path):
  750. self._makedirs_synced(token_folder)
  751. try:
  752. # Race: Other processes might have created and locked the file.
  753. with self._atomic_write(token_path, "wb") as f:
  754. pickle.dump(state, f)
  755. except PermissionError:
  756. pass
  757. else:
  758. # clean up old sync tokens and item cache
  759. self._clean_cache(token_folder, os.listdir(token_folder),
  760. max_age=self.configuration.getint(
  761. "storage", "max_sync_token_age"))
  762. self._clean_history_cache()
  763. else:
  764. # Try to update the modification time
  765. try:
  766. # Race: Another process might have deleted the file.
  767. os.utime(token_path)
  768. except FileNotFoundError:
  769. pass
  770. changes = []
  771. # Find all new, changed and deleted (that are still in the item cache)
  772. # items
  773. for href, history_etag in state.items():
  774. if history_etag != old_state.get(href):
  775. changes.append(href)
  776. # Find all deleted items that are no longer in the item cache
  777. for href, history_etag in old_state.items():
  778. if href not in state:
  779. changes.append(href)
  780. return token, changes
  781. def list(self):
  782. for href in scandir(self._filesystem_path, only_files=True):
  783. if not is_safe_filesystem_path_component(href):
  784. if not href.startswith(".Radicale"):
  785. self.logger.debug(
  786. "Skipping item %r in %r", href, self.path)
  787. continue
  788. yield href
  789. _item_cache_cleaned = False
  790. def get(self, href, verify_href=True):
  791. item, metadata = self._get_with_metadata(href, verify_href=verify_href)
  792. return item
  793. def _get_with_metadata(self, href, verify_href=True):
  794. # Like ``get`` but additonally returns the following metadata:
  795. # tag, start, end: see ``xmlutils.find_tag_and_time_range``
  796. if verify_href:
  797. try:
  798. if not is_safe_filesystem_path_component(href):
  799. raise UnsafePathError(href)
  800. path = path_to_filesystem(self._filesystem_path, href)
  801. except ValueError as e:
  802. self.logger.debug(
  803. "Can't translate name %r safely to filesystem in %r: %s",
  804. href, self.path, e, exc_info=True)
  805. return None, None
  806. else:
  807. path = os.path.join(self._filesystem_path, href)
  808. try:
  809. with open(path, "rb") as f:
  810. btext = f.read()
  811. except (FileNotFoundError, IsADirectoryError):
  812. return None, None
  813. # The hash of the component in the file system. This is used to check,
  814. # if the entry in the cache is still valid.
  815. input_hash = md5()
  816. input_hash.update(btext)
  817. input_hash = input_hash.hexdigest()
  818. cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache",
  819. "item")
  820. try:
  821. with open(os.path.join(cache_folder, href), "rb") as f:
  822. cinput_hash, cetag, ctext, ctag, cstart, cend = pickle.load(f)
  823. except (FileNotFoundError, pickle.UnpicklingError, ValueError) as e:
  824. if isinstance(e, (pickle.UnpicklingError, ValueError)):
  825. self.logger.warning(
  826. "Failed to load item cache entry %r in %r: %s",
  827. href, self.path, e, exc_info=True)
  828. cinput_hash = cetag = ctext = ctag = cstart = cend = None
  829. vobject_item = None
  830. if input_hash != cinput_hash:
  831. try:
  832. vobject_item = Item(self, href=href,
  833. text=btext.decode(self.encoding)).item
  834. check_item(vobject_item)
  835. except Exception as e:
  836. raise RuntimeError("Failed to parse item %r from %r: %s" %
  837. (href, self.path, e)) from e
  838. # Serialize the object again, to normalize the text representation.
  839. # The storage may have been edited externally.
  840. ctext = vobject_item.serialize()
  841. cetag = get_etag(ctext)
  842. try:
  843. ctag, cstart, cend = xmlutils.find_tag_and_time_range(
  844. vobject_item)
  845. except Exception as e:
  846. raise RuntimeError("Failed to find tag and time range of item "
  847. "%r from %r: %s" % (href, self.path,
  848. e)) from e
  849. self._makedirs_synced(cache_folder)
  850. try:
  851. # Race: Other processes might have created and locked the
  852. # file.
  853. with self._atomic_write(os.path.join(cache_folder, href),
  854. "wb") as f:
  855. pickle.dump((input_hash, cetag, ctext,
  856. ctag, cstart, cend), f)
  857. except PermissionError:
  858. pass
  859. # Clean cache entries (max once per request)
  860. # This happens once after new uploads, or if the data in the
  861. # file system was edited externally.
  862. if not self._item_cache_cleaned:
  863. self._item_cache_cleaned = True
  864. self._clean_cache(cache_folder, (
  865. href for href in scandir(cache_folder) if not
  866. os.path.isfile(os.path.join(self._filesystem_path, href))))
  867. last_modified = time.strftime(
  868. "%a, %d %b %Y %H:%M:%S GMT",
  869. time.gmtime(os.path.getmtime(path)))
  870. return Item(self, href=href, last_modified=last_modified, etag=cetag,
  871. text=ctext, item=vobject_item), (ctag, cstart, cend)
  872. def get_multi2(self, hrefs):
  873. # It's faster to check for file name collissions here, because
  874. # we only need to call os.listdir once.
  875. files = None
  876. for href in hrefs:
  877. if files is None:
  878. # List dir after hrefs returned one item, the iterator may be
  879. # empty and the for-loop is never executed.
  880. files = os.listdir(self._filesystem_path)
  881. path = os.path.join(self._filesystem_path, href)
  882. if (not is_safe_filesystem_path_component(href) or
  883. href not in files and os.path.lexists(path)):
  884. self.logger.debug(
  885. "Can't translate name safely to filesystem: %r", href)
  886. yield (href, None)
  887. else:
  888. yield (href, self.get(href, verify_href=False))
  889. def get_all(self):
  890. # We don't need to check for collissions, because the the file names
  891. # are from os.listdir.
  892. return (self.get(href, verify_href=False) for href in self.list())
  893. def get_all_filtered(self, filters):
  894. tag, start, end, simple = xmlutils.simplify_prefilters(filters)
  895. if not tag:
  896. # no filter
  897. yield from ((item, simple) for item in self.get_all())
  898. return
  899. for item, (itag, istart, iend) in (
  900. self._get_with_metadata(href, verify_href=False)
  901. for href in self.list()):
  902. if tag == itag and istart < end and iend > start:
  903. yield item, simple and (start <= istart or iend <= end)
  904. def upload(self, href, vobject_item):
  905. if not is_safe_filesystem_path_component(href):
  906. raise UnsafePathError(href)
  907. path = path_to_filesystem(self._filesystem_path, href)
  908. item = Item(self, href=href, item=vobject_item)
  909. with self._atomic_write(path, newline="") as fd:
  910. fd.write(item.serialize())
  911. # Track the change
  912. self._update_history_etag(href, item)
  913. self._clean_history_cache()
  914. return item
  915. def delete(self, href=None):
  916. if href is None:
  917. # Delete the collection
  918. parent_dir = os.path.dirname(self._filesystem_path)
  919. try:
  920. os.rmdir(self._filesystem_path)
  921. except OSError:
  922. with TemporaryDirectory(
  923. prefix=".Radicale.tmp-", dir=parent_dir) as tmp:
  924. os.rename(self._filesystem_path, os.path.join(
  925. tmp, os.path.basename(self._filesystem_path)))
  926. self._sync_directory(parent_dir)
  927. else:
  928. self._sync_directory(parent_dir)
  929. else:
  930. # Delete an item
  931. if not is_safe_filesystem_path_component(href):
  932. raise UnsafePathError(href)
  933. path = path_to_filesystem(self._filesystem_path, href)
  934. if not os.path.isfile(path):
  935. raise ComponentNotFoundError(href)
  936. os.remove(path)
  937. self._sync_directory(os.path.dirname(path))
  938. # Track the change
  939. self._update_history_etag(href, None)
  940. self._clean_history_cache()
  941. def get_meta(self, key=None):
  942. # reuse cached value if the storage is read-only
  943. if self._writer or self._meta is None:
  944. try:
  945. with open(self._props_path, encoding=self.encoding) as f:
  946. self._meta = json.load(f)
  947. except FileNotFoundError:
  948. self._meta = {}
  949. except ValueError as e:
  950. raise RuntimeError("Failed to load properties of collect"
  951. "ion %r: %s" % (self.path, e)) from e
  952. return self._meta.get(key) if key else self._meta
  953. def set_meta(self, props):
  954. new_props = self.get_meta()
  955. new_props.update(props)
  956. for key in tuple(new_props.keys()):
  957. if not new_props[key]:
  958. del new_props[key]
  959. with self._atomic_write(self._props_path, "w") as f:
  960. json.dump(new_props, f)
  961. @property
  962. def last_modified(self):
  963. relevant_files = chain(
  964. (self._filesystem_path,),
  965. (self._props_path,) if os.path.exists(self._props_path) else (),
  966. (os.path.join(self._filesystem_path, h) for h in self.list()))
  967. last = max(map(os.path.getmtime, relevant_files))
  968. return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
  969. def serialize(self):
  970. # serialize collection
  971. if self.get_meta("tag") == "VCALENDAR":
  972. in_vcalendar = False
  973. vtimezones = ""
  974. included_tzids = set()
  975. vtimezone = []
  976. tzid = None
  977. components = ""
  978. # Concatenate all child elements of VCALENDAR from all items
  979. # together, while preventing duplicated VTIMEZONE entries.
  980. # VTIMEZONEs are only distinguished by their TZID, if different
  981. # timezones share the same TZID this produces errornous ouput.
  982. # VObject fails at this too.
  983. for item in self.get_all():
  984. depth = 0
  985. for line in item.serialize().split("\r\n"):
  986. if line.startswith("BEGIN:"):
  987. depth += 1
  988. if depth == 1 and line == "BEGIN:VCALENDAR":
  989. in_vcalendar = True
  990. elif in_vcalendar:
  991. if depth == 1 and line.startswith("END:"):
  992. in_vcalendar = False
  993. if depth == 2 and line == "BEGIN:VTIMEZONE":
  994. vtimezone.append(line)
  995. elif vtimezone:
  996. vtimezone.append(line)
  997. if depth == 2 and line.startswith("TZID:"):
  998. tzid = line[len("TZID:"):]
  999. elif depth == 2 and line.startswith("END:"):
  1000. if tzid is None or tzid not in included_tzids:
  1001. if vtimezones:
  1002. vtimezones += "\r\n"
  1003. vtimezones += "\r\n".join(vtimezone)
  1004. included_tzids.add(tzid)
  1005. vtimezone.clear()
  1006. tzid = None
  1007. elif depth >= 2:
  1008. if components:
  1009. components += "\r\n"
  1010. components += line
  1011. if line.startswith("END:"):
  1012. depth -= 1
  1013. return "\r\n".join(filter(bool, (
  1014. "BEGIN:VCALENDAR",
  1015. "VERSION:2.0",
  1016. "PRODID:-//PYVOBJECT//NONSGML Version 1//EN",
  1017. vtimezones,
  1018. components,
  1019. "END:VCALENDAR")))
  1020. elif self.get_meta("tag") == "VADDRESSBOOK":
  1021. return "".join((item.serialize() for item in self.get_all()))
  1022. return ""
  1023. @property
  1024. def etag(self):
  1025. # reuse cached value if the storage is read-only
  1026. if self._writer or self._etag is None:
  1027. etag = md5()
  1028. for item in self.get_all():
  1029. etag.update((item.href + "/" + item.etag).encode("utf-8"))
  1030. self._etag = '"%s"' % etag.hexdigest()
  1031. return self._etag
  1032. _lock = threading.Lock()
  1033. _waiters = []
  1034. _lock_file = None
  1035. _lock_file_locked = False
  1036. _readers = 0
  1037. _writer = False
  1038. @classmethod
  1039. @contextmanager
  1040. def acquire_lock(cls, mode, user=None):
  1041. def condition():
  1042. if mode == "r":
  1043. return not cls._writer
  1044. else:
  1045. return not cls._writer and cls._readers == 0
  1046. file_locking = cls.configuration.getboolean("storage",
  1047. "filesystem_locking")
  1048. folder = os.path.expanduser(cls.configuration.get(
  1049. "storage", "filesystem_folder"))
  1050. # Use a primitive lock which only works within one process as a
  1051. # precondition for inter-process file-based locking
  1052. with cls._lock:
  1053. if cls._waiters or not condition():
  1054. # Use FIFO for access requests
  1055. waiter = threading.Condition(lock=cls._lock)
  1056. cls._waiters.append(waiter)
  1057. while True:
  1058. waiter.wait()
  1059. if condition():
  1060. break
  1061. cls._waiters.pop(0)
  1062. if mode == "r":
  1063. cls._readers += 1
  1064. # Notify additional potential readers
  1065. if cls._waiters:
  1066. cls._waiters[0].notify()
  1067. else:
  1068. cls._writer = True
  1069. if not cls._lock_file:
  1070. cls._makedirs_synced(folder)
  1071. lock_path = os.path.join(folder, ".Radicale.lock")
  1072. cls._lock_file = open(lock_path, "w+")
  1073. # Set access rights to a necessary minimum to prevent locking
  1074. # by arbitrary users
  1075. try:
  1076. os.chmod(lock_path, stat.S_IWUSR | stat.S_IRUSR)
  1077. except OSError as e:
  1078. cls.logger.info("Failed to set permissions on lock file:"
  1079. " %s", e, exc_info=True)
  1080. if file_locking and not cls._lock_file_locked:
  1081. if os.name == "nt":
  1082. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  1083. flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0
  1084. overlapped = Overlapped()
  1085. if not lock_file_ex(handle, flags, 0, 1, 0, overlapped):
  1086. raise RuntimeError("Locking the storage failed: %s" %
  1087. ctypes.FormatError())
  1088. elif os.name == "posix":
  1089. _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH
  1090. try:
  1091. fcntl.flock(cls._lock_file.fileno(), _cmd)
  1092. except OSError as e:
  1093. raise RuntimeError("Locking the storage failed: %s" %
  1094. e) from e
  1095. else:
  1096. raise RuntimeError("Locking the storage failed: "
  1097. "Unsupported operating system")
  1098. cls._lock_file_locked = True
  1099. try:
  1100. yield
  1101. # execute hook
  1102. hook = cls.configuration.get("storage", "hook")
  1103. if mode == "w" and hook:
  1104. cls.logger.debug("Running hook")
  1105. subprocess.check_call(
  1106. hook % {"user": shlex.quote(user or "Anonymous")},
  1107. shell=True, cwd=folder)
  1108. finally:
  1109. with cls._lock:
  1110. if mode == "r":
  1111. cls._readers -= 1
  1112. else:
  1113. cls._writer = False
  1114. if file_locking and cls._readers == 0:
  1115. if os.name == "nt":
  1116. handle = msvcrt.get_osfhandle(cls._lock_file.fileno())
  1117. overlapped = Overlapped()
  1118. if not unlock_file_ex(handle, 0, 1, 0, overlapped):
  1119. raise RuntimeError("Unlocking the storage failed: "
  1120. "%s" % ctypes.FormatError())
  1121. elif os.name == "posix":
  1122. try:
  1123. fcntl.flock(cls._lock_file.fileno(), fcntl.LOCK_UN)
  1124. except OSError as e:
  1125. raise RuntimeError("Unlocking the storage failed: "
  1126. "%s" % e) from e
  1127. else:
  1128. raise RuntimeError("Unlocking the storage failed: "
  1129. "Unsupported operating system")
  1130. cls._lock_file_locked = False
  1131. if cls._waiters:
  1132. cls._waiters[0].notify()
  1133. if (cls.configuration.getboolean(
  1134. "storage", "filesystem_close_lock_file") and
  1135. cls._readers == 0 and not cls._waiters):
  1136. cls._lock_file.close()
  1137. cls._lock_file = None