Merge pull request #1898 from pbiering/issue-1897-strict_precondition

Issue 1897 strict preconditions
This commit is contained in:
Peter Bieringer
2025-10-19 21:22:03 +02:00
committed by GitHub
9 changed files with 62 additions and 2 deletions

View File

@@ -4,6 +4,7 @@
* Extend [auth]: re-factor & overhaul LDAP authentication, especially for Python's ldap module
* Fix: out-of-range timestamp on 32-bit systems
* Feature: extend logging with response size in bytes and flag served as plain or gzip
* Feature: [storage] strict_preconditions: new config option to enforce strict preconditions check on PUT in case item already exists [RFC6352#9.2]
## 3.5.7
* Extend: [auth] dovecot: add support for version >= 2.4

View File

@@ -1523,6 +1523,14 @@ Skip broken item instead of triggering an exception
Default: `True`
##### strict_preconditions
_(>= 3.5.8)_
Strict preconditions check on PUT in case item already exists [RFC6352#9.2](https://www.rfc-editor.org/rfc/rfc6352#section-9.2)
Default: `False`
##### hook
Command that is run after changes to storage. See the

3
config
View File

@@ -242,6 +242,9 @@
# Skip broken item instead of triggering an exception
#skip_broken_item = True
# Strict preconditions check on PUT
#strict_preconditions = False
# Command that is run after changes to storage, default is emtpy
# Supported placeholders:
# %(user)s: logged-in user

View File

@@ -73,8 +73,6 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
_web_type: str
_script_name: str
_extra_headers: Mapping[str, str]
_permit_delete_collection: bool
_permit_overwrite_collection: bool
def __init__(self, configuration: config.Configuration) -> None:
"""Initialize Application.
@@ -116,6 +114,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
self._extra_headers = dict()
for key in self.configuration.options("headers"):
self._extra_headers[key] = configuration.get("headers", key)
self._strict_preconditions = configuration.get("storage", "strict_preconditions")
logger.info("strict preconditions check: %s", self._strict_preconditions)
def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
"""Mask passwords and cookies."""

View File

@@ -41,6 +41,7 @@ class ApplicationBase:
_encoding: str
_permit_delete_collection: bool
_permit_overwrite_collection: bool
_strict_preconditions: bool
_hook: hook.BaseHook
def __init__(self, configuration: config.Configuration) -> None:

View File

@@ -207,6 +207,9 @@ class ApplicationPartPut(ApplicationBase):
return httputils.NOT_ALLOWED
etag = environ.get("HTTP_IF_MATCH", "")
if item and not etag and self._strict_preconditions:
logger.warning("Precondition failed for %r: existing item, no If-Match header, strict mode enabled", path)
return httputils.PRECONDITION_FAILED
if not item and etag:
# Etag asked but no item found: item has been removed
logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item not existing)", path, etag)

View File

@@ -430,6 +430,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "",
"help": "command that is run after changes to storage",
"type": str}),
("strict_preconditions", {
"value": "False",
"help": "strict preconditions check on PUT",
"type": bool}),
("_filesystem_fsync", {
"value": "True",
"help": "sync all changes to filesystem during requests",

View File

@@ -75,6 +75,10 @@ class BaseTest:
if login is not None and not isinstance(login, str):
raise TypeError("login argument must be %r, not %r" %
(str, type(login)))
http_if_match = kwargs.pop("http_if_match", None)
if http_if_match is not None and not isinstance(http_if_match, str):
raise TypeError("http_if_match argument must be %r, not %r" %
(str, type(http_if_match)))
environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()}
for k, v in environ.items():
if not isinstance(v, str):
@@ -84,6 +88,8 @@ class BaseTest:
if login:
environ["HTTP_AUTHORIZATION"] = "Basic " + base64.b64encode(
login.encode(encoding)).decode()
if http_if_match:
environ["HTTP_IF_MATCH"] = http_if_match
environ["REQUEST_METHOD"] = method.upper()
environ["PATH_INFO"] = path
if data is not None:

View File

@@ -229,6 +229,40 @@ permissions: RrWw""")
_, answer = self.get(path)
assert "DTSTAMP:20130902T150159Z" in answer
def test_update_event_no_etag_strict_preconditions_true(self) -> None:
"""Update an event without serving etag."""
self.configure({"storage": {"strict_preconditions": True}})
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event_modified = get_file_content("event1_modified.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event, check=201)
self.put(path, event_modified, check=412)
def test_update_event_with_etag_strict_preconditions_true(self) -> None:
"""Update an event with serving etag."""
self.configure({"storage": {"strict_preconditions": True}})
self.configure({"logging": {"response_content_on_debug": True}})
self.mkcalendar("/calendar.ics/")
event = get_file_content("event1.ics")
event_modified = get_file_content("event1_modified.ics")
path = "/calendar.ics/event1.ics"
self.put(path, event, check=201)
# get etag
_, responses = self.report("/calendar.ics/", """\
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop xmlns:D="DAV:">
<D:getetag/>
</D:prop>
</C:calendar-query>""")
assert len(responses) == 1
response = responses["/calendar.ics/event1.ics"]
assert not isinstance(response, int)
status, prop = response["D:getetag"]
assert status == 200 and prop.text
self.put(path, event_modified, check=204, http_if_match=prop.text)
def test_update_event_uid_event(self) -> None:
"""Update an event with a different UID."""
self.mkcalendar("/calendar.ics/")