Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<d:getetag/>` 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]
Expand Down
18 changes: 16 additions & 2 deletions nc_py_api/files/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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 `<d:getetag/>`, which arrives here as None
self.etag = kwargs.get("etag") or ""
self.info = FsNodeInfo(**kwargs)
self.lock_info = FsNodeLockInfo(**kwargs)

Expand All @@ -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 (
Expand Down
17 changes: 17 additions & 0 deletions tests/actual_tests/files_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
88 changes: 88 additions & 0 deletions tests_unit/test_etag_normalization.py
Original file line number Diff line number Diff line change
@@ -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 `<d:getetag/>`, 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"