From 49c816193e89d0ec5381965bbcc2f7c53ee29d76 Mon Sep 17 00:00:00 2001 From: Andrew Gaul Date: Fri, 31 Jul 2026 11:46:01 -0700 Subject: [PATCH] fix(storage): forward predefinedAcl on compose over JSON Storage.compose accepts BlobTargetOption.predefinedAcl and carries it down as StorageRpc.Option.PREDEFINED_ACL, but HttpStorageRpc.compose forwarded only the generation, metageneration and userProject options. The caller was given no error: objects.compose ran without destinationPredefinedAcl and the composed object kept the ACL it would have had anyway, so an object asked to be public came back private. The two transports disagreed on the same call. UnifiedOpts.PredefinedAcl already implements composeObject as setDestinationPredefinedAcl, so gRPC applied the option while JSON dropped it, and which one a caller got depended on how the client had been built. Objects.Compose has taken destinationPredefinedAcl all along; set it as rewrite already does a few lines below. --- .../cloud/storage/spi/v1/HttpStorageRpc.java | 1 + .../spi/v1/HttpStorageRpcComposeTest.java | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcComposeTest.java diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java index ec7d8e02e411..7dde88455e11 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java @@ -826,6 +826,7 @@ public StorageObject compose( storage .objects() .compose(target.getBucket(), target.getName(), request) + .setDestinationPredefinedAcl(Option.PREDEFINED_ACL.getString(targetOptions)) .setIfMetagenerationMatch(Option.IF_METAGENERATION_MATCH.getLong(targetOptions)) .setIfGenerationMatch(Option.IF_GENERATION_MATCH.getLong(targetOptions)) .setUserProject(Option.USER_PROJECT.getString(targetOptions)); diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcComposeTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcComposeTest.java new file mode 100644 index 000000000000..e39dd7a87baf --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/spi/v1/HttpStorageRpcComposeTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.storage.spi.v1; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.api.services.storage.model.StorageObject; +import com.google.cloud.NoCredentials; +import com.google.cloud.TransportOptions; +import com.google.cloud.Tuple; +import com.google.cloud.http.HttpTransportOptions; +import com.google.cloud.storage.StorageOptions; +import com.google.cloud.storage.spi.v1.StorageRpc.Option; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public final class HttpStorageRpcComposeTest { + + private static final String BUCKET = "test-bucket"; + + @Test + public void compose_forwardsPredefinedAcl() throws Exception { + String url = composeAndCaptureUrl(ImmutableMap.of(Option.PREDEFINED_ACL, "publicRead")); + + assertThat(url).contains("destinationPredefinedAcl=publicRead"); + } + + @Test + public void compose_withoutPredefinedAclSendsNone() throws Exception { + String url = composeAndCaptureUrl(ImmutableMap.of()); + + assertThat(url).doesNotContain("destinationPredefinedAcl"); + } + + private String composeAndCaptureUrl(Map targetOptions) throws Exception { + MockLowLevelHttpResponse response = + new MockLowLevelHttpResponse() + .setContentType("application/json") + .setContent("{\"bucket\":\"" + BUCKET + "\",\"name\":\"destination\"}") + .setStatusCode(200); + AuditingHttpTransport transport = new AuditingHttpTransport(response); + TransportOptions transportOptions = + HttpTransportOptions.newBuilder().setHttpTransportFactory(() -> transport).build(); + StorageOptions options = + StorageOptions.getDefaultInstance().toBuilder() + .setProjectId("test-project") + .setCredentials(NoCredentials.getInstance()) + .setTransportOptions(transportOptions) + .build(); + + StorageObject source = new StorageObject().setBucket(BUCKET).setName("source"); + StorageObject target = new StorageObject().setBucket(BUCKET).setName("destination"); + new HttpStorageRpc(options).compose(ImmutableList.of(source), target, targetOptions); + + List> calls = transport.getBuildRequestCalls(); + assertThat(calls).hasSize(1); + return calls.get(0).y(); + } +}