private void handleCopyPart(HttpServletRequest request,
            HttpServletResponse response, BlobStore blobStore,
            String containerName, String blobName, String uploadId)
            throws IOException, S3Exception {
        // TODO: duplicated from handlePutBlob
        String copySourceHeader = request.getHeader(AwsHttpHeaders.COPY_SOURCE);
        copySourceHeader = URLDecoder.decode(
                copySourceHeader, StandardCharsets.UTF_8);
        if (copySourceHeader.startsWith("/")) {
            // Some clients like boto do not include the leading slash
            copySourceHeader = copySourceHeader.substring(1);
        }
        String[] path = copySourceHeader.split("/", 2);
        if (path.length != 2) {
            throw new S3Exception(S3ErrorCode.INVALID_REQUEST);
        }
        String sourceContainerName = path[0];
        String sourceBlobName = path[1];

        var options = new GetOptions();
        String range = request.getHeader(AwsHttpHeaders.COPY_SOURCE_RANGE);
        long expectedSize = -1;
        if (range != null) {
            if (!range.startsWith("bytes=") || range.indexOf(',') != -1 ||
                range.indexOf('-') == -1) {
                throw new S3Exception(S3ErrorCode.INVALID_ARGUMENT,
                    "The x-amz-copy-source-range value must be of the form " +
                    "bytes=first-last where first and last are the " +
                    "zero-based offsets of the first and last bytes to copy");
            }
            try {
                range = range.substring("bytes=".length());
                String[] ranges = range.split("-", 2);
                if (ranges[0].isEmpty()) {
                    options.tail(Long.parseLong(ranges[1]));
                } else if (ranges[1].isEmpty()) {
                    options.startAt(Long.parseLong(ranges[0]));
                } else {
                    long start = Long.parseLong(ranges[0]);
                    long end = Long.parseLong(ranges[1]);
                    expectedSize = end - start + 1;
                    if (expectedSize > MAX_MULTIPART_COPY_SIZE) {
                        throw new S3Exception(S3ErrorCode.INVALID_REQUEST,
                                "The specified copy source is larger than" +
                                " the maximum allowable size for a copy" +
                                " source: " + MAX_MULTIPART_COPY_SIZE);
                    }
                    options.range(start, end);
                }
            } catch (NumberFormatException nfe) {
                throw new S3Exception(S3ErrorCode.INVALID_ARGUMENT,
                    "The x-amz-copy-source-range value must be of the form " +
                    "bytes=first-last where first and last are the " +
                    "zero-based offsets of the first and last bytes to copy",
                    nfe);
            }
        }

        String partNumberString = request.getParameter("partNumber");
        if (partNumberString == null) {
            throw new S3Exception(S3ErrorCode.INVALID_ARGUMENT);
        }
        int partNumber;
        try {
            partNumber = Integer.parseInt(partNumberString);
        } catch (NumberFormatException nfe) {
            throw new S3Exception(S3ErrorCode.INVALID_ARGUMENT,
                    "Part number must be an integer between 1 and 10000" +
                    ", inclusive", nfe, Map.of(
                            "ArgumentName", "partNumber",
                            "ArgumentValue", partNumberString));
        }
        if (partNumber < 1 || partNumber > 10_000) {
            throw new S3Exception(S3ErrorCode.INVALID_ARGUMENT,
                    "Part number must be an integer between 1 and 10000" +
                    ", inclusive", (Throwable) null, Map.of(
                            "ArgumentName", "partNumber",
                            "ArgumentValue", partNumberString));
        }

        // GCS only supports 32 parts so partition MPU into 32-part chunks.
        String blobStoreType = getBlobStoreType(blobStore);
        if (blobStoreType.equals("google-cloud-storage")) {
            // fix up 1-based part numbers
            uploadId = String.format(
                    "%s_%08d", uploadId, ((partNumber - 1) / 32) + 1);
            partNumber = ((partNumber - 1) % 32) + 1;
        }

        // TODO: how to reconstruct original mpu?
        MultipartUpload mpu = MultipartUpload.create(containerName,
                blobName, uploadId, createFakeBlobMetadata(blobStore),
                new PutOptions());

        // TODO: Blob can leak on precondition failures.
        Blob blob = blobStore.getBlob(sourceContainerName, sourceBlobName,
                options);
        if (blob == null) {
            throw new S3Exception(S3ErrorCode.NO_SUCH_KEY);
        }

        BlobMetadata blobMetadata = blob.getMetadata();
        // HTTP GET allow overlong ranges but S3 CopyPart does not
        if (expectedSize != -1 && blobMetadata.getSize() < expectedSize) {
            throw new S3Exception(S3ErrorCode.INVALID_RANGE);
        }

        String ifMatch = request.getHeader(
                AwsHttpHeaders.COPY_SOURCE_IF_MATCH);
        String ifNoneMatch = request.getHeader(
                AwsHttpHeaders.COPY_SOURCE_IF_NONE_MATCH);
        long ifModifiedSince = request.getDateHeader(
                AwsHttpHeaders.COPY_SOURCE_IF_MODIFIED_SINCE);
        long ifUnmodifiedSince = request.getDateHeader(
                AwsHttpHeaders.COPY_SOURCE_IF_UNMODIFIED_SINCE);
        String eTag = blobMetadata.getETag();
        if (eTag != null) {
            eTag = maybeQuoteETag(eTag);
            if (ifMatch != null && !ifMatch.equals(eTag)) {
                throw new S3Exception(S3ErrorCode.PRECONDITION_FAILED);
            }
            if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) {
                throw new S3Exception(S3ErrorCode.PRECONDITION_FAILED);
            }
        }

        Date lastModified = blobMetadata.getLastModified();
        if (lastModified != null) {
            if (ifModifiedSince != -1 && lastModified.compareTo(
                    new Date(ifModifiedSince)) <= 0) {
                throw new S3Exception(S3ErrorCode.PRECONDITION_FAILED);
            }
            if (ifUnmodifiedSince != -1 && lastModified.compareTo(
                    new Date(ifUnmodifiedSince)) >= 0) {
                throw new S3Exception(S3ErrorCode.PRECONDITION_FAILED);
            }
        }

        long contentLength =
                blobMetadata.getContentMetadata().getContentLength();

        try (InputStream is = blob.getPayload().openStream()) {
            if (blobStoreType.equals("azureblob")) {
                // Azure has a smaller maximum part size than S3.  Split a
                // single S3 part multiple Azure parts.
                long azureMaximumMultipartPartSize =
                        blobStore.getMaximumMultipartPartSize();
                var his = new HashingInputStream(MD5, is);
                int subPartNumber = 0;
                for (long offset = 0; offset < contentLength;
                        offset += azureMaximumMultipartPartSize,
                        ++subPartNumber) {
                    Payload payload = Payloads.newInputStreamPayload(
                            new UncloseableInputStream(ByteStreams.limit(his,
                                    azureMaximumMultipartPartSize)));
                    payload.getContentMetadata().setContentLength(
                            Math.min(azureMaximumMultipartPartSize,
                                    contentLength - offset));
                    blobStore.uploadMultipartPart(mpu,
                            10_000 * partNumber + subPartNumber, payload);
                }
                eTag = BaseEncoding.base16().lowerCase().encode(
                        his.hash().asBytes());
            } else {
                Payload payload = Payloads.newInputStreamPayload(is);
                payload.getContentMetadata().setContentLength(contentLength);

                MultipartPart part = blobStore.uploadMultipartPart(mpu,
                        partNumber, payload);
                eTag = part.partETag();
            }
        }

        response.setCharacterEncoding(UTF_8);
        addCorsResponseHeader(request, response);
        try (Writer writer = response.getWriter()) {
            response.setContentType(XML_CONTENT_TYPE);
            XMLStreamWriter xml = xmlOutputFactory.createXMLStreamWriter(
                    writer);
            xml.writeStartDocument();
            xml.writeStartElement("CopyObjectResult");
            xml.writeDefaultNamespace(AWS_XMLNS);

            if (lastModified != null) {
                writeSimpleElement(xml, "LastModified",
                        formatDate(lastModified));
            }
            if (eTag != null) {
                writeSimpleElement(xml, "ETag", maybeQuoteETag(eTag));
            }

            xml.writeEndElement();
            xml.flush();
        } catch (XMLStreamException xse) {
            throw new IOException(xse);
        }
    }
Map XML ACLs to a canned policy if an exact tranformation exists.
src/main/java/org/gaul/s3proxy/S3ProxyHandler.java