Skip to content
Draft
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: 4 additions & 1 deletion pyiceberg/io/fsspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
TYPE_CHECKING,
Any,
)
from urllib.parse import ParseResult, urlparse
from urllib.parse import ParseResult, parse_qs, urlparse

import requests
from fsspec import AbstractFileSystem
Expand Down Expand Up @@ -148,6 +148,9 @@ def __call__(self, request: "AWSRequest", **_: Any) -> None:
"uri": request.url,
"headers": {key: [val] for key, val in request.headers.items()},
}
if request.method == "POST" and "delete" in parse_qs(urlparse(request.url).query, keep_blank_values=True):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the Java client's existing predicate and body handling: POST with a delete query key. The REST contract calls out DeleteObjects because its body contains the keys that the signing service must validate; the URL identifies only the bucket. keep_blank_values=True makes both ?delete and ?delete= recognizable. Other POST operations, including multipart completion, keep their existing behavior.

if body := request.body:
signer_body["body"] = body.decode("utf-8")

@smaheshwar-pltr smaheshwar-pltr Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bytes assumption comes from Botocore's REST XML serializer, which serializes the request using its UTF-8 default. AWSRequest.body exposes that prepared body. Decoding creates the string expected by the signing API; it does not replace the original XML bytes or checksum headers sent to S3. The guards above prevent accessing unrelated upload streams.


response = self._session.post(f"{signer_url}/{signer_endpoint.strip()}", headers=signer_headers, json=signer_body)
try:
Expand Down
44 changes: 44 additions & 0 deletions tests/io/test_fsspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import tempfile
import threading
import uuid
from io import BytesIO
from unittest import mock

import pytest
Expand Down Expand Up @@ -995,6 +996,49 @@ def test_s3v4_rest_signer(requests_mock: Mocker) -> None:
}


@pytest.mark.parametrize("query", ["delete", "delete=", "delete=&x-id=DeleteObjects"])
def test_s3v4_rest_signer_sends_delete_objects_body(requests_mock: Mocker, query: str) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is reachable even when deleting one file: FsspecFileIO.delete calls fs.rm, and s3fs batches those paths through _bulk_delete, which calls DeleteObjects. All three cases here failed before the fix because the captured signing JSON had no body. The six cases below separately guard against forwarding or reading unrelated request bodies.

body = "<Delete><Object><Key>data/space + percent%2F-snowman-☃</Key></Object></Delete>"
uri = f"https://bucket.s3.us-west-2.amazonaws.com/?{query}"
request = AWSRequest(method="POST", url=uri, data=body.encode("utf-8"), headers={"x-amz-checksum-crc32": "AAAAAA=="})
request.context["client_region"] = "us-west-2"
requests_mock.post(f"{TEST_URI}/v1/aws/s3/sign", json={"uri": uri, "headers": {}})

S3V4RestSigner(properties={"uri": TEST_URI})(request)

assert requests_mock.last_request is not None
signed_request = requests_mock.last_request.json()
assert signed_request["body"] == body
assert signed_request["headers"]["x-amz-checksum-crc32"] == ["AAAAAA=="]
assert request.body == body.encode("utf-8")


@pytest.mark.parametrize(
"method,query",
[
("PUT", "delete"),
("POST", "uploads"),
("POST", "uploadId=upload"),
("GET", "delete"),
("POST", "undelete"),
("POST", "key=delete"),
],
)
def test_s3v4_rest_signer_does_not_read_other_bodies(requests_mock: Mocker, method: str, query: str) -> None:
body = BytesIO(b"\x00\xffobject data")
uri = f"https://bucket.s3.us-west-2.amazonaws.com/key?{query}"
request = AWSRequest(method=method, url=uri, data=body)
request.context["client_region"] = "us-west-2"
requests_mock.post(f"{TEST_URI}/v1/aws/s3/sign", json={"uri": uri, "headers": {}})

S3V4RestSigner(properties={"uri": TEST_URI})(request)

assert requests_mock.last_request is not None
assert "body" not in requests_mock.last_request.json()
assert request.body is body
assert body.tell() == 0


def test_s3v4_rest_signer_endpoint(requests_mock: Mocker) -> None:
new_uri = "https://other-bucket/metadata/snap-8048355899640248710-1-a5c8ea2d-aa1f-48e8-89f4-1fa69db8c742.avro"
endpoint = "v1/main/s3-sign/foo.bar?e=e&b=b&k=k=k&s=s&w=w"
Expand Down
Loading