Clean many, many things

This commit is contained in:
Guillaume Ayoub 2016-08-05 02:14:49 +02:00
parent 263f31c84b
commit 8ac3ce1a89
5 changed files with 240 additions and 239 deletions

View File

@ -50,9 +50,8 @@ from . import auth, rights, storage, xmlutils
VERSION = "2.0.0rc0" VERSION = "2.0.0rc0"
# Standard "not allowed" response that is returned when an authenticated user
# tries to access information they don't have rights to
NOT_ALLOWED = (client.FORBIDDEN, {}, None) NOT_ALLOWED = (client.FORBIDDEN, {}, None)
DAV_HEADERS = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
class HTTPServer(wsgiref.simple_server.WSGIServer): class HTTPServer(wsgiref.simple_server.WSGIServer):
@ -136,6 +135,7 @@ class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
class Application: class Application:
"""WSGI application managing collections.""" """WSGI application managing collections."""
def __init__(self, configuration, logger): def __init__(self, configuration, logger):
"""Initialize application.""" """Initialize application."""
super().__init__() super().__init__()
@ -149,15 +149,20 @@ class Application:
def headers_log(self, environ): def headers_log(self, environ):
"""Sanitize headers for logging.""" """Sanitize headers for logging."""
request_environ = dict(environ) request_environ = dict(environ)
# Remove environment variables # Remove environment variables
if not self.configuration.getboolean("logging", "full_environment"): if not self.configuration.getboolean("logging", "full_environment"):
for shell_variable in os.environ: for shell_variable in os.environ:
request_environ.pop(shell_variable, None) request_environ.pop(shell_variable, None)
# Mask credentials
if (self.configuration.getboolean("logging", "mask_passwords") and # Mask passwords
request_environ.get("HTTP_AUTHORIZATION", mask_passwords = self.configuration.getboolean(
"").startswith("Basic")): "logging", "mask_passwords")
authorization = request_environ.get(
"HTTP_AUTHORIZATION", "").startswith("Basic")
if mask_passwords and authorization:
request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**" request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
return request_environ return request_environ
def decode(self, text, environ): def decode(self, text, environ):
@ -215,6 +220,7 @@ class Application:
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
"""Manage a request.""" """Manage a request."""
def response(status, headers={}, answer=None): def response(status, headers={}, answer=None):
# Start response # Start response
status = "%i %s" % ( status = "%i %s" % (
@ -224,6 +230,7 @@ class Application:
# Return response content # Return response content
return [answer] if answer else [] return [answer] if answer else []
self.logger.debug("\n") # Add empty lines between requests in debug
self.logger.info("%s request at %s received" % ( self.logger.info("%s request at %s received" % (
environ["REQUEST_METHOD"], environ["PATH_INFO"])) environ["REQUEST_METHOD"], environ["PATH_INFO"]))
headers = pprint.pformat(self.headers_log(environ)) headers = pprint.pformat(self.headers_log(environ))
@ -246,7 +253,6 @@ class Application:
environ["PATH_INFO"] = storage.sanitize_path( environ["PATH_INFO"] = storage.sanitize_path(
unquote(environ["PATH_INFO"])) unquote(environ["PATH_INFO"]))
self.logger.debug("Sanitized path: %s", environ["PATH_INFO"]) self.logger.debug("Sanitized path: %s", environ["PATH_INFO"])
path = environ["PATH_INFO"] path = environ["PATH_INFO"]
# Get function corresponding to method # Get function corresponding to method
@ -254,7 +260,6 @@ class Application:
# Ask authentication backend to check rights # Ask authentication backend to check rights
authorization = environ.get("HTTP_AUTHORIZATION", None) authorization = environ.get("HTTP_AUTHORIZATION", None)
if authorization and authorization.startswith("Basic"): if authorization and authorization.startswith("Basic"):
authorization = authorization[len("Basic"):].strip() authorization = authorization[len("Basic"):].strip()
user, password = self.decode(base64.b64decode( user, password = self.decode(base64.b64decode(
@ -263,7 +268,7 @@ class Application:
user = environ.get("REMOTE_USER") user = environ.get("REMOTE_USER")
password = None password = None
# If /.well-known is not available, clients query / # If "/.well-known" is not available, clients query "/"
if path == "/.well-known" or path.startswith("/.well-known/"): if path == "/.well-known" or path.startswith("/.well-known/"):
return response(client.NOT_FOUND, {}) return response(client.NOT_FOUND, {})
@ -281,7 +286,8 @@ class Application:
if self.authorized(user, principal_path, "w"): if self.authorized(user, principal_path, "w"):
with self.Collection.acquire_lock("r"): with self.Collection.acquire_lock("r"):
principal = next( principal = next(
self.Collection.discover(principal_path), None) self.Collection.discover(principal_path, depth="1"),
None)
if not principal: if not principal:
with self.Collection.acquire_lock("w"): with self.Collection.acquire_lock("w"):
self.Collection.create_collection(principal_path) self.Collection.create_collection(principal_path)
@ -336,6 +342,7 @@ class Application:
headers["Content-Length"] = str(len(answer)) headers["Content-Length"] = str(len(answer))
# Add extra headers set in configuration
if self.configuration.has_section("headers"): if self.configuration.has_section("headers"):
for key in self.configuration.options("headers"): for key in self.configuration.options("headers"):
headers[key] = self.configuration.get("headers", key) headers[key] = self.configuration.get("headers", key)
@ -343,12 +350,11 @@ class Application:
return response(status, headers, answer) return response(status, headers, answer)
def _access(self, user, path, permission, item=None): def _access(self, user, path, permission, item=None):
"""Checks if ``user`` can access ``path`` or the parent collection """Check if ``user`` can access ``path`` or the parent collection.
with ``permission``.
``permission`` must either be "r" or "w". ``permission`` must either be "r" or "w".
If ``item`` is given, only access to that class of item is checked. If ``item`` is given, only access to that class of item is checked.
""" """
path = storage.sanitize_path(path) path = storage.sanitize_path(path)
@ -380,7 +386,7 @@ class Application:
if not self._access(user, path, "w"): if not self._access(user, path, "w"):
return NOT_ALLOWED return NOT_ALLOWED
with self._lock_collection("w", user): with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None) item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item): if not self._access(user, path, "w", item):
return NOT_ALLOWED return NOT_ALLOWED
if not item: if not item:
@ -405,7 +411,7 @@ class Application:
if not self._access(user, path, "r"): if not self._access(user, path, "r"):
return NOT_ALLOWED return NOT_ALLOWED
with self._lock_collection("r", user): with self._lock_collection("r", user):
item = next(self.Collection.discover(path, depth="0"), None) item = next(self.Collection.discover(path), None)
if not self._access(user, path, "r", item): if not self._access(user, path, "r", item):
return NOT_ALLOWED return NOT_ALLOWED
if not item: if not item:
@ -414,8 +420,8 @@ class Application:
collection = item collection = item
else: else:
collection = item.collection collection = item.collection
content_type = storage.MIMETYPES.get(collection.get_meta("tag"), content_type = xmlutils.MIMETYPES.get(
"text/plain") collection.get_meta("tag"), "text/plain")
headers = { headers = {
"Content-Type": content_type, "Content-Type": content_type,
"Last-Modified": collection.last_modified, "Last-Modified": collection.last_modified,
@ -434,7 +440,7 @@ class Application:
return NOT_ALLOWED return NOT_ALLOWED
with self._lock_collection("w", user): with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None) item = next(self.Collection.discover(path), None)
if item: if item:
return client.CONFLICT, {}, None return client.CONFLICT, {}, None
props = xmlutils.props_from_request(content) props = xmlutils.props_from_request(content)
@ -449,7 +455,7 @@ class Application:
if not self.authorized(user, path, "w"): if not self.authorized(user, path, "w"):
return NOT_ALLOWED return NOT_ALLOWED
with self._lock_collection("w", user): with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None) item = next(self.Collection.discover(path), None)
if item: if item:
return client.CONFLICT, {}, None return client.CONFLICT, {}, None
props = xmlutils.props_from_request(content) props = xmlutils.props_from_request(content)
@ -464,27 +470,33 @@ class Application:
if to_url.netloc != environ["HTTP_HOST"]: if to_url.netloc != environ["HTTP_HOST"]:
# Remote destination server, not supported # Remote destination server, not supported
return client.BAD_GATEWAY, {}, None return client.BAD_GATEWAY, {}, None
to_path = storage.sanitize_path(to_url.path) if not self._access(user, path, "w"):
if (not self._access(user, path, "w") or
not self._access(user, to_path, "w")):
return NOT_ALLOWED return NOT_ALLOWED
to_path = storage.sanitize_path(to_url.path)
if not self._access(user, to_path, "w"):
return NOT_ALLOWED
if to_path.strip("/").startswith(path.strip("/")):
return client.CONFLICT, {}, None
with self._lock_collection("w", user): with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None) item = next(self.Collection.discover(path), None)
if (not self._access(user, path, "w", item) or if not self._access(user, path, "w", item):
not self._access(user, to_path, "w", item)): return NOT_ALLOWED
if not self._access(user, to_path, "w", item):
return NOT_ALLOWED return NOT_ALLOWED
if not item: if not item:
return client.GONE, {}, None return client.GONE, {}, None
to_item = next(self.Collection.discover(to_path, depth="0"), None) if isinstance(item, self.Collection):
return client.CONFLICT, {}, None
to_item = next(self.Collection.discover(to_path), None)
to_parent_path = storage.sanitize_path( to_parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(to_path.strip("/"))) "/%s/" % posixpath.dirname(to_path.strip("/")))
to_href = posixpath.basename(to_path.strip("/")) to_collection = next(
to_collection = next(self.Collection.discover( self.Collection.discover(to_parent_path), None)
to_parent_path, depth="0"), None) if not to_collection or to_item:
print(path, isinstance(item, self.Collection))
if (isinstance(item, self.Collection) or not to_collection or
to_item or to_path.strip("/").startswith(path.strip("/"))):
return client.CONFLICT, {}, None return client.CONFLICT, {}, None
to_href = posixpath.basename(to_path.strip("/"))
to_collection.upload(to_href, item.item) to_collection.upload(to_href, item.item)
item.collection.delete(item.href) item.collection.delete(item.href)
return client.CREATED, {}, None return client.CREATED, {}, None
@ -492,22 +504,20 @@ class Application:
def do_OPTIONS(self, environ, path, content, user): def do_OPTIONS(self, environ, path, content, user):
"""Manage OPTIONS request.""" """Manage OPTIONS request."""
headers = { headers = {
"Allow": ("DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, " "Allow": ", ".join(
"OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT"), name[3:] for name in dir(self) if name.startswith("do_")),
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"} "DAV": DAV_HEADERS}
return client.OK, headers, None return client.OK, headers, None
def do_PROPFIND(self, environ, path, content, user): def do_PROPFIND(self, environ, path, content, user):
"""Manage PROPFIND request.""" """Manage PROPFIND request."""
with self._lock_collection("r", user): with self._lock_collection("r", user):
items = self.Collection.discover(path, items = self.Collection.discover(
environ.get("HTTP_DEPTH", "0")) path, environ.get("HTTP_DEPTH", "0"))
read_items, write_items = self.collect_allowed_items(items, user) read_items, write_items = self.collect_allowed_items(items, user)
if not read_items and not write_items: if not read_items and not write_items:
return (client.NOT_FOUND, {}, None) if user else NOT_ALLOWED return (client.NOT_FOUND, {}, None) if user else NOT_ALLOWED
headers = { headers = {"DAV": DAV_HEADERS, "Content-Type": "text/xml"}
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
"Content-Type": "text/xml"}
answer = xmlutils.propfind( answer = xmlutils.propfind(
path, content, read_items, write_items, user) path, content, read_items, write_items, user)
return client.MULTI_STATUS, headers, answer return client.MULTI_STATUS, headers, answer
@ -517,13 +527,11 @@ class Application:
if not self.authorized(user, path, "w"): if not self.authorized(user, path, "w"):
return NOT_ALLOWED return NOT_ALLOWED
with self._lock_collection("w", user): with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None) item = next(self.Collection.discover(path), None)
if not isinstance(item, self.Collection): if not isinstance(item, self.Collection):
return client.CONFLICT, {}, None return client.CONFLICT, {}, None
headers = {"DAV": DAV_HEADERS, "Content-Type": "text/xml"}
answer = xmlutils.proppatch(path, content, item) answer = xmlutils.proppatch(path, content, item)
headers = {
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
"Content-Type": "text/xml"}
return client.MULTI_STATUS, headers, answer return client.MULTI_STATUS, headers, answer
def do_PUT(self, environ, path, content, user): def do_PUT(self, environ, path, content, user):
@ -534,33 +542,39 @@ class Application:
with self._lock_collection("w", user): with self._lock_collection("w", user):
parent_path = storage.sanitize_path( parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/"))) "/%s/" % posixpath.dirname(path.strip("/")))
item = next(self.Collection.discover(path, depth="0"), None) item = next(self.Collection.discover(path), None)
parent_item = next(self.Collection.discover( parent_item = next(self.Collection.discover(parent_path), None)
parent_path, depth="0"), None)
write_whole_collection = ( write_whole_collection = (
isinstance(item, self.Collection) or isinstance(item, self.Collection) or
not parent_item or not parent_item or (
not next(parent_item.list(), None) and not next(parent_item.list(), None) and
parent_item.get_meta("tag") not in ( parent_item.get_meta("tag") not in (
"VADDRESSBOOK", "VCALENDAR")) "VADDRESSBOOK", "VCALENDAR")))
if (write_whole_collection and if write_whole_collection:
not self.authorized(user, path, "w") or if not self.authorized(user, path, "w"):
not write_whole_collection and return NOT_ALLOWED
not self.authorized(user, parent_path, "w")): elif not self.authorized(user, parent_path, "w"):
return NOT_ALLOWED return NOT_ALLOWED
etag = environ.get("HTTP_IF_MATCH", "")
match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
if ((not item and etag) or (item and etag and item.etag != etag) or etag = environ.get("HTTP_IF_MATCH", "")
(item and match)): if not item and etag:
# Etag asked but no item found: item has been removed
return client.PRECONDITION_FAILED, {}, None
if item and etag and item.etag != etag:
# Etag asked but item not matching: item has changed
return client.PRECONDITION_FAILED, {}, None
match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
if item and match:
# Creation asked but item found: item can't be replaced
return client.PRECONDITION_FAILED, {}, None return client.PRECONDITION_FAILED, {}, None
items = list(vobject.readComponents(content or "")) items = list(vobject.readComponents(content or ""))
content_type = environ.get("CONTENT_TYPE") content_type = environ.get("CONTENT_TYPE", "").split(";")[0]
tag = None tags = {value: key for key, value in xmlutils.MIMETYPES.items()}
if content_type: tag = tags.get(content_type)
tags = {value: key for key, value in storage.MIMETYPES.items()}
tag = tags.get(content_type.split(";")[0])
if write_whole_collection: if write_whole_collection:
if item: if item:
# Delete old collection # Delete old collection
@ -582,7 +596,7 @@ class Application:
if not self._access(user, path, "w"): if not self._access(user, path, "w"):
return NOT_ALLOWED return NOT_ALLOWED
with self._lock_collection("r", user): with self._lock_collection("r", user):
item = next(self.Collection.discover(path, depth="0"), None) item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item): if not self._access(user, path, "w", item):
return NOT_ALLOWED return NOT_ALLOWED
if not item: if not item:

View File

@ -167,14 +167,13 @@ def serve(configuration, logger):
try: try:
open(filename, "r").close() open(filename, "r").close()
except IOError as exception: except IOError as exception:
logger.warning( logger.warning("Error while reading SSL %s %r: %s" % (
"Error while reading SSL %s %r: %s" % ( name, filename, exception))
name, filename, exception))
else: else:
server_class = ThreadedHTTPServer server_class = ThreadedHTTPServer
server_class.client_timeout = configuration.getint("server", "timeout") server_class.client_timeout = configuration.getint("server", "timeout")
server_class.max_connections = configuration.getint("server", server_class.max_connections = configuration.getint(
"max_connections") "server", "max_connections")
if not configuration.getboolean("server", "dns_lookup"): if not configuration.getboolean("server", "dns_lookup"):
RequestHandler.address_string = lambda self: self.client_address[0] RequestHandler.address_string = lambda self: self.client_address[0]

View File

@ -111,7 +111,7 @@ class Rights(BaseRights):
user = user or "" user = user or ""
if user and not storage.is_safe_path_component(user): if user and not storage.is_safe_path_component(user):
# Prevent usernames like "user/calendar.ics" # Prevent usernames like "user/calendar.ics"
raise ValueError("Unsafe username") raise ValueError("Refused unsafe username: %s", user)
sane_path = storage.sanitize_path(path).strip("/") sane_path = storage.sanitize_path(path).strip("/")
# Prevent "regex injection" # Prevent "regex injection"
user_escaped = re.escape(user) user_escaped = re.escape(user)

View File

@ -43,6 +43,7 @@ from tempfile import TemporaryDirectory
from atomicwrites import AtomicWriter from atomicwrites import AtomicWriter
import vobject import vobject
if os.name == "nt": if os.name == "nt":
import ctypes import ctypes
import ctypes.wintypes import ctypes.wintypes
@ -55,26 +56,29 @@ if os.name == "nt":
ULONG_PTR = ctypes.c_uint64 ULONG_PTR = ctypes.c_uint64
class Overlapped(ctypes.Structure): class Overlapped(ctypes.Structure):
_fields_ = [("internal", ULONG_PTR), _fields_ = [
("internal_high", ULONG_PTR), ("internal", ULONG_PTR),
("offset", ctypes.wintypes.DWORD), ("internal_high", ULONG_PTR),
("offset_high", ctypes.wintypes.DWORD), ("offset", ctypes.wintypes.DWORD),
("h_event", ctypes.wintypes.HANDLE)] ("offset_high", ctypes.wintypes.DWORD),
("h_event", ctypes.wintypes.HANDLE)]
lock_file_ex = ctypes.windll.kernel32.LockFileEx lock_file_ex = ctypes.windll.kernel32.LockFileEx
lock_file_ex.argtypes = [ctypes.wintypes.HANDLE, lock_file_ex.argtypes = [
ctypes.wintypes.DWORD, ctypes.wintypes.HANDLE,
ctypes.wintypes.DWORD, ctypes.wintypes.DWORD,
ctypes.wintypes.DWORD, ctypes.wintypes.DWORD,
ctypes.wintypes.DWORD, ctypes.wintypes.DWORD,
ctypes.POINTER(Overlapped)] ctypes.wintypes.DWORD,
ctypes.POINTER(Overlapped)]
lock_file_ex.restype = ctypes.wintypes.BOOL lock_file_ex.restype = ctypes.wintypes.BOOL
unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx
unlock_file_ex.argtypes = [ctypes.wintypes.HANDLE, unlock_file_ex.argtypes = [
ctypes.wintypes.DWORD, ctypes.wintypes.HANDLE,
ctypes.wintypes.DWORD, ctypes.wintypes.DWORD,
ctypes.wintypes.DWORD, ctypes.wintypes.DWORD,
ctypes.POINTER(Overlapped)] ctypes.wintypes.DWORD,
ctypes.POINTER(Overlapped)]
unlock_file_ex.restype = ctypes.wintypes.BOOL unlock_file_ex.restype = ctypes.wintypes.BOOL
elif os.name == "posix": elif os.name == "posix":
import fcntl import fcntl
@ -95,9 +99,6 @@ def load(configuration, logger):
return CollectionCopy return CollectionCopy
MIMETYPES = {"VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"}
def get_etag(text): def get_etag(text):
"""Etag from collection or item.""" """Etag from collection or item."""
etag = md5() etag = md5()
@ -105,13 +106,9 @@ def get_etag(text):
return '"%s"' % etag.hexdigest() return '"%s"' % etag.hexdigest()
def is_safe_path_component(path): def get_uid(item):
"""Check if path is a single component of a path. """UID value of an item if defined."""
return hasattr(item, "uid") and item.uid.value
Check that the path is safe to join too.
"""
return path and "/" not in path and path not in (".", "..")
def sanitize_path(path): def sanitize_path(path):
@ -131,6 +128,15 @@ def sanitize_path(path):
return new_path + trailing_slash return new_path + trailing_slash
def is_safe_path_component(path):
"""Check if path is a single component of a path.
Check that the path is safe to join too.
"""
return path and "/" not in path and path not in (".", "..")
def is_safe_filesystem_path_component(path): def is_safe_filesystem_path_component(path):
"""Check if path is a single component of a filesystem path. """Check if path is a single component of a filesystem path.
@ -158,14 +164,13 @@ def path_to_filesystem(root, *paths):
continue continue
for part in path.split("/"): for part in path.split("/"):
if not is_safe_filesystem_path_component(part): if not is_safe_filesystem_path_component(part):
raise ValueError( raise UnsafePathError(part)
"Can't tranlate name safely to filesystem: %s" % part)
safe_path = os.path.join(safe_path, part) safe_path = os.path.join(safe_path, part)
return safe_path return safe_path
def sync_directory(path): def sync_directory(path):
"""Sync directory to disk """Sync directory to disk.
This only works on POSIX and does nothing on other systems. This only works on POSIX and does nothing on other systems.
@ -181,14 +186,38 @@ def sync_directory(path):
os.close(fd) os.close(fd)
class UnsafePathError(ValueError):
def __init__(self, path):
message = "Can't translate name safely to filesystem: %s" % path
super().__init__(message)
class ComponentExistsError(ValueError):
def __init__(self, path):
message = "Component already exists: %s" % path
super().__init__(message)
class ComponentNotFoundError(ValueError):
def __init__(self, path):
message = "Component doesn't exist: %s" % path
super().__init__(message)
class EtagMismatchError(ValueError):
def __init__(self, etag1, etag2):
message = "ETags don't match: %s != %s" % (etag1, etag2)
super().__init__(message)
class _EncodedAtomicWriter(AtomicWriter): class _EncodedAtomicWriter(AtomicWriter):
def __init__(self, path, encoding, mode="w", overwrite=True): def __init__(self, path, encoding, mode="w", overwrite=True):
self._encoding = encoding self._encoding = encoding
return super().__init__(path, mode, overwrite=True) return super().__init__(path, mode, overwrite=True)
def get_fileobject(self, **kwargs): def get_fileobject(self, **kwargs):
return super().get_fileobject(encoding=self._encoding, return super().get_fileobject(
prefix=".Radicale.tmp-", **kwargs) encoding=self._encoding, prefix=".Radicale.tmp-", **kwargs)
class Item: class Item:
@ -222,7 +251,7 @@ class BaseCollection:
raise NotImplementedError raise NotImplementedError
@classmethod @classmethod
def discover(cls, path, depth="1"): def discover(cls, path, depth="0"):
"""Discover a list of collections under the given ``path``. """Discover a list of collections under the given ``path``.
If ``depth`` is "0", only the actual object under ``path`` is If ``depth`` is "0", only the actual object under ``path`` is
@ -248,8 +277,9 @@ class BaseCollection:
``props`` are metadata values for the collection. ``props`` are metadata values for the collection.
``props["tag"]`` is the type of collection (VCALENDAR or VADDRESSBOOK). If ``props["tag"]`` is the type of collection (VCALENDAR or
the key ``tag`` is missing, it is guessed from the collection. VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
collection.
""" """
raise NotImplementedError raise NotImplementedError
@ -347,31 +377,25 @@ class Collection(BaseCollection):
def __init__(self, path, principal=False, folder=None): def __init__(self, path, principal=False, folder=None):
if not folder: if not folder:
folder = self._get_collection_root_folder() folder = self._get_collection_root_folder()
# path should already be sanitized # Path should already be sanitized
self.path = sanitize_path(path).strip("/") self.path = sanitize_path(path).strip("/")
self.storage_encoding = self.configuration.get("encoding", "stock") self.encoding = self.configuration.get("encoding", "stock")
self._filesystem_path = path_to_filesystem(folder, self.path) self._filesystem_path = path_to_filesystem(folder, self.path)
self._props_path = os.path.join( self._props_path = os.path.join(
self._filesystem_path, ".Radicale.props") self._filesystem_path, ".Radicale.props")
split_path = self.path.split("/") split_path = self.path.split("/")
if len(split_path) > 1: self.owner = split_path[0] if len(split_path) > 1 else None
# URL with at least one folder
self.owner = split_path[0]
else:
self.owner = None
self.is_principal = principal self.is_principal = principal
@classmethod @classmethod
def _get_collection_root_folder(cls): def _get_collection_root_folder(cls):
filesystem_folder = os.path.expanduser( filesystem_folder = os.path.expanduser(
cls.configuration.get("storage", "filesystem_folder")) cls.configuration.get("storage", "filesystem_folder"))
folder = os.path.join(filesystem_folder, "collection-root") return os.path.join(filesystem_folder, "collection-root")
return folder
@contextmanager @contextmanager
def _atomic_write(self, path, mode="w"): def _atomic_write(self, path, mode="w"):
with _EncodedAtomicWriter( with _EncodedAtomicWriter(path, self.encoding, mode).open() as fd:
path, self.storage_encoding, mode).open() as fd:
yield fd yield fd
def _find_available_file_name(self): def _find_available_file_name(self):
@ -383,12 +407,12 @@ class Collection(BaseCollection):
raise FileExistsError(errno.EEXIST, "No usable file name found") raise FileExistsError(errno.EEXIST, "No usable file name found")
@classmethod @classmethod
def discover(cls, path, depth="1"): def discover(cls, path, depth="0"):
# path == None means wrong URL
if path is None: if path is None:
# Wrong URL
return return
# path should already be sanitized # Path should already be sanitized
sane_path = sanitize_path(path).strip("/") sane_path = sanitize_path(path).strip("/")
attributes = sane_path.split("/") attributes = sane_path.split("/")
if not attributes[0]: if not attributes[0]:
@ -401,24 +425,31 @@ class Collection(BaseCollection):
except ValueError: except ValueError:
# Path is unsafe # Path is unsafe
return return
href = None
if not os.path.isdir(filesystem_path): if not os.path.isdir(filesystem_path):
if attributes and os.path.isfile(filesystem_path): if attributes and os.path.isfile(filesystem_path):
href = attributes.pop() href = attributes.pop()
else: else:
return return
else:
href = None
path = "/".join(attributes) path = "/".join(attributes)
principal = len(attributes) == 1 principal = len(attributes) == 1
collection = cls(path, principal) collection = cls(path, principal)
if href: if href:
yield collection.get(href) yield collection.get(href)
return return
yield collection yield collection
if depth == "0": if depth == "0":
return return
for item in collection.list(): for item in collection.list():
yield collection.get(item[0]) yield collection.get(item[0])
for href in os.listdir(filesystem_path): for href in os.listdir(filesystem_path):
if not is_safe_filesystem_path_component(href): if not is_safe_filesystem_path_component(href):
cls.logger.debug("Skipping collection: %s", href) cls.logger.debug("Skipping collection: %s", href)
@ -432,7 +463,7 @@ class Collection(BaseCollection):
def create_collection(cls, href, collection=None, props=None): def create_collection(cls, href, collection=None, props=None):
folder = cls._get_collection_root_folder() folder = cls._get_collection_root_folder()
# path should already be sanitized # Path should already be sanitized
sane_path = sanitize_path(href).strip("/") sane_path = sanitize_path(href).strip("/")
attributes = sane_path.split("/") attributes = sane_path.split("/")
if not attributes[0]: if not attributes[0]:
@ -450,40 +481,37 @@ class Collection(BaseCollection):
parent_dir = os.path.dirname(filesystem_path) parent_dir = os.path.dirname(filesystem_path)
os.makedirs(parent_dir, exist_ok=True) os.makedirs(parent_dir, exist_ok=True)
with TemporaryDirectory(prefix=".Radicale.tmp-",
dir=parent_dir) as tmp_dir: # Create a temporary directory with an unsafe name
with TemporaryDirectory(
prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir:
# The temporary directory itself can't be renamed # The temporary directory itself can't be renamed
tmp_filesystem_path = os.path.join(tmp_dir, "collection") tmp_filesystem_path = os.path.join(tmp_dir, "collection")
os.makedirs(tmp_filesystem_path) os.makedirs(tmp_filesystem_path)
# path is unsafe
self = cls("/", principal=principal, folder=tmp_filesystem_path) self = cls("/", principal=principal, folder=tmp_filesystem_path)
self.set_meta(props) self.set_meta(props)
if props.get("tag") == "VCALENDAR":
if collection: if collection:
if props.get("tag") == "VCALENDAR":
collection, = collection collection, = collection
items = [] items = []
for content in ("vevent", "vtodo", "vjournal"): for content in ("vevent", "vtodo", "vjournal"):
items.extend(getattr(collection, "%s_list" % content, items.extend(
[])) getattr(collection, "%s_list" % content, []))
items_by_uid = groupby(sorted(items, key=get_uid), get_uid)
def get_uid(item):
return hasattr(item, "uid") and item.uid.value
items_by_uid = groupby(
sorted(items, key=get_uid), get_uid)
for uid, items in items_by_uid: for uid, items in items_by_uid:
new_collection = vobject.iCalendar() new_collection = vobject.iCalendar()
for item in items: for item in items:
new_collection.add(item) new_collection.add(item)
self.upload( self.upload(
self._find_available_file_name(), new_collection) self._find_available_file_name(), new_collection)
elif props.get("tag") == "VCARD": elif props.get("tag") == "VCARD":
if collection:
for card in collection: for card in collection:
self.upload(self._find_available_file_name(), card) self.upload(self._find_available_file_name(), card)
os.rename(tmp_filesystem_path, filesystem_path) os.rename(tmp_filesystem_path, filesystem_path)
sync_directory(parent_dir) sync_directory(parent_dir)
return cls(sane_path, principal=principal) return cls(sane_path, principal=principal)
def list(self): def list(self):
@ -498,7 +526,7 @@ class Collection(BaseCollection):
continue continue
path = os.path.join(self._filesystem_path, href) path = os.path.join(self._filesystem_path, href)
if os.path.isfile(path): if os.path.isfile(path):
with open(path, encoding=self.storage_encoding) as fd: with open(path, encoding=self.encoding) as fd:
yield href, get_etag(fd.read()) yield href, get_etag(fd.read())
def get(self, href): def get(self, href):
@ -507,12 +535,12 @@ class Collection(BaseCollection):
href = href.strip("{}").replace("/", "_") href = href.strip("{}").replace("/", "_")
if not is_safe_filesystem_path_component(href): if not is_safe_filesystem_path_component(href):
self.logger.debug( self.logger.debug(
"Can't tranlate name safely to filesystem: %s", href) "Can't translate name safely to filesystem: %s", href)
return None return None
path = path_to_filesystem(self._filesystem_path, href) path = path_to_filesystem(self._filesystem_path, href)
if not os.path.isfile(path): if not os.path.isfile(path):
return None return None
with open(path, encoding=self.storage_encoding) as fd: with open(path, encoding=self.encoding) as fd:
text = fd.read() text = fd.read()
last_modified = time.strftime( last_modified = time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", "%a, %d %b %Y %H:%M:%S GMT",
@ -525,11 +553,10 @@ class Collection(BaseCollection):
def upload(self, href, vobject_item): def upload(self, href, vobject_item):
# TODO: use returned object in code # TODO: use returned object in code
if not is_safe_filesystem_path_component(href): if not is_safe_filesystem_path_component(href):
raise ValueError( raise UnsafePathError(href)
"Can't tranlate name safely to filesystem: %s" % href)
path = path_to_filesystem(self._filesystem_path, href) path = path_to_filesystem(self._filesystem_path, href)
if os.path.exists(path): if os.path.exists(path):
raise ValueError("Component already exists: %s" % href) raise ComponentExistsError(href)
item = Item(self, vobject_item, href) item = Item(self, vobject_item, href)
with self._atomic_write(path) as fd: with self._atomic_write(path) as fd:
fd.write(item.serialize()) fd.write(item.serialize())
@ -539,16 +566,14 @@ class Collection(BaseCollection):
# TODO: use etag in code and test it here # TODO: use etag in code and test it here
# TODO: use returned object in code # TODO: use returned object in code
if not is_safe_filesystem_path_component(href): if not is_safe_filesystem_path_component(href):
raise ValueError( raise UnsafePathError(href)
"Can't tranlate name safely to filesystem: %s" % href)
path = path_to_filesystem(self._filesystem_path, href) path = path_to_filesystem(self._filesystem_path, href)
if not os.path.isfile(path): if not os.path.isfile(path):
raise ValueError("Component doesn't exist: %s" % href) raise ComponentNotFoundError(href)
with open(path, encoding=self.storage_encoding) as fd: with open(path, encoding=self.encoding) as fd:
text = fd.read() text = fd.read()
if etag and etag != get_etag(text): if etag and etag != get_etag(text):
raise ValueError( raise EtagMismatchError(etag, get_etag(text))
"ETag doesn't match: %s != %s" % (etag, get_etag(text)))
item = Item(self, vobject_item, href) item = Item(self, vobject_item, href)
with self._atomic_write(path) as fd: with self._atomic_write(path) as fd:
fd.write(item.serialize()) fd.write(item.serialize())
@ -564,31 +589,28 @@ class Collection(BaseCollection):
else: else:
# Delete an item # Delete an item
if not is_safe_filesystem_path_component(href): if not is_safe_filesystem_path_component(href):
raise ValueError( raise UnsafePathError(href)
"Can't tranlate name safely to filesystem: %s" % href)
path = path_to_filesystem(self._filesystem_path, href) path = path_to_filesystem(self._filesystem_path, href)
if not os.path.isfile(path): if not os.path.isfile(path):
raise ValueError("Component doesn't exist: %s" % href) raise ComponentNotFoundError(href)
with open(path, encoding=self.storage_encoding) as fd: with open(path, encoding=self.encoding) as fd:
text = fd.read() text = fd.read()
if etag and etag != get_etag(text): if etag and etag != get_etag(text):
raise ValueError( raise EtagMismatchError(etag, get_etag(text))
"ETag doesn't match: %s != %s" % (etag, get_etag(text)))
os.remove(path) os.remove(path)
def get_meta(self, key): def get_meta(self, key):
if os.path.exists(self._props_path): if os.path.exists(self._props_path):
with open(self._props_path, encoding=self.storage_encoding) as prop: with open(self._props_path, encoding=self.encoding) as prop:
return json.load(prop).get(key) return json.load(prop).get(key)
def set_meta(self, props): def set_meta(self, props):
if os.path.exists(self._props_path): if os.path.exists(self._props_path):
with open(self._props_path, encoding=self.storage_encoding) as prop: with open(self._props_path, encoding=self.encoding) as prop:
old_props = json.load(prop) old_props = json.load(prop)
old_props.update(props) old_props.update(props)
props = old_props props = old_props
# filter empty entries props = {key: value for key, value in props.items() if value}
props = {k:v for k,v in props.items() if v}
with self._atomic_write(self._props_path, "w+") as prop: with self._atomic_write(self._props_path, "w+") as prop:
json.dump(props, prop) json.dump(props, prop)
@ -609,7 +631,7 @@ class Collection(BaseCollection):
continue continue
path = os.path.join(self._filesystem_path, href) path = os.path.join(self._filesystem_path, href)
if os.path.isfile(path): if os.path.isfile(path):
with open(path, encoding=self.storage_encoding) as fd: with open(path, encoding=self.encoding) as fd:
items.append(vobject.readOne(fd.read())) items.append(vobject.readOne(fd.read()))
if self.get_meta("tag") == "VCALENDAR": if self.get_meta("tag") == "VCALENDAR":
collection = vobject.iCalendar() collection = vobject.iCalendar()
@ -640,13 +662,11 @@ class Collection(BaseCollection):
else: else:
return not cls._writer and cls._readers == 0 return not cls._writer and cls._readers == 0
if mode not in ("r", "w"):
raise ValueError("Invalid lock mode: %s" % mode)
# Use a primitive lock which only works within one process as a # Use a primitive lock which only works within one process as a
# precondition for inter-process file-based locking # precondition for inter-process file-based locking
with cls._lock: with cls._lock:
if cls._waiters or not condition(): if cls._waiters or not condition():
# use FIFO for access requests # Use FIFO for access requests
waiter = threading.Condition(lock=cls._lock) waiter = threading.Condition(lock=cls._lock)
cls._waiters.append(waiter) cls._waiters.append(waiter)
while True: while True:
@ -656,7 +676,7 @@ class Collection(BaseCollection):
cls._waiters.pop(0) cls._waiters.pop(0)
if mode == "r": if mode == "r":
cls._readers += 1 cls._readers += 1
# notify additional potential readers # Notify additional potential readers
if cls._waiters: if cls._waiters:
cls._waiters[0].notify() cls._waiters[0].notify()
else: else:
@ -668,7 +688,7 @@ class Collection(BaseCollection):
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
lock_path = os.path.join(folder, ".Radicale.lock") lock_path = os.path.join(folder, ".Radicale.lock")
cls._lock_file = open(lock_path, "w+") cls._lock_file = open(lock_path, "w+")
# set access rights to a necessary minimum to prevent locking # Set access rights to a necessary minimum to prevent locking
# by arbitrary users # by arbitrary users
try: try:
os.chmod(lock_path, stat.S_IWUSR | stat.S_IRUSR) os.chmod(lock_path, stat.S_IWUSR | stat.S_IRUSR)

View File

@ -30,11 +30,13 @@ import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http import client
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
import vobject
from . import client, storage MIMETYPES = {
"VADDRESSBOOK": "text/vcard",
"VCALENDAR": "text/calendar"}
NAMESPACES = { NAMESPACES = {
"C": "urn:ietf:params:xml:ns:caldav", "C": "urn:ietf:params:xml:ns:caldav",
@ -44,21 +46,12 @@ NAMESPACES = {
"ICAL": "http://apple.com/ns/ical/", "ICAL": "http://apple.com/ns/ical/",
"ME": "http://me.com/_namespace/"} "ME": "http://me.com/_namespace/"}
NAMESPACES_REV = {} NAMESPACES_REV = {}
for short, url in NAMESPACES.items(): for short, url in NAMESPACES.items():
NAMESPACES_REV[url] = short NAMESPACES_REV[url] = short
ET.register_namespace("" if short == "D" else short, url) ET.register_namespace("" if short == "D" else short, url)
CLARK_TAG_REGEX = re.compile(r" {(?P<namespace>[^}]*)}(?P<tag>.*)", re.VERBOSE)
CLARK_TAG_REGEX = re.compile(r"""
{ # {
(?P<namespace>[^}]*) # namespace URL
} # }
(?P<tag>.*) # short tag name
""", re.VERBOSE)
def _pretty_xml(element, level=0): def _pretty_xml(element, level=0):
@ -430,7 +423,7 @@ def name_from_path(path, collection):
collection_parts = collection_path.split("/") if collection_path else [] collection_parts = collection_path.split("/") if collection_path else []
path = path.strip("/") path = path.strip("/")
path_parts = path.split("/") if path else [] path_parts = path.split("/") if path else []
if (len(path_parts) - len(collection_parts)): if len(path_parts) - len(collection_parts):
return path_parts[-1] return path_parts[-1]
@ -479,7 +472,7 @@ def delete(path, collection, href=None):
""" """
collection.delete(href) collection.delete(href)
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus")) multistatus = ET.Element(_tag("D", "multistatus"))
response = ET.Element(_tag("D", "response")) response = ET.Element(_tag("D", "response"))
multistatus.append(response) multistatus.append(response)
@ -504,22 +497,20 @@ def propfind(path, xml_request, read_collections, write_collections, user):
in the output. in the output.
""" """
# Reading request
if xml_request: if xml_request:
root = ET.fromstring(xml_request.encode("utf8")) root = ET.fromstring(xml_request.encode("utf8"))
props = [prop.tag for prop in root.find(_tag("D", "prop"))] props = [prop.tag for prop in root.find(_tag("D", "prop"))]
else: else:
props = [_tag("D", "getcontenttype"), props = [
_tag("D", "resourcetype"), _tag("D", "getcontenttype"),
_tag("D", "displayname"), _tag("D", "resourcetype"),
_tag("D", "owner"), _tag("D", "displayname"),
_tag("D", "getetag"), _tag("D", "owner"),
_tag("ICAL", "calendar-color"), _tag("D", "getetag"),
_tag("CS", "getctag")] _tag("ICAL", "calendar-color"),
_tag("CS", "getctag")]
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus")) multistatus = ET.Element(_tag("D", "multistatus"))
collections = [] collections = []
for collection in write_collections: for collection in write_collections:
collections.append(collection) collections.append(collection)
@ -603,10 +594,11 @@ def _propfind_response(path, item, props, user, write=False):
element.append(comp) element.append(comp)
else: else:
is404 = True is404 = True
elif tag in (_tag("D", "current-user-principal"), elif tag in (
_tag("C", "calendar-user-address-set"), _tag("D", "current-user-principal"),
_tag("CR", "addressbook-home-set"), _tag("C", "calendar-user-address-set"),
_tag("C", "calendar-home-set")): _tag("CR", "addressbook-home-set"),
_tag("C", "calendar-home-set")):
tag = ET.Element(_tag("D", "href")) tag = ET.Element(_tag("D", "href"))
tag.text = _href(collection, ("/%s/" % user) if user else "/") tag.text = _href(collection, ("/%s/" % user) if user else "/")
element.append(tag) element.append(tag)
@ -632,7 +624,7 @@ def _propfind_response(path, item, props, user, write=False):
if tag == _tag("D", "getcontenttype"): if tag == _tag("D", "getcontenttype"):
item_tag = item.get_meta("tag") item_tag = item.get_meta("tag")
if item_tag: if item_tag:
element.text = storage.MIMETYPES[item_tag] element.text = MIMETYPES[item_tag]
else: else:
is404 = True is404 = True
elif tag == _tag("D", "resourcetype"): elif tag == _tag("D", "resourcetype"):
@ -717,10 +709,7 @@ def _add_propstat_to(element, tag, status_number):
prop = ET.Element(_tag("D", "prop")) prop = ET.Element(_tag("D", "prop"))
propstat.append(prop) propstat.append(prop)
if "{" in tag: clark_tag = tag if "{" in tag else _tag(*tag.split(":", 1))
clark_tag = tag
else:
clark_tag = _tag(*tag.split(":", 1))
prop_tag = ET.Element(clark_tag) prop_tag = ET.Element(clark_tag)
prop.append(prop_tag) prop.append(prop_tag)
@ -735,14 +724,11 @@ def proppatch(path, xml_request, collection):
Read rfc4918-9.2 for info. Read rfc4918-9.2 for info.
""" """
# Reading request
root = ET.fromstring(xml_request.encode("utf8")) root = ET.fromstring(xml_request.encode("utf8"))
props_to_set = props_from_request(root, actions=("set",)) props_to_set = props_from_request(root, actions=("set",))
props_to_remove = props_from_request(root, actions=("remove",)) props_to_remove = props_from_request(root, actions=("remove",))
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus")) multistatus = ET.Element(_tag("D", "multistatus"))
response = ET.Element(_tag("D", "response")) response = ET.Element(_tag("D", "response"))
multistatus.append(response) multistatus.append(response)
@ -750,11 +736,8 @@ def proppatch(path, xml_request, collection):
href.text = _href(collection, path) href.text = _href(collection, path)
response.append(href) response.append(href)
# Merge props_to_set and props_to_remove
for short_name in props_to_remove: for short_name in props_to_remove:
props_to_set[short_name] = "" props_to_set[short_name] = ""
# Set/Delete props in one atomic operation
collection.set_meta(props_to_set) collection.set_meta(props_to_set)
for short_name in props_to_set: for short_name in props_to_set:
@ -769,17 +752,16 @@ def report(path, xml_request, collection):
Read rfc3253-3.6 for info. Read rfc3253-3.6 for info.
""" """
# Reading request
root = ET.fromstring(xml_request.encode("utf8")) root = ET.fromstring(xml_request.encode("utf8"))
prop_element = root.find(_tag("D", "prop")) prop_element = root.find(_tag("D", "prop"))
props = ( props = (
[prop.tag for prop in prop_element] [prop.tag for prop in prop_element]
if prop_element is not None else []) if prop_element is not None else [])
if collection: if collection:
if root.tag in (_tag("C", "calendar-multiget"), if root.tag in (
_tag("CR", "addressbook-multiget")): _tag("C", "calendar-multiget"),
_tag("CR", "addressbook-multiget")):
# Read rfc4791-7.9 for info # Read rfc4791-7.9 for info
base_prefix = collection.configuration.get("server", "base_prefix") base_prefix = collection.configuration.get("server", "base_prefix")
hreferences = set() hreferences = set()
@ -795,24 +777,19 @@ def report(path, xml_request, collection):
else: else:
hreferences = filters = () hreferences = filters = ()
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus")) multistatus = ET.Element(_tag("D", "multistatus"))
for hreference in hreferences: for hreference in hreferences:
# Check if the reference is an item or a collection
name = name_from_path(hreference, collection) name = name_from_path(hreference, collection)
if name: if name:
# Reference is an item # Reference is an item
path = "/".join(hreference.split("/")[:-1]) + "/" path = "/".join(hreference.split("/")[:-1]) + "/"
item = collection.get(name) item = collection.get(name)
if item is None: if item is None:
multistatus.append( response = _item_response(hreference, found_item=False)
_item_response(hreference, found_item=False)) multistatus.append(response)
continue continue
items = [item] items = [item]
else: else:
# Reference is a collection # Reference is a collection
path = hreference path = hreference
@ -840,8 +817,9 @@ def report(path, xml_request, collection):
"text/vcard" if name == "vcard" else "text/calendar") "text/vcard" if name == "vcard" else "text/calendar")
element.text = "%s; component=%s" % (mimetype, name) element.text = "%s; component=%s" % (mimetype, name)
found_props.append(element) found_props.append(element)
elif tag in (_tag("C", "calendar-data"), elif tag in (
_tag("CR", "address-data")): _tag("C", "calendar-data"),
_tag("CR", "address-data")):
element.text = item.serialize() element.text = item.serialize()
found_props.append(element) found_props.append(element)
else: else:
@ -869,27 +847,17 @@ def _item_response(href, found_props=(), not_found_props=(), found_item=True):
response.append(href_tag) response.append(href_tag)
if found_item: if found_item:
if found_props: for code, props in ((200, found_props), (404, not_found_props)):
propstat = ET.Element(_tag("D", "propstat")) if props:
status = ET.Element(_tag("D", "status")) propstat = ET.Element(_tag("D", "propstat"))
status.text = _response(200) status = ET.Element(_tag("D", "status"))
prop = ET.Element(_tag("D", "prop")) status.text = _response(code)
for p in found_props: prop_tag = ET.Element(_tag("D", "prop"))
prop.append(p) for prop in props:
propstat.append(prop) prop_tag.append(prop)
propstat.append(status) propstat.append(prop_tag)
response.append(propstat) propstat.append(status)
response.append(propstat)
if not_found_props:
propstat = ET.Element(_tag("D", "propstat"))
status = ET.Element(_tag("D", "status"))
status.text = _response(404)
prop = ET.Element(_tag("D", "prop"))
for p in not_found_props:
prop.append(p)
propstat.append(prop)
propstat.append(status)
response.append(propstat)
else: else:
status = ET.Element(_tag("D", "status")) status = ET.Element(_tag("D", "status"))
status.text = _response(404) status.text = _response(404)