diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b101ff4..190be1a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file. ## [0.30.3 - 2026-08-03] +### Added + +- `FsNode.etag_unquoted` returning the entity tag without the double quotes the server wraps it in, for comparing or storing the bare value. `FsNode.etag` keeps what the server sent, so it can still be passed to an `If-Match`/`If-None-Match` header unchanged. #448 Thanks to @kyteinsky + ### Fixed +- `FsNode.etag` is always a string now; trashbin entries used to yield `None`, because the server sends an empty `` there. #448 - PROPFIND property lists are no longer mutated in place. `get_propfind_properties()` and both `trashbin_list()` implementations extended the shared `PROPFIND_PROPERTIES` constant with `+=`, so it grew on every call (7 entries per call against servers advertising `files.locking`, 3 per `trashbin_list()`). Long-running clients ended up sending multi-megabyte PROPFIND bodies that could exhaust the server's workers. Both property constants are now immutable tuples, so this class of bug cannot come back. #453 Thanks to @ciberkids ## [0.30.2 - 2026-06-02] diff --git a/nc_py_api/files/__init__.py b/nc_py_api/files/__init__.py index e6a986f3..e089c7b0 100644 --- a/nc_py_api/files/__init__.py +++ b/nc_py_api/files/__init__.py @@ -210,7 +210,12 @@ class FsNode: """File ID + NC instance ID""" etag: str - """An entity tag (ETag) of the object""" + """An entity tag (ETag) of the object, exactly as the server sent it, including the double quotes around it. + + Send it back as-is, e.g. ``{"If-Match": fs_node.etag}``: the quotes are part of the entity tag + (:rfc:`9110#section-8.8.3`), and a server rejects the precondition without them. + Use :py:attr:`~nc_py_api.files.FsNode.etag_unquoted` to compare or store the bare value. + """ info: FsNodeInfo """Additional extra information for the object""" @@ -221,7 +226,8 @@ class FsNode: def __init__(self, full_path: str, **kwargs): self.full_path = full_path self.file_id = kwargs.get("file_id", "") - self.etag = kwargs.get("etag", "") + # the trashbin sends an empty ``, which arrives here as None + self.etag = kwargs.get("etag") or "" self.info = FsNodeInfo(**kwargs) self.lock_info = FsNodeLockInfo(**kwargs) @@ -230,6 +236,14 @@ def is_dir(self) -> bool: """Returns ``True`` for the directories, ``False`` otherwise.""" return self.full_path.endswith("/") + @property + def etag_unquoted(self) -> str: + """:py:attr:`~nc_py_api.files.FsNode.etag` without the surrounding double quotes. + + For comparing or storing the bare tag; use :py:attr:`~nc_py_api.files.FsNode.etag` in request headers. + """ + return self.etag.strip('"') + def __str__(self): if self.info.is_version: return ( diff --git a/tests/actual_tests/files_test.py b/tests/actual_tests/files_test.py index 66440acf..c42b6df7 100644 --- a/tests/actual_tests/files_test.py +++ b/tests/actual_tests/files_test.py @@ -1300,3 +1300,20 @@ async def test_file_locking_async(anc_any): with pytest.raises(NextcloudException) as e: await anc_any.files.unlock(test_file) assert e.value.status_code == 412 + + +def test_etag_is_accepted_by_server_as_is(nc_any): + """`FsNode.etag` must be usable in a request header without the caller touching it.""" + nc_any.files.delete("test_etag_as_is.txt", not_fail=True) + node = nc_any.files.upload("test_etag_as_is.txt", b"content") + listed = nc_any.files.by_path("test_etag_as_is.txt") + assert node.etag == listed.etag + assert listed.etag_unquoted == listed.etag.strip('"') + dav_path = f"/files/{nc_any.user}/test_etag_as_is.txt" + unchanged = nc_any._session.adapter_dav.request("GET", dav_path, headers={"If-None-Match": listed.etag}) + assert unchanged.status_code == 304 + overwritten = nc_any._session.adapter_dav.request( + "PUT", dav_path, data=b"new content", headers={"If-Match": listed.etag} + ) + assert overwritten.status_code in (200, 204) + nc_any.files.delete("test_etag_as_is.txt", not_fail=True) diff --git a/tests_unit/test_etag_normalization.py b/tests_unit/test_etag_normalization.py new file mode 100644 index 00000000..2dcaff12 --- /dev/null +++ b/tests_unit/test_etag_normalization.py @@ -0,0 +1,88 @@ +"""Tests for FsNode.etag: kept exactly as the server sent it, with a bare variant next to it.""" + +from nc_py_api.files import ActionFileInfo, FsNode +from nc_py_api.files._files import _parse_record, etag_fileid_from_response + + +class _FakeResponse: + def __init__(self, headers: dict): + self.headers = headers + + +def _prop_stat(etag) -> dict: + return { + "d:status": "HTTP/1.1 200 OK", + "d:prop": {"oc:id": "00000123", "oc:fileid": "123", "oc:permissions": "RGDNVW", "d:getetag": etag}, + } + + +def test_etag_is_kept_as_the_server_sent_it(): + # the quotes are part of the entity tag, so `etag` stays usable in a request header as-is + assert FsNode("files/admin/a.txt", etag='"6a351fb28bebc"').etag == '"6a351fb28bebc"' + + +def test_etag_unquoted_strips_the_quotes(): + assert FsNode("files/admin/a.txt", etag='"6a351fb28bebc"').etag_unquoted == "6a351fb28bebc" + + +def test_unquoted_etag_passes_through_both_ways(): + # versions endpoints answer with a bare timestamp instead of a quoted tag + node = FsNode("files/admin/a.txt", etag="1785767946") + assert node.etag == "1785767946" + assert node.etag_unquoted == "1785767946" + + +def test_missing_and_empty_etag_become_empty_string(): + for node in (FsNode("files/admin/a.txt"), FsNode("files/admin/a.txt", etag=""), FsNode("f/a", etag=None)): + assert node.etag == "" + assert node.etag_unquoted == "" + + +def test_propfind_record_keeps_the_quoted_etag(): + assert _parse_record("files/admin/a.txt", [_prop_stat('"6a351fb28bebc"')]).etag == '"6a351fb28bebc"' + assert _parse_record("files/admin/a.txt", [_prop_stat('"6a351fb28bebc"')]).etag_unquoted == "6a351fb28bebc" + # the trashbin sends ``, which arrives as None + assert _parse_record("files/admin/a.txt", [_prop_stat(None)]).etag == "" + + +def test_oc_etag_header_keeps_the_quoted_etag(): + response = _FakeResponse({"OC-Etag": '"e9673fb8e3e49ff7cbbff9f21e9c60d1"', "OC-FileId": "00000123"}) + node = FsNode("files/admin/a.txt", **etag_fileid_from_response(response)) + assert node.etag == '"e9673fb8e3e49ff7cbbff9f21e9c60d1"' + assert node.etag_unquoted == "e9673fb8e3e49ff7cbbff9f21e9c60d1" + + +def test_etag_missing_from_headers(): + response = _FakeResponse({"OC-FileId": "00000123"}) + assert FsNode("files/admin/a.txt", **etag_fileid_from_response(response)).etag == "" + + +def test_both_sources_agree_for_the_same_file(): + from_propfind = _parse_record("files/admin/a.txt", [_prop_stat('"e9673fb8e3e49ff7cbbff9f21e9c60d1"')]) + from_header = FsNode( + "files/admin/a.txt", + **etag_fileid_from_response( + _FakeResponse({"OC-Etag": '"e9673fb8e3e49ff7cbbff9f21e9c60d1"', "OC-FileId": "00000123"}) + ), + ) + assert from_propfind.etag == from_header.etag + assert from_propfind.etag_unquoted == from_header.etag_unquoted == "e9673fb8e3e49ff7cbbff9f21e9c60d1" + + +def test_action_file_info_to_fs_node_keeps_etag(): + # the ExApp UI file actions build FsNode from data the server posts to the ExApp + action_file = ActionFileInfo( + fileId=123, + name="a.txt", + directory="/", + etag='"6a351fb28bebc"', + mime="text/plain", + fileType="file", + size=7, + favorite="false", + permissions=27, + mtime=1785767946, + userId="admin", + ) + assert action_file.to_fs_node().etag == '"6a351fb28bebc"' + assert action_file.to_fs_node().etag_unquoted == "6a351fb28bebc"