|
| 1 | +# Copyright 2026 Google LLC |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Helpers for GCS S3-compatible XML multipart uploads.""" |
| 16 | + |
| 17 | +import datetime |
| 18 | +import hashlib |
| 19 | +import uuid |
| 20 | +import xml.etree.ElementTree as ET |
| 21 | + |
| 22 | +import gcs.object |
| 23 | +import testbench |
| 24 | +import testbench.common |
| 25 | +from gcs.upload import Upload |
| 26 | + |
| 27 | +MIN_PART_SIZE = 5 * 1024 * 1024 # 5 MiB |
| 28 | + |
| 29 | + |
| 30 | +def _create_upload_id(bucket_name, object_name): |
| 31 | + return hashlib.sha256( |
| 32 | + ("%s/%s/o/%s" % (uuid.uuid4().hex, bucket_name, object_name)).encode("utf-8") |
| 33 | + ).hexdigest() |
| 34 | + |
| 35 | + |
| 36 | +def init_xml_multipart(request, bucket, object_name): |
| 37 | + """Create an Upload representing an S3-style multipart upload. |
| 38 | +
|
| 39 | + Captures metadata from request headers at initiate time; parts are |
| 40 | + accumulated later via xml_upload_part. |
| 41 | + """ |
| 42 | + upload_id = _create_upload_id(bucket.name, object_name) |
| 43 | + metadata = { |
| 44 | + "bucket": bucket.name, |
| 45 | + "name": object_name, |
| 46 | + "metadata": {"x_emulator_upload": "xml_multipart"}, |
| 47 | + } |
| 48 | + headers = request.headers |
| 49 | + if "content-type" in headers: |
| 50 | + metadata["contentType"] = headers["content-type"] |
| 51 | + if headers.get("content-encoding"): |
| 52 | + metadata["contentEncoding"] = headers["content-encoding"] |
| 53 | + if headers.get("content-disposition"): |
| 54 | + metadata["contentDisposition"] = headers["content-disposition"] |
| 55 | + if headers.get("content-language"): |
| 56 | + metadata["contentLanguage"] = headers["content-language"] |
| 57 | + elif headers.get("x-goog-content-language"): |
| 58 | + metadata["contentLanguage"] = headers["x-goog-content-language"] |
| 59 | + if headers.get("cache-control"): |
| 60 | + metadata["cacheControl"] = headers["cache-control"] |
| 61 | + if headers.get("x-goog-storage-class"): |
| 62 | + metadata["storageClass"] = headers["x-goog-storage-class"] |
| 63 | + |
| 64 | + # Collect x-goog-meta-* custom metadata |
| 65 | + custom = metadata["metadata"] |
| 66 | + for key, value in headers.items(): |
| 67 | + lower = key.lower() |
| 68 | + if lower.startswith("x-goog-meta-"): |
| 69 | + custom[lower[len("x-goog-meta-") :]] = value |
| 70 | + |
| 71 | + fake_request = testbench.common.FakeRequest.init_xml(request) |
| 72 | + predefined_acl = headers.get("x-goog-acl") |
| 73 | + if predefined_acl: |
| 74 | + fake_request.args["predefinedAcl"] = predefined_acl |
| 75 | + preconditions = testbench.common.make_xml_preconditions(fake_request) |
| 76 | + |
| 77 | + upload = Upload( |
| 78 | + request=fake_request, |
| 79 | + metadata=metadata, |
| 80 | + bucket=bucket, |
| 81 | + location="", |
| 82 | + upload_id=upload_id, |
| 83 | + media=b"", |
| 84 | + complete=False, |
| 85 | + transfer=set(), |
| 86 | + parts={}, |
| 87 | + kind="xml_multipart", |
| 88 | + ) |
| 89 | + upload.preconditions = preconditions |
| 90 | + return upload |
| 91 | + |
| 92 | + |
| 93 | +# --- ETag helpers --- |
| 94 | + |
| 95 | + |
| 96 | +def compute_part_etag(data): |
| 97 | + return '"%s"' % hashlib.md5(data).hexdigest() |
| 98 | + |
| 99 | + |
| 100 | +def compute_multipart_etag(part_etags_in_order): |
| 101 | + """Compute the S3-style composite ETag from per-part ETags. |
| 102 | +
|
| 103 | + Each element of *part_etags_in_order* is a quoted hex-md5 string |
| 104 | + (e.g. '"abc123..."'). The composite ETag is the MD5 of the |
| 105 | + concatenated *binary* MD5 digests, suffixed with ``-N``. |
| 106 | + """ |
| 107 | + binary = b"" |
| 108 | + for etag in part_etags_in_order: |
| 109 | + hex_str = etag.strip('"') |
| 110 | + binary += bytes.fromhex(hex_str) |
| 111 | + composite = hashlib.md5(binary).hexdigest() |
| 112 | + return '"%s-%d"' % (composite, len(part_etags_in_order)) |
| 113 | + |
| 114 | + |
| 115 | +# --- XML response builders --- |
| 116 | + |
| 117 | +_XML_NS = "http://s3.amazonaws.com/doc/2006-03-01/" |
| 118 | + |
| 119 | + |
| 120 | +def _xml_preamble(): |
| 121 | + return '<?xml version="1.0" encoding="UTF-8"?>\n' |
| 122 | + |
| 123 | + |
| 124 | +def _el(tag, text): |
| 125 | + e = ET.Element(tag) |
| 126 | + e.text = str(text) |
| 127 | + return e |
| 128 | + |
| 129 | + |
| 130 | +def build_initiate_response_xml(bucket, key, upload_id): |
| 131 | + root = ET.Element("InitiateMultipartUploadResult", xmlns=_XML_NS) |
| 132 | + root.append(_el("Bucket", bucket)) |
| 133 | + root.append(_el("Key", key)) |
| 134 | + root.append(_el("UploadId", upload_id)) |
| 135 | + return (_xml_preamble() + ET.tostring(root, encoding="unicode")).encode("utf-8") |
| 136 | + |
| 137 | + |
| 138 | +def build_complete_response_xml(location, bucket, key, etag): |
| 139 | + root = ET.Element("CompleteMultipartUploadResult", xmlns=_XML_NS) |
| 140 | + root.append(_el("Location", location)) |
| 141 | + root.append(_el("Bucket", bucket)) |
| 142 | + root.append(_el("Key", key)) |
| 143 | + root.append(_el("ETag", etag)) |
| 144 | + return (_xml_preamble() + ET.tostring(root, encoding="unicode")).encode("utf-8") |
| 145 | + |
| 146 | + |
| 147 | +def build_list_parts_response_xml( |
| 148 | + bucket, |
| 149 | + key, |
| 150 | + upload_id, |
| 151 | + parts_page, |
| 152 | + part_number_marker, |
| 153 | + next_marker, |
| 154 | + max_parts, |
| 155 | + is_truncated, |
| 156 | +): |
| 157 | + """Build ListPartsResult XML. |
| 158 | +
|
| 159 | + *parts_page* is a list of (part_number, last_modified_datetime, etag, size). |
| 160 | + """ |
| 161 | + root = ET.Element("ListPartsResult", xmlns=_XML_NS) |
| 162 | + root.append(_el("Bucket", bucket)) |
| 163 | + root.append(_el("Key", key)) |
| 164 | + root.append(_el("UploadId", upload_id)) |
| 165 | + root.append(_el("PartNumberMarker", part_number_marker)) |
| 166 | + if next_marker is not None: |
| 167 | + root.append(_el("NextPartNumberMarker", next_marker)) |
| 168 | + root.append(_el("MaxParts", max_parts)) |
| 169 | + root.append(_el("IsTruncated", "true" if is_truncated else "false")) |
| 170 | + for part_number, last_modified, etag, size in parts_page: |
| 171 | + part_el = ET.SubElement(root, "Part") |
| 172 | + part_el.append(_el("PartNumber", part_number)) |
| 173 | + part_el.append( |
| 174 | + _el("LastModified", last_modified.strftime("%Y-%m-%dT%H:%M:%S.000Z")) |
| 175 | + ) |
| 176 | + part_el.append(_el("ETag", etag)) |
| 177 | + part_el.append(_el("Size", size)) |
| 178 | + return (_xml_preamble() + ET.tostring(root, encoding="unicode")).encode("utf-8") |
| 179 | + |
| 180 | + |
| 181 | +# --- XML request parsing --- |
| 182 | + |
| 183 | + |
| 184 | +def parse_complete_request_xml(body): |
| 185 | + """Parse a CompleteMultipartUpload XML body. |
| 186 | +
|
| 187 | + Returns [(part_number: int, etag: str), ...] in document order. |
| 188 | + Tolerates the optional S3 namespace. |
| 189 | + """ |
| 190 | + try: |
| 191 | + root = ET.fromstring(body) |
| 192 | + except ET.ParseError: |
| 193 | + testbench.error.invalid("CompleteMultipartUpload XML", context=None) |
| 194 | + # Handle optional namespace |
| 195 | + ns = "" |
| 196 | + if root.tag.startswith("{"): |
| 197 | + ns = root.tag.split("}")[0] + "}" |
| 198 | + parts = [] |
| 199 | + for part in root.findall(ns + "Part"): |
| 200 | + part_number_text = part.findtext(ns + "PartNumber") |
| 201 | + try: |
| 202 | + pn = int(part_number_text) |
| 203 | + except (TypeError, ValueError): |
| 204 | + testbench.error.invalid("CompleteMultipartUpload XML", context=None) |
| 205 | + etag = part.findtext(ns + "ETag") |
| 206 | + parts.append((pn, etag)) |
| 207 | + return parts |
| 208 | + |
| 209 | + |
| 210 | +# --- Validation --- |
| 211 | + |
| 212 | + |
| 213 | +def validate_min_part_size(requested_parts, upload): |
| 214 | + """Raise 400 if any non-final part is smaller than MIN_PART_SIZE.""" |
| 215 | + for i, (part_number, _etag) in enumerate(requested_parts): |
| 216 | + is_last = i == len(requested_parts) - 1 |
| 217 | + if is_last: |
| 218 | + break |
| 219 | + part = upload.parts.get(part_number) |
| 220 | + if part is None: |
| 221 | + testbench.error.invalid("Part %d not uploaded" % part_number, context=None) |
| 222 | + if len(part["data"]) < MIN_PART_SIZE: |
| 223 | + testbench.error.generic( |
| 224 | + "EntityTooSmall: Part %d is %d bytes, minimum is %d" |
| 225 | + % (part_number, len(part["data"]), MIN_PART_SIZE), |
| 226 | + 400, |
| 227 | + None, |
| 228 | + None, |
| 229 | + ) |
| 230 | + |
| 231 | + |
| 232 | +def validate_requested_parts(requested_parts, upload): |
| 233 | + """Raise 400 if the completion manifest does not match uploaded parts.""" |
| 234 | + for part_number, requested_etag in requested_parts: |
| 235 | + part = upload.parts.get(part_number) |
| 236 | + if part is None: |
| 237 | + testbench.error.invalid("Part %d not uploaded" % part_number, context=None) |
| 238 | + actual_etag = part["etag"] |
| 239 | + if requested_etag != actual_etag: |
| 240 | + testbench.error.generic( |
| 241 | + "InvalidPart: Part %d ETag %s does not match uploaded part %s" |
| 242 | + % (part_number, requested_etag, actual_etag), |
| 243 | + 400, |
| 244 | + None, |
| 245 | + None, |
| 246 | + ) |
| 247 | + |
| 248 | + |
| 249 | +def validate_requested_part_order(requested_parts): |
| 250 | + """Raise 400 if the completion manifest is not strictly ascending.""" |
| 251 | + last_part_number = None |
| 252 | + for part_number, _requested_etag in requested_parts: |
| 253 | + if last_part_number is not None and part_number <= last_part_number: |
| 254 | + testbench.error.generic("InvalidPartOrder", 400, None, None) |
| 255 | + last_part_number = part_number |
| 256 | + |
| 257 | + |
| 258 | +def validate_requested_parts_not_empty(requested_parts): |
| 259 | + """Raise 400 if the completion manifest does not include any parts.""" |
| 260 | + if not requested_parts: |
| 261 | + testbench.error.generic( |
| 262 | + "InvalidRequest: CompleteMultipartUpload requires at least one part", |
| 263 | + 400, |
| 264 | + None, |
| 265 | + None, |
| 266 | + ) |
| 267 | + |
| 268 | + |
| 269 | +# --- Finalization --- |
| 270 | + |
| 271 | + |
| 272 | +def finalize_multipart(upload, requested_parts): |
| 273 | + """Assemble parts into a finalized Object. |
| 274 | +
|
| 275 | + *requested_parts* comes from parse_complete_request_xml. |
| 276 | + Returns (blob, multipart_etag_str). |
| 277 | + """ |
| 278 | + validate_requested_parts_not_empty(requested_parts) |
| 279 | + validate_requested_part_order(requested_parts) |
| 280 | + validate_min_part_size(requested_parts, upload) |
| 281 | + validate_requested_parts(requested_parts, upload) |
| 282 | + |
| 283 | + media = b"" |
| 284 | + etags_in_order = [] |
| 285 | + for part_number, _etag in requested_parts: |
| 286 | + part = upload.parts.get(part_number) |
| 287 | + media += part["data"] |
| 288 | + etags_in_order.append(part["etag"]) |
| 289 | + |
| 290 | + multipart_etag = compute_multipart_etag(etags_in_order) |
| 291 | + |
| 292 | + metadata = dict(upload.metadata) |
| 293 | + metadata["metadata"] = dict(metadata.get("metadata", {})) |
| 294 | + metadata["metadata"]["x_emulator_upload"] = "xml_multipart" |
| 295 | + |
| 296 | + blob, _ = gcs.object.Object.init_dict( |
| 297 | + upload.request, metadata, media, upload.bucket, False |
| 298 | + ) |
| 299 | + return blob, multipart_etag |
0 commit comments