From 9d1cb26f0b324b9cb01cbb71e717fa6ee0cec988 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 8 Jun 2026 06:31:55 +0200 Subject: [PATCH 1/6] add 2 inode test cases --- radicale/tests/test_storage.py | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/radicale/tests/test_storage.py b/radicale/tests/test_storage.py index 1ae28047..a00859aa 100644 --- a/radicale/tests/test_storage.py +++ b/radicale/tests/test_storage.py @@ -143,6 +143,46 @@ class TestMultiFileSystem(BaseTest): assert answer1 == answer2 assert os.path.exists(os.path.join(cache_folder, "event1.ics")) + def test_put_items_multiple(self) -> None: + """Upload 2 items to calendar, check that collection inode number stays.""" + self.configure({"logging": {"response_content_on_debug": "False", + "response_header_on_debug": "False", + "request_content_on_debug": "False", + "request_header_on_debug": "False", + }}) + self.mkcalendar("/calendar.ics/") + event1 = get_file_content("event1.ics") + path1 = "/calendar.ics/event1.ics" + event2 = get_file_content("event2.ics") + path2 = "/calendar.ics/event2.ics" + self.put(path1, event1) + collection_folder = os.path.join(self.colpath, "collection-root", "calendar.ics") + collection_folder_st_ino_1 = os.stat(collection_folder).st_ino + logger.debug("path=%r stat.ST_INO=%d", collection_folder, collection_folder_st_ino_1) + self.put(path2, event2) + collection_folder_st_ino_2 = os.stat(collection_folder).st_ino + logger.debug("path=%r stat.ST_INO=%d", collection_folder, collection_folder_st_ino_2) + assert collection_folder_st_ino_1 == collection_folder_st_ino_2 + + def test_put_calendar_multiple(self) -> None: + """Upload 2 calendars, check that collection inode number changes.""" + self.configure({"logging": {"response_content_on_debug": "False", + "response_header_on_debug": "False", + "request_content_on_debug": "False", + "request_header_on_debug": "False", + }}) + self.put("/calendar.ics/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR") + collection_folder = os.path.join(self.colpath, "collection-root", + "calendar.ics") + collection_folder_st_ino_1 = os.stat(collection_folder).st_ino + logger.debug("path=%r stat.ST_INO=%d", collection_folder, collection_folder_st_ino_1) + # Overwrite + events = get_file_content("event_multiple.ics") + self.put("/calendar.ics/", events) + collection_folder_st_ino_2 = os.stat(collection_folder).st_ino + logger.debug("path=%r stat.ST_INO=%d", collection_folder, collection_folder_st_ino_2) + assert collection_folder_st_ino_1 != collection_folder_st_ino_2 + def test_put_whole_calendar_uids_used_as_file_names(self) -> None: """Test if UIDs are used as file names.""" _TestBaseRequests.test_put_whole_calendar( From 8aec777e16bf88cfd835741aaa156ba6948fc92b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 8 Jun 2026 21:44:33 +0200 Subject: [PATCH 2/6] flake8 fix --- radicale/tests/test_storage.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/radicale/tests/test_storage.py b/radicale/tests/test_storage.py index a00859aa..d2eb0f80 100644 --- a/radicale/tests/test_storage.py +++ b/radicale/tests/test_storage.py @@ -172,8 +172,7 @@ class TestMultiFileSystem(BaseTest): "request_header_on_debug": "False", }}) self.put("/calendar.ics/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR") - collection_folder = os.path.join(self.colpath, "collection-root", - "calendar.ics") + collection_folder = os.path.join(self.colpath, "collection-root", "calendar.ics") collection_folder_st_ino_1 = os.stat(collection_folder).st_ino logger.debug("path=%r stat.ST_INO=%d", collection_folder, collection_folder_st_ino_1) # Overwrite From 8e9216cd08fe33b46e23b113ff29c795799f835f Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 8 Jun 2026 21:59:24 +0200 Subject: [PATCH 3/6] sharing: report+propfind honor base_prefix on backmap --- radicale/app/propfind.py | 4 ++-- radicale/app/report.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 1a574c68..13d1f2fc 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -257,8 +257,8 @@ def xml_propfind_response( child_element.text = xmlutils.make_href(base_prefix, path) if share: # backmap - if child_element.text.startswith(share['PathMapped']): - child_element.text = str(share['PathOrToken']) + child_element.text.removeprefix(share['PathMapped']) + if child_element.text.startswith(base_prefix + share['PathMapped']): + child_element.text = base_prefix + str(share['PathOrToken']) + child_element.text.removeprefix(base_prefix + share['PathMapped']) if share_bday_automap and child_element.text.endswith(".vcf"): child_element.text = child_element.text.removesuffix(".vcf") + ".ics" element.append(child_element) diff --git a/radicale/app/report.py b/radicale/app/report.py index 05fda541..807a923a 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -739,11 +739,11 @@ def xml_item_response(base_prefix: str, href: str, href_element = ET.Element(xmlutils.make_clark("D:href")) href_element.text = xmlutils.make_href(base_prefix, href) - logger.trace("REPORT/xml_report: href=%r", href_element.text) + logger.trace("REPORT/xml_report: href=%r base_prefix=%r", href_element.text, base_prefix) if share: # backmap - if href_element.text.startswith(share['PathMapped']): - href_element.text = str(share['PathOrToken']) + href_element.text.removeprefix(share['PathMapped']) + if href_element.text.startswith(base_prefix + share['PathMapped']): + href_element.text = base_prefix + str(share['PathOrToken']) + href_element.text.removeprefix(base_prefix + share['PathMapped']) if share_bday_automap and href_element.text.endswith(".vcf"): href_element.text = href_element.text.removesuffix(".vcf") + ".ics" logger.trace("REPORT/xml_report: href=%r (backmapped)", href_element.text) From 83b00f1b7b27af7770925f3ffdcea68f54fa91e7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 8 Jun 2026 21:59:57 +0200 Subject: [PATCH 4/6] sharing/bday conversion: add additional test cases via proxy --- radicale/tests/test_sharing.py | 141 +++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 6 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 447893a2..5a01b643 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -86,13 +86,13 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept, prefix=prefix) return _, headers, answer - def _propfind_allprop(self, path: str, login: str = "", prefix: Union[str, None] = None, check=207) -> dict: + def _propfind_allprop(self, path: str, login: str = "", prefix: Union[str, None] = None, check=207, x_forwarded_for: Union[str, None] = None) -> dict: propfind_allprop = get_file_content("allprop.xml") if prefix is not None: path = prefix + path _, responses = self.propfind(path=path, data=propfind_allprop, login=login, x_forwarded_for="127.0.0.2", check=check) else: - _, responses = self.propfind(path=path, data=propfind_allprop, login=login, check=check) + _, responses = self.propfind(path=path, data=propfind_allprop, login=login, check=check, x_forwarded_for=x_forwarded_for) logging.info("response: %r", responses) if check != 207: return {} @@ -5845,7 +5845,9 @@ permissions: RrWw""") assert int(str(prop.text)) == 2 def test_sharing_api_map_vcf_bday_self(self) -> None: - """share-by-map with conversion=bday to self tests.""" + """share-by-map with conversion=bday to self tests (without and with simulated proxy).""" + script_name = "/radicale" + self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, @@ -5859,6 +5861,7 @@ permissions: RrWw""") "response_header_on_debug": "True", "response_content_on_debug": "True", "request_content_on_debug": "True"}, + "server": {"script_name": script_name}, "rights": {"type": "owner_only"}}) json_dict: dict @@ -5899,6 +5902,17 @@ permissions: RrWw""") assert not isinstance(response, int) assert "CR:supported-address-data" in response + logging.info("\n*** PROPFIND collection owner via proxy -> ok") + _, responses = self.propfind(script_name + path_mapped, """\ + + + +""", login="owner:ownerpw", x_forwarded_for="127.0.0.2") + logging.info("response: %r", responses) + response = responses[script_name + path_mapped] + assert not isinstance(response, int) + assert "CR:supported-address-data" in response + # execute GET as owner logging.info("\n*** GET VCF collection owner -> ok") _, answer = self.get(path_mapped, login="owner:ownerpw") @@ -5948,6 +5962,36 @@ permissions: RrWw""") assert "{urn:ietf:params:xml:ns:carddav}addressbook" in resourcetypes.tag assert "{urn:ietf:params:xml:ns:caldav}calendar" not in resourcetypes.tag + logging.info("\n*** PROPFIND all as owner via proxy") + _, responses = self.propfind(script_name + path_owner, """\ + + + + + + + + + + + + + + +""", login="owner:ownerpw", HTTP_DEPTH="1", x_forwarded_for="127.0.0.2") + # logging.debug("responses: %r", responses) + response = responses[script_name + path_mapped] + assert not isinstance(response, int) + logging.debug("response %r: %r", path_mapped, response) + assert "C:supported-calendar-component-set" in response + assert path_shared not in responses + assert "D:resourcetype" in response + status, resourcetype = response["D:resourcetype"] + resourcetypes = resourcetype.find(xmlutils.make_clark("CR:addressbook")) + assert resourcetypes is not None + assert "{urn:ietf:params:xml:ns:carddav}addressbook" in resourcetypes.tag + assert "{urn:ietf:params:xml:ns:caldav}calendar" not in resourcetypes.tag + # enable + unhide logging.info("\n*** enable+unhide bday owner to itself -> ok") json_dict = {} @@ -5957,7 +6001,7 @@ permissions: RrWw""") _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) # check PROPFIND item as owner - logging.info("\n*** PROPFIND all as owner") + logging.info("\n*** PROPFIND item as owner") _, responses = self.propfind(path_owner, """\ @@ -5995,8 +6039,47 @@ permissions: RrWw""") assert {'name': 'VTODO'} not in comp_attr assert {'name': 'VJOURNAL'} not in comp_attr - # check PROPFIND item as owner - logging.info("\n*** PROPFIND item as owner -> calendar") + # check PROPFIND item as owner via proxy + logging.info("\n*** PROPFIND collection as owner via proxy") + _, responses = self.propfind(script_name + path_owner, """\ + + + + + + + + + + + + + + +""", login="owner:ownerpw", HTTP_DEPTH="1", x_forwarded_for="127.0.0.2") + # logging.debug("responses: %r", responses) + response = responses[script_name + path_shared] + assert not isinstance(response, int) + logging.debug("response %r: %r", path_mapped, response) + assert "C:supported-calendar-component-set" in response + assert script_name + path_shared in responses + status, resourcetype = response["D:resourcetype"] + resourcetypes = resourcetype.find(xmlutils.make_clark("C:calendar")) + assert resourcetypes is not None + assert "{urn:ietf:params:xml:ns:carddav}addressbook" not in resourcetypes.tag + assert "{urn:ietf:params:xml:ns:caldav}calendar" in resourcetypes.tag + status, sup_cal_comp_set = response["C:supported-calendar-component-set"] + sup_cal_comp_sets = sup_cal_comp_set.findall(xmlutils.make_clark("C:comp")) + comp_attr = [] + for comp in sup_cal_comp_sets: + comp_attr.append(comp.attrib) + logging.debug("comp: %r", comp.attrib) + assert {'name': 'VEVENT'} in comp_attr + assert {'name': 'VTODO'} not in comp_attr + assert {'name': 'VJOURNAL'} not in comp_attr + + # check PROPFIND collection as owner + logging.info("\n*** PROPFIND collection as owner -> calendar") response = self._propfind_allprop(path_shared, login="owner:ownerpw") logging.debug("response: %r", response) assert "CR:supported-address-data" not in response @@ -6004,6 +6087,15 @@ permissions: RrWw""") assert "C:supported-calendar-component-set" in response assert "D:current-user-privilege-set" in response + # check PROPFIND collection as owner via proxy + logging.info("\n*** PROPFIND collection as owner via proxy -> calendar") + response = self._propfind_allprop(script_name + path_shared, login="owner:ownerpw", x_forwarded_for="127.0.0.2") + logging.debug("response: %r", response) + assert "CR:supported-address-data" not in response + assert "D:sync-token" not in response + assert "C:supported-calendar-component-set" in response + assert "D:current-user-privilege-set" in response + # verify content as owner logging.info("\n*** GET collection owner -> ok") _, headers, answer = self.request("GET", path_shared, login="owner:ownerpw") @@ -6013,6 +6105,43 @@ permissions: RrWw""") assert 'Content-Disposition' in headers assert 'Calendar.ics' in headers['Content-Disposition'] + # verify content as owner via proxy + logging.info("\n*** GET collection owner via proxy -> ok") + _, headers, answer = self.request("GET", script_name + path_shared, login="owner:ownerpw", x_forwarded_for="127.0.0.2") + assert 'Content-Type' in headers + assert 'text/calendar' in headers['Content-Type'] + # title from default + assert 'Content-Disposition' in headers + assert 'Calendar.ics' in headers['Content-Disposition'] + + # verify report as owner + logging.info("\n*** REPORT collection owner -> ok") + _, responses = self.report(path_shared, """\ + + + + + +""", login="owner:ownerpw") + logging.debug("resonses: %r", responses) + assert path_shared + "contact2-with-bday.ics" in responses + assert path_shared + "contact3-with-bday.ics" in responses + assert path_shared + "contact1.ics" not in responses + + # verify report as owner via proxy + logging.info("\n*** REPORT collection owner -> ok") + _, responses = self.report(script_name + path_shared, """\ + + + + + +""", login="owner:ownerpw", x_forwarded_for="127.0.0.2") + logging.debug("resonses: %r", responses) + assert script_name + path_shared + "contact2-with-bday.ics" in responses + assert script_name + path_shared + "contact3-with-bday.ics" in responses + assert script_name + path_shared + "contact1.ics" not in responses + def test_sharing_api_token_vcf_bday(self) -> None: """share-by-bday to a token tests.""" self.configure({"auth": {"type": "htpasswd", From 8d25f93dc038abd4db68caef1b1ec559953b7519 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 8 Jun 2026 22:02:01 +0200 Subject: [PATCH 5/6] changelog for fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8116d2f7..bc46c290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Add: [sharing] conversion_bday_age_max (limit in case of "age" placeholder is used which blocks using RRULE) * Extension: [sharing/bday conversion]: add STATUS + CLASS fields * Improve: sanitize item align timezone (add/remove) of EXDATE or RDATE with DTSTART +* Fix: sharing: bday conversion backmap on PROPFIND/REPORT if script_name is active (via reverse proxy) ## 3.7.4 * Fix: sharing: PROPFIND returns now empty owner element in case of a mapped share as clients try PROPFIND on this not accessable href From f684d3814727118d6c72f4adaf65d794e18b7277 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 9 Jun 2026 07:28:57 +0200 Subject: [PATCH 6/6] rework tests with proxy simulation --- radicale/tests/test_sharing.py | 40 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 5a01b643..2e3bd408 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -60,39 +60,31 @@ class TestSharingApiSanity(BaseTest): f.write(htpasswd_content) # Helper functions - def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str, None], data: str, content_type: str, accept: Union[str, None], prefix: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str, None], data: str, content_type: str, accept: Union[str, None], x_forwarded_for: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: path_base = "/.sharing/v1/" + sharing_type + "/" - if prefix is not None: - path_base = prefix + path_base - _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept, x_forwarded_for="127.0.0.2") - else: - _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept) + _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept, x_forwarded_for=x_forwarded_for) logging.info("received answer:\n%s", "\n".join(answer.splitlines())) return _, headers, answer - def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str, None], form_array: Sequence[str], accept: Union[str, None] = None, prefix: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str, None], form_array: Sequence[str], accept: Union[str, None] = None, x_forwarded_for: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: data = "&".join(form_array) content_type = "application/x-www-form-urlencoded" if accept is None: accept = "text/plain" - _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept, prefix=prefix) + _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept, x_forwarded_for=x_forwarded_for) return _, headers, answer - def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str, None], json_dict: dict, accept: Union[str, None] = None, prefix: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str, None], json_dict: dict, accept: Union[str, None] = None, x_forwarded_for: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: data = json.dumps(json_dict) content_type = "application/json" if accept is None: accept = "application/json" - _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept, prefix=prefix) + _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept, x_forwarded_for=x_forwarded_for) return _, headers, answer - def _propfind_allprop(self, path: str, login: str = "", prefix: Union[str, None] = None, check=207, x_forwarded_for: Union[str, None] = None) -> dict: + def _propfind_allprop(self, path: str, login: str = "", check=207, x_forwarded_for: Union[str, None] = None) -> dict: propfind_allprop = get_file_content("allprop.xml") - if prefix is not None: - path = prefix + path - _, responses = self.propfind(path=path, data=propfind_allprop, login=login, x_forwarded_for="127.0.0.2", check=check) - else: - _, responses = self.propfind(path=path, data=propfind_allprop, login=login, check=check, x_forwarded_for=x_forwarded_for) + _, responses = self.propfind(path=path, data=propfind_allprop, login=login, check=check, x_forwarded_for=x_forwarded_for) logging.info("response: %r", responses) if check != 207: return {} @@ -784,10 +776,10 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** create token") json_dict = {} - json_dict["PathMapped"] = script_name + path_base + json_dict["PathMapped"] = path_base json_dict["Enabled"] = True json_dict["Hidden"] = False - _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, prefix=script_name) + _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, x_forwarded_for="127.0.0.2") answer_dict = json.loads(answer) assert "Status" in answer_dict assert "PathOrToken" in answer_dict @@ -796,9 +788,17 @@ class TestSharingApiSanity(BaseTest): assert Token.startswith(script_name) is True path_shared = Token - # check PROPFIND item as owner (remove prefix again as added later) + # check PROPFIND item as owner without proxy + logging.info("\n*** PROPFIND item as owner via proxy -> calendar") + response = self._propfind_allprop(path_shared, login="owner:ownerpw", x_forwarded_for="127.0.0.2") + logging.debug("response: %r", response) + assert "CR:supported-address-data" not in response + assert "C:supported-calendar-component-set" in response + assert "D:current-user-privilege-set" in response + + # check PROPFIND item as owner without proxy logging.info("\n*** PROPFIND item as owner -> calendar") - response = self._propfind_allprop(path_shared.removeprefix(script_name), login="owner:ownerpw", prefix=script_name) + response = self._propfind_allprop(path_shared.removeprefix(script_name), login="owner:ownerpw") logging.debug("response: %r", response) assert "CR:supported-address-data" not in response assert "C:supported-calendar-component-set" in response