Repository: googleapis/java-storage
Pull request #1439 (2022-06-07 19:30:39)

feat: TransformPageDecorator 
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/java-storage/issues/new/choose) before writing your code!  That way we can discuss the change, evaluate designs, and agree on the general idea
- [ ] Ensure the tests and linter pass
- [ ] Code coverage does not decrease (if any source code was changed)
- [ ] Appropriate docs were updated (if necessary)

Fixes #<issue_number_goes_here> ☕️

If you write sample code, please follow the [samples format](
https://github.com/GoogleCloudPlatform/java-docs-samples/blob/main/SAMPLE_FORMAT.md).



Patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultBufferedReadableByteChannel.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultBufferedReadableByteChannel.java
index ac0e9ea6..1edf0db0 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultBufferedReadableByteChannel.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultBufferedReadableByteChannel.java
@@ -28,6 +28,9 @@ final class DefaultBufferedReadableByteChannel implements BufferedReadableByteCh
 
   private final UnbufferedReadableByteChannel channel;
 
+  private boolean flipped = false;
+  private boolean retEOF = false;
+
   DefaultBufferedReadableByteChannel(ByteBuffer buffer, UnbufferedReadableByteChannel channel) {
     this.buffer = buffer;
     this.channel = channel;
@@ -38,11 +41,12 @@ final class DefaultBufferedReadableByteChannel implements BufferedReadableByteCh
     return channel.isComplete();
   }
 
-  long totalRead = 0;
-
   @Override
   public int read(ByteBuffer dst) throws IOException {
-    if (!channel.isOpen()) {
+    if (retEOF) {
+      retEOF = false;
+      return -1;
+    } else if (!channel.isOpen()) {
       throw new ClosedChannelException();
     }
 
@@ -56,35 +60,40 @@ final class DefaultBufferedReadableByteChannel implements BufferedReadableByteCh
 
       final int tmpBytesCopied;
       if (enqueuedBytes()) {
+        if (!flipped) {
+          buffer.flip();
+          flipped = true;
+        }
         long copy = Buffers.copy(buffer, new ByteBuffer[] {dst});
         if (buffer.remaining() == 0) {
           Buffers.clear(buffer);
         }
         tmpBytesCopied = Math.toIntExact(copy);
       } else {
-        if (bufferRemaining < dstRemaining) {
-          // no enqueued data and the available space in dst is larger than our buffer
-          // rather than copying into the buffer before copying to dst, simply read a buffer size
-          // worth of bytes directly into dst
-          ByteBuffer slice = dst.slice();
-          int sliceLimit = dstPosition + bufferRemaining;
-          Buffers.limit(slice, sliceLimit);
-          int read = channel.read(slice);
-          if (read == -1) {
-            close();
-            return -1;
+        if (bufferRemaining <= dstRemaining) {
+          ByteBuffer buf;
+          if (bufferRemaining == dstRemaining) {
+            // the available space in dst is the same as our buffer rather than reading into buffer
+            // before copying to dst, simply read directly into dst
+            buf = dst;
+          } else {
+            // the available space in dst is larger than our buffer rather than reading into buffer
+            // before copying to dst, simply read a buffer size worth of bytes directly into dst
+            buf = dst.slice();
+            Buffers.limit(buf, bufferRemaining);
           }
-          Buffers.position(dst, dstPosition + read);
-          tmpBytesCopied = read;
-        } else if (bufferRemaining == dstRemaining) {
-          // no enqueued data and the available space in dst is the same as our buffer
-          // rather than copying into the buffer before copying to dst, simply read directly into
-          // dst
-          int read = channel.read(dst);
+          int read = channel.read(buf);
           if (read == -1) {
-            close();
-            return -1;
+            if (bytesConsumed == 0) {
+              close();
+              return -1;
+            } else {
+              retEOF = true;
+              close();
+              break;
+            }
           }
+          Buffers.position(dst, dstPosition + read);
           tmpBytesCopied = read;
         } else {
 
@@ -99,8 +108,14 @@ final class DefaultBufferedReadableByteChannel implements BufferedReadableByteCh
           ByteBuffer[] dsts = {dst, slice};
           long read = channel.read(dsts);
           if (read == -1) {
-            close();
-            return -1;
+            if (bytesConsumed == 0) {
+              close();
+              return -1;
+            } else {
+              retEOF = true;
+              close();
+              break;
+            }
           } else if (read < dstRemaining) {
             // we didn't read enough bytes to fill up dst, no need to advance buffer position
             tmpBytesCopied = Math.toIntExact(read);
@@ -109,13 +124,13 @@ final class DefaultBufferedReadableByteChannel implements BufferedReadableByteCh
             // determine the position buffer needs to be set to
             long bytesReadIntoBuffer = read - dstRemaining;
             Buffers.position(buffer, Math.toIntExact(bytesReadIntoBuffer));
-            tmpBytesCopied = Math.toIntExact(read);
+            flipped = false;
+            tmpBytesCopied = dstRemaining;
           }
         }
       }
       bytesConsumed += tmpBytesCopied;
     }
-    totalRead += bytesConsumed;
     return bytesConsumed;
   }
 
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java
index 1bdacd01..989a7963 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcConversions.java
@@ -25,9 +25,12 @@ import static com.google.cloud.storage.Utils.todo;
 import com.google.cloud.storage.BlobInfo.CustomerEncryption;
 import com.google.cloud.storage.Conversions.Codec;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.io.BaseEncoding;
 import com.google.common.primitives.Ints;
 import com.google.protobuf.ByteString;
+import com.google.protobuf.Descriptors;
 import com.google.protobuf.Timestamp;
 import com.google.storage.v2.Bucket;
 import com.google.storage.v2.Bucket.Billing;
@@ -35,28 +38,32 @@ import com.google.storage.v2.HmacKeyMetadata;
 import com.google.storage.v2.Object;
 import com.google.storage.v2.ObjectAccessControl;
 import com.google.storage.v2.ObjectChecksums;
+import com.google.storage.v2.Owner;
 import com.google.type.Date;
 import java.time.Instant;
 import java.time.LocalDate;
 import java.time.OffsetDateTime;
 import java.time.ZoneOffset;
-import java.util.logging.Level;
-import java.util.logging.LogRecord;
+import java.util.List;
 
 final class GrpcConversions {
   static final GrpcConversions INSTANCE = new GrpcConversions();
 
-  private final Codec<?, ?> entityCodec = Codec.of(Utils::todo, Utils::todo);
-  private final Codec<Acl, ObjectAccessControl> objectAclCodec = Codec.of(Utils::todo, Utils::todo);
+  private final Codec<Acl.Entity, String> entityCodec =
+      Codec.of(this::entityEncode, this::entityDecode);
+  private final Codec<Acl, ObjectAccessControl> objectAclCodec =
+      Codec.of(this::objectAclEncode, this::objectAclDecode);
   private final Codec<?, ?> bucketAclCodec = Codec.of(Utils::todo, Utils::todo);
   private final Codec<HmacKey.HmacKeyMetadata, HmacKeyMetadata> hmacKeyMetadataCodec =
       Codec.of(this::hmacKeyMetadataEncode, this::hmacKeyMetadataDecode);
   private final Codec<?, ?> hmacKeyCodec = Codec.of(Utils::todo, Utils::todo);
   private final Codec<ServiceAccount, com.google.storage.v2.ServiceAccount> serviceAccountCodec =
       Codec.of(this::serviceAccountEncode, this::serviceAccountDecode);
-  private final Codec<?, ?> corsCodec = Codec.of(Utils::todo, Utils::todo);
-  private final Codec<?, ?> loggingCodec = Codec.of(Utils::todo, Utils::todo);
-  private final Codec<?, ?> iamConfigurationCodec = Codec.of(Utils::todo, Utils::todo);
+  private final Codec<Cors, Bucket.Cors> corsCodec = Codec.of(this::corsEncode, this::corsDecode);
+  private final Codec<BucketInfo.Logging, Bucket.Logging> loggingCodec =
+      Codec.of(this::loggingEncode, this::loggingDecode);
+  private final Codec<BucketInfo.IamConfiguration, Bucket.IamConfig> iamConfigurationCodec =
+      Codec.of(this::iamConfigEncode, this::iamConfigDecode);
   private final Codec<BucketInfo.LifecycleRule, Bucket.Lifecycle.Rule> lifecycleRuleCodec =
       Codec.of(this::lifecycleRuleEncode, this::lifecycleRuleDecode);
   private final Codec<BucketInfo.DeleteRule, Bucket.Lifecycle.Rule> deleteRuleCodec =
@@ -105,8 +112,8 @@ final class GrpcConversions {
 
   private GrpcConversions() {}
 
-  Codec<?, ?> entity() {
-    return todo();
+  Codec<Acl.Entity, String> entity() {
+    return entityCodec;
   }
 
   Codec<Acl, ObjectAccessControl> objectAcl() {
@@ -129,16 +136,16 @@ final class GrpcConversions {
     return serviceAccountCodec;
   }
 
-  Codec<?, ?> cors() {
-    return todo();
+  Codec<Cors, Bucket.Cors> cors() {
+    return corsCodec;
   }
 
-  Codec<?, ?> logging() {
-    return todo();
+  Codec<BucketInfo.Logging, Bucket.Logging> logging() {
+    return loggingCodec;
   }
 
-  Codec<?, ?> iamConfiguration() {
-    return todo();
+  Codec<BucketInfo.IamConfiguration, Bucket.IamConfig> iamConfiguration() {
+    return iamConfigurationCodec;
   }
 
   Codec<BucketInfo.LifecycleRule, Bucket.Lifecycle.Rule> lifecycleRule() {
@@ -206,18 +213,20 @@ final class GrpcConversions {
         lift(Bucket.Lifecycle::getRuleList).andThen(toImmutableListOf(lifecycleRule()::decode)),
         to::setLifecycleRules);
     // preserve mapping to deprecated property
+    if (from.hasLifecycle()) {
+      to.setDeleteRules(
+          from.getLifecycle().getRuleList().stream()
+              .filter(this::isValidDeleteRule)
+              .map(deleteRule()::decode)
+              .collect(ImmutableList.toImmutableList()));
+    }
+    ifNonNull(from.getCorsList(), toImmutableListOf(cors()::decode), to::setCors);
+    ifNonNull(from.getLogging(), loggingCodec::decode, to::setLogging);
+    ifNonNull(from.getOwner(), lift(Owner::getEntity).andThen(this::entityDecode), to::setOwner);
     ifNonNull(
-        from.getLifecycle(),
-        lift(Bucket.Lifecycle::getRuleList).andThen(toImmutableListOf(deleteRule()::decode)),
-        to::setDeleteRules);
-    // TODO(frankyn): Add logging decoder
-    // TODO(frankyn): Add entity decoder
-    // TODO(frankyn): Add Cors decoder support
-    // TODO(frnakyn): Add DefaultObjectAcl decoder support
-    // TODO(frankyn): Add lifeycle decoder
-    // TODO(frankyn): Add deleteRules decoder
+        from.getDefaultObjectAclList(), toImmutableListOf(objectAcl()::decode), to::setDefaultAcl);
+    ifNonNull(from.getIamConfig(), iamConfiguration()::decode, to::setIamConfiguration);
     // TODO(frankyn): Add SelfLink when the field is available
-    // TODO(frankyn): Add decoder for iamConfig
     // TODO(frankyn): Add Etag when support is available
     return to.build();
   }
@@ -263,52 +272,179 @@ final class GrpcConversions {
     to.setVersioning(versioningBuilder.build());
     ifNonNull(from.getDefaultEventBasedHold(), to::setDefaultEventBasedHold);
     ifNonNull(from.getLabels(), to::putAllLabels);
-    // preserve mapping to deprecated property
-    Bucket.Lifecycle.Builder lifecycleBuilder = Bucket.Lifecycle.newBuilder();
+    to.setLifecycle(buildLifecyclePolicy(from));
+    ifNonNull(from.getLogging(), loggingCodec::encode, to::setLogging);
+    ifNonNull(from.getCors(), toImmutableListOf(cors()::encode), to::addAllCors);
     ifNonNull(
-        from.getLifecycleRules(),
-        Utils.toImmutableListOf(lifecycleRule()::encode),
-        lifecycleBuilder::addAllRule);
-    /*
-    // Corruption occurs by having deleteRule() values added
+        from.getOwner(),
+        lift(entity()::encode).andThen(o -> Owner.newBuilder().setEntity(o).build()),
+        to::setOwner);
     ifNonNull(
-       from.getDeleteRules(),
-       Utils.toImmutableListOf(deleteRule()::encode),
-       lifecycleBuilder::addAllRule);
-    */
-    to.setLifecycle(lifecycleBuilder.build());
-    // TODO(frankyn): Add logging decoder
-    // TODO(frankyn): Add entity decoder
-    // TODO(frankyn): Add Cors decoder support
-    // TODO(frnakyn): Add DefaultObjectAcl decoder support
-    // TODO(frankyn): Add lifeycle decoder
-    // TODO(frankyn): Add deleteRules decoder
+        from.getDefaultAcl(), toImmutableListOf(objectAcl()::encode), to::addAllDefaultObjectAcl);
+    ifNonNull(from.getIamConfiguration(), iamConfiguration()::encode, to::setIamConfig);
     // TODO(frankyn): Add SelfLink when the field is available
-    // TODO(frankyn): Add decoder for iamConfig
     // TODO(frankyn): Add Etag when support is avialable
     return to.build();
   }
 
+  private Bucket.Lifecycle buildLifecyclePolicy(BucketInfo from) {
+    // Handle duplicate rules introduced by deleteRules using a backing ImmutableSet
+    Bucket.Lifecycle.Builder lifecycleBuilder = Bucket.Lifecycle.newBuilder();
+    ImmutableSet.Builder<Bucket.Lifecycle.Rule> rules = new ImmutableSet.Builder<>();
+    if (from.getLifecycleRules() != null) {
+      rules.addAll(
+          from.getLifecycleRules().stream()
+              .map(lifecycleRule()::encode)
+              .collect(ImmutableSet.toImmutableSet()));
+    }
+    // preserve mapping to deprecated property
+    if (from.getDeleteRules() != null) {
+      rules.addAll(
+          from.getDeleteRules().stream()
+              .map(deleteRule()::encode)
+              .collect(ImmutableSet.toImmutableSet()));
+    }
+    return lifecycleBuilder.addAllRule(rules.build()).build();
+  }
+
+  private Bucket.Logging loggingEncode(BucketInfo.Logging from) {
+    Bucket.Logging.Builder to = Bucket.Logging.newBuilder();
+    if (from.getLogBucket() != null || from.getLogObjectPrefix() != null) {
+      to.setLogBucket(from.getLogBucket());
+      to.setLogObjectPrefix(from.getLogObjectPrefix());
+    }
+    return to.build();
+  }
+
+  private BucketInfo.Logging loggingDecode(Bucket.Logging from) {
+    return BucketInfo.Logging.newBuilder()
+        .setLogBucket(from.getLogBucket())
+        .setLogObjectPrefix(from.getLogObjectPrefix())
+        .build();
+  }
+
+  private Bucket.Cors corsEncode(Cors from) {
+    Bucket.Cors.Builder to = Bucket.Cors.newBuilder();
+    to.setMaxAgeSeconds(from.getMaxAgeSeconds());
+    to.addAllResponseHeader(from.getResponseHeaders());
+    ifNonNull(from.getMethods(), toImmutableListOf(java.lang.Object::toString), to::addAllMethod);
+    ifNonNull(from.getOrigins(), toImmutableListOf(java.lang.Object::toString), to::addAllOrigin);
+    return to.build();
+  }
+
+  private Cors corsDecode(Bucket.Cors from) {
+    Cors.Builder to = Cors.newBuilder().setMaxAgeSeconds(from.getMaxAgeSeconds());
+    ifNonNull(
+        from.getMethodList(),
+        m ->
+            m.stream()
+                .map(String::toUpperCase)
+                .map(HttpMethod::valueOf)
+                .collect(ImmutableList.toImmutableList()),
+        to::setMethods);
+    ifNonNull(from.getOriginList(), toImmutableListOf(Cors.Origin::of), to::setOrigins);
+    to.setResponseHeaders(from.getResponseHeaderList());
+    return to.build();
+  }
+
+  private String entityEncode(Acl.Entity from) {
+    if (from instanceof Acl.RawEntity) {
+      return from.getValue();
+    } else if (from instanceof Acl.User) {
+      switch (from.getValue()) {
+        case Acl.User.ALL_AUTHENTICATED_USERS:
+          return Acl.User.ALL_AUTHENTICATED_USERS;
+        case Acl.User.ALL_USERS:
+          return Acl.User.ALL_USERS;
+        default:
+          break;
+      }
+    }
+    // intentionally not an else so that if the default is hit above it will fall through to here
+    return from.getType().name().toLowerCase() + "-" + from.getValue();
+  }
+
+  private Acl.Entity entityDecode(String from) {
+    if (from.startsWith("user-")) {
+      return new Acl.User(from.substring(5));
+    }
+    if (from.equals(Acl.User.ALL_USERS)) {
+      return Acl.User.ofAllUsers();
+    }
+    if (from.equals(Acl.User.ALL_AUTHENTICATED_USERS)) {
+      return Acl.User.ofAllAuthenticatedUsers();
+    }
+    if (from.startsWith("group-")) {
+      return new Acl.Group(from.substring(6));
+    }
+    if (from.startsWith("domain-")) {
+      return new Acl.Domain(from.substring(7));
+    }
+    if (from.startsWith("project-")) {
+      int idx = from.indexOf('-', 8);
+      String team = from.substring(8, idx);
+      String projectId = from.substring(idx + 1);
+      return new Acl.Project(Acl.Project.ProjectRole.valueOf(team.toUpperCase()), projectId);
+    }
+    return new Acl.RawEntity(from);
+  }
+
+  private Acl objectAclDecode(ObjectAccessControl from) {
+    Acl.Role role = Acl.Role.valueOf(from.getRole());
+    Acl.Entity entity = entityDecode(from.getEntity());
+    // TODO: Add etag when it becomes available
+    return Acl.newBuilder(entity, role).setId(from.getId()).build();
+  }
+
+  private ObjectAccessControl objectAclEncode(Acl from) {
+    // TODO: Add etag when it becomes available
+    return ObjectAccessControl.newBuilder()
+        .setEntity(entityEncode(from.getEntity()))
+        .setRole(from.getRole().name())
+        .setId(from.getId())
+        .build();
+  }
+
+  private Bucket.IamConfig.UniformBucketLevelAccess ublaEncode(BucketInfo.IamConfiguration from) {
+    Bucket.IamConfig.UniformBucketLevelAccess.Builder to =
+        Bucket.IamConfig.UniformBucketLevelAccess.newBuilder();
+    to.setEnabled(from.isUniformBucketLevelAccessEnabled());
+    if (from.isUniformBucketLevelAccessEnabled() == Boolean.TRUE) {
+      ifNonNull(
+          from.getUniformBucketLevelAccessLockedTimeOffsetDateTime(),
+          timestampCodec::encode,
+          to::setLockTime);
+    }
+    return to.build();
+  }
+
+  private Bucket.IamConfig iamConfigEncode(BucketInfo.IamConfiguration from) {
+    Bucket.IamConfig.Builder to = Bucket.IamConfig.newBuilder();
+    to.setUniformBucketLevelAccess(ublaEncode(from));
+    if (from.getPublicAccessPrevention() != null) {
+      ifNonNull(from.getPublicAccessPrevention().getValue(), to::setPublicAccessPrevention);
+    }
+    return to.build();
+  }
+
+  private BucketInfo.IamConfiguration iamConfigDecode(Bucket.IamConfig from) {
+    Bucket.IamConfig.UniformBucketLevelAccess ubla = from.getUniformBucketLevelAccess();
+
+    BucketInfo.IamConfiguration.Builder to = BucketInfo.IamConfiguration.newBuilder();
+    ifNonNull(ubla.getEnabled(), to::setIsUniformBucketLevelAccessEnabled);
+    ifNonNull(
+        ubla.getLockTime(),
+        timestampCodec::decode,
+        to::setUniformBucketLevelAccessLockedTimeOffsetDateTime);
+    ifNonNull(
+        from.getPublicAccessPrevention(),
+        BucketInfo.PublicAccessPrevention::parse,
+        to::setPublicAccessPrevention);
+    return to.build();
+  }
+
   @SuppressWarnings("deprecation")
   private Bucket.Lifecycle.Rule deleteRuleEncode(BucketInfo.DeleteRule from) {
-    if (from instanceof BucketInfo.RawDeleteRule) {
-      Bucket.Lifecycle.Rule rule =
-          Bucket.Lifecycle.Rule.newBuilder()
-              .setAction(Bucket.Lifecycle.Rule.Action.newBuilder().setType(from.getType().name()))
-              .build();
-      String msg =
-          "The lifecycle condition "
-              + resolveRuleActionType(rule)
-              + " is not currently supported. Please update to the latest version of google-cloud-java."
-              + " Also, use LifecycleRule rather than the deprecated DeleteRule.";
-      // manually construct a log record, so we maintain class name and method name
-      // from the old implicit values.
-      LogRecord record = new LogRecord(Level.WARNING, msg);
-      record.setSourceClassName(BucketInfo.RawDeleteRule.class.getName());
-      record.setSourceMethodName("populateCondition");
-      BucketInfo.log.log(record);
-      return rule;
-    }
     Bucket.Lifecycle.Rule.Builder to = Bucket.Lifecycle.Rule.newBuilder();
     to.setAction(
         Bucket.Lifecycle.Rule.Action.newBuilder().setType(BucketInfo.DeleteRule.SUPPORTED_ACTION));
@@ -338,42 +474,42 @@ final class GrpcConversions {
     return to.build();
   }
 
-  private String resolveRuleActionType(Bucket.Lifecycle.Rule rule) {
-    if (rule != null && rule.getAction() != null) {
-      return rule.getAction().getType();
-    } else {
-      return null;
-    }
+  static final List<Descriptors.FieldDescriptor> SUPPORTED_CONDITIONS_DELETE_RULE =
+      ImmutableList.of(
+          Bucket.Lifecycle.Rule.Condition.getDescriptor()
+              .findFieldByNumber(Bucket.Lifecycle.Rule.Condition.AGE_DAYS_FIELD_NUMBER),
+          Bucket.Lifecycle.Rule.Condition.getDescriptor()
+              .findFieldByNumber(Bucket.Lifecycle.Rule.Condition.CREATED_BEFORE_FIELD_NUMBER),
+          Bucket.Lifecycle.Rule.Condition.getDescriptor()
+              .findFieldByNumber(Bucket.Lifecycle.Rule.Condition.NUM_NEWER_VERSIONS_FIELD_NUMBER),
+          Bucket.Lifecycle.Rule.Condition.getDescriptor()
+              .findFieldByNumber(Bucket.Lifecycle.Rule.Condition.IS_LIVE_FIELD_NUMBER));
+
+  private boolean isValidDeleteRule(Bucket.Lifecycle.Rule rule) {
+    return rule.hasAction()
+        && rule.getAction().getType().equals(BucketInfo.LifecycleRule.DeleteLifecycleAction.TYPE)
+        && rule.getCondition().getAllFields().keySet().size() == 1
+        && !rule.getCondition().getAllFields().keySet().stream()
+            .noneMatch(SUPPORTED_CONDITIONS_DELETE_RULE::contains);
   }
 
   @SuppressWarnings("deprecation")
   private BucketInfo.DeleteRule deleteRuleDecode(Bucket.Lifecycle.Rule from) {
-    if (from.getAction() != null
-        && BucketInfo.DeleteRule.SUPPORTED_ACTION.endsWith(resolveRuleActionType(from))) {
-      Bucket.Lifecycle.Rule.Condition condition = from.getCondition();
-      Integer age = condition.getAgeDays();
-      if (age != null) {
-        return new BucketInfo.AgeDeleteRule(age);
-      }
+    if (!isValidDeleteRule(from)) {
+      throw new IllegalArgumentException("Rule is not a valid DeleteRule" + from);
+    }
+    Bucket.Lifecycle.Rule.Condition condition = from.getCondition();
+    if (condition.hasAgeDays()) {
+      return new BucketInfo.AgeDeleteRule(condition.getAgeDays());
+    } else if (condition.hasCreatedBefore()) {
       Date date = condition.getCreatedBefore();
-      if (date != null) {
-        return new BucketInfo.CreatedBeforeDeleteRule(
-            OffsetDateTime.from(LocalDate.of(date.getYear(), date.getMonth(), date.getDay())));
-      }
-      Integer numNewerVersions = condition.getNumNewerVersions();
-      if (numNewerVersions != null) {
-        return new BucketInfo.NumNewerVersionsDeleteRule(numNewerVersions);
-      }
-      Boolean isLive = condition.getIsLive();
-      if (isLive != null) {
-        return new BucketInfo.IsLiveDeleteRule(isLive);
-      }
+      return new BucketInfo.CreatedBeforeDeleteRule(
+          OffsetDateTime.from(LocalDate.of(date.getYear(), date.getMonth(), date.getDay())));
+    } else if (condition.hasNumNewerVersions()) {
+      return new BucketInfo.NumNewerVersionsDeleteRule(condition.getNumNewerVersions());
+    } else {
+      return new BucketInfo.IsLiveDeleteRule(condition.getIsLive());
     }
-    return new BucketInfo.RawDeleteRule(
-        new com.google.api.services.storage.model.Bucket.Lifecycle.Rule()
-            .setAction(
-                new com.google.api.services.storage.model.Bucket.Lifecycle.Rule.Action()
-                    .setType(resolveRuleActionType(from))));
   }
 
   private Bucket.Lifecycle.Rule lifecycleRuleEncode(BucketInfo.LifecycleRule from) {
@@ -385,13 +521,22 @@ final class GrpcConversions {
 
   private Bucket.Lifecycle.Rule.Condition ruleConditionEncode(
       BucketInfo.LifecycleRule.LifecycleCondition from) {
-    Bucket.Lifecycle.Rule.Condition.Builder to =
-        Bucket.Lifecycle.Rule.Condition.newBuilder()
-            .setAgeDays(from.getAge())
-            .setIsLive(from.getIsLive())
-            .setNumNewerVersions(from.getNumberOfNewerVersions())
-            .setDaysSinceNoncurrentTime(from.getDaysSinceNoncurrentTime())
-            .setDaysSinceCustomTime(from.getDaysSinceCustomTime());
+    Bucket.Lifecycle.Rule.Condition.Builder to = Bucket.Lifecycle.Rule.Condition.newBuilder();
+    if (from.getAge() != null) {
+      to.setAgeDays(from.getAge());
+    }
+    if (from.getIsLive() != null) {
+      to.setIsLive(from.getIsLive());
+    }
+    if (from.getNumberOfNewerVersions() != null) {
+      to.setNumNewerVersions(from.getNumberOfNewerVersions());
+    }
+    if (from.getDaysSinceNoncurrentTime() != null) {
+      to.setDaysSinceNoncurrentTime(from.getDaysSinceNoncurrentTime());
+    }
+    if (from.getDaysSinceCustomTime() != null) {
+      to.setDaysSinceCustomTime(from.getDaysSinceCustomTime());
+    }
     ifNonNull(from.getCreatedBeforeOffsetDateTime(), odtDateCodec::encode, to::setCreatedBefore);
     ifNonNull(
         from.getNoncurrentTimeBeforeOffsetDateTime(),
@@ -447,21 +592,38 @@ final class GrpcConversions {
     Bucket.Lifecycle.Rule.Condition condition = from.getCondition();
 
     BucketInfo.LifecycleRule.LifecycleCondition.Builder conditionBuilder =
-        BucketInfo.LifecycleRule.LifecycleCondition.newBuilder()
-            .setAge(condition.getAgeDays())
-            .setCreateBeforeOffsetDateTime(odtDateCodec.decode(condition.getCreatedBefore()))
-            .setIsLive(condition.getIsLive())
-            .setNumberOfNewerVersions(condition.getNumNewerVersions())
-            .setDaysSinceNoncurrentTime(condition.getDaysSinceNoncurrentTime())
-            .setNoncurrentTimeBeforeOffsetDateTime(
-                odtDateCodec.decode(condition.getNoncurrentTimeBefore()))
-            .setCustomTimeBeforeOffsetDateTime(odtDateCodec.decode(condition.getCustomTimeBefore()))
-            .setDaysSinceCustomTime(condition.getDaysSinceCustomTime());
+        BucketInfo.LifecycleRule.LifecycleCondition.newBuilder();
+    if (condition.hasAgeDays()) {
+      conditionBuilder.setAge(condition.getAgeDays());
+    }
+    if (condition.hasCreatedBefore()) {
+      conditionBuilder.setCreateBeforeOffsetDateTime(
+          odtDateCodec.nullable().decode(condition.getCreatedBefore()));
+    }
+    if (condition.hasIsLive()) {
+      conditionBuilder.setIsLive(condition.getIsLive());
+    }
+    if (condition.hasNumNewerVersions()) {
+      conditionBuilder.setNumberOfNewerVersions(condition.getNumNewerVersions());
+    }
+    if (condition.hasDaysSinceNoncurrentTime()) {
+      conditionBuilder.setDaysSinceNoncurrentTime(condition.getDaysSinceNoncurrentTime());
+    }
+    if (condition.hasNoncurrentTimeBefore()) {
+      conditionBuilder.setNoncurrentTimeBeforeOffsetDateTime(
+          odtDateCodec.decode(condition.getNoncurrentTimeBefore()));
+    }
+    if (condition.hasCustomTimeBefore()) {
+      conditionBuilder.setCustomTimeBeforeOffsetDateTime(
+          odtDateCodec.decode(condition.getCustomTimeBefore()));
+    }
+    if (condition.hasDaysSinceCustomTime()) {
+      conditionBuilder.setDaysSinceCustomTime(condition.getDaysSinceCustomTime());
+    }
     ifNonNull(
         condition.getMatchesStorageClassList(),
         toImmutableListOf(StorageClass::valueOf),
         conditionBuilder::setMatchesStorageClass);
-
     return new BucketInfo.LifecycleRule(lifecycleAction, conditionBuilder.build());
   }
 
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
index 3c4cf057..d7a041d1 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
@@ -23,6 +23,7 @@ import static java.util.Objects.requireNonNull;
 
 import com.google.api.core.ApiFuture;
 import com.google.api.core.ApiFutures;
+import com.google.api.gax.paging.AbstractPage;
 import com.google.api.gax.paging.Page;
 import com.google.api.gax.rpc.ApiExceptions;
 import com.google.api.gax.rpc.UnaryCallable;
@@ -44,8 +45,17 @@ import com.google.storage.v2.DeleteHmacKeyRequest;
 import com.google.storage.v2.GetBucketRequest;
 import com.google.storage.v2.GetObjectRequest;
 import com.google.storage.v2.GetServiceAccountRequest;
+import com.google.storage.v2.ListBucketsRequest;
+import com.google.storage.v2.ListHmacKeysRequest;
+import com.google.storage.v2.ListObjectsRequest;
 import com.google.storage.v2.Object;
 import com.google.storage.v2.StartResumableWriteRequest;
+import com.google.storage.v2.StorageClient.ListBucketsPage;
+import com.google.storage.v2.StorageClient.ListBucketsPagedResponse;
+import com.google.storage.v2.StorageClient.ListHmacKeysPage;
+import com.google.storage.v2.StorageClient.ListHmacKeysPagedResponse;
+import com.google.storage.v2.StorageClient.ListObjectsPage;
+import com.google.storage.v2.StorageClient.ListObjectsPagedResponse;
 import com.google.storage.v2.WriteObjectResponse;
 import com.google.storage.v2.WriteObjectSpec;
 import com.google.storage.v2.stub.GrpcStorageStub;
@@ -64,6 +74,7 @@ import java.nio.file.Files;
 import java.nio.file.OpenOption;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -245,12 +256,43 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public Page<Bucket> list(BucketListOption... options) {
-    return todo();
+    UnaryCallable<ListBucketsRequest, ListBucketsPagedResponse> listBucketsCallable =
+        grpcStorageStub.listBucketsPagedCallable();
+    final Map<StorageRpc.Option, ?> optionsMap = StorageImpl.optionMap(options);
+    String projectId = (String) optionsMap.get(StorageRpc.Option.PROJECT_ID);
+    if (projectId == null) {
+      projectId = this.getOptions().getProjectId();
+    }
+    ListBucketsRequest.Builder builder = ListBucketsRequest.newBuilder().setParent(projectId);
+    ifNonNull(
+        (Long) optionsMap.get(StorageRpc.Option.MAX_RESULTS), Long::intValue, builder::setPageSize);
+    ifNonNull((String) optionsMap.get(StorageRpc.Option.PAGE_TOKEN), builder::setPageToken);
+    ifNonNull((String) optionsMap.get(StorageRpc.Option.PREFIX), builder::setPrefix);
+    // TODO(sydmunro): StorageRpc.Option.Fields
+    // TODO(sydmunro): User Project
+    ListBucketsPagedResponse call = listBucketsCallable.call(builder.build());
+    ListBucketsPage page = call.getPage();
+    return new TransformingPageDecorator<>(page, syntaxDecoders.bucket);
   }
 
   @Override
   public Page<Blob> list(String bucket, BlobListOption... options) {
-    return todo();
+    UnaryCallable<ListObjectsRequest, ListObjectsPagedResponse> listObjectsCallable =
+        grpcStorageStub.listObjectsPagedCallable();
+    final Map<StorageRpc.Option, ?> optionsMap = StorageImpl.optionMap(options);
+    ListObjectsRequest.Builder builder = ListObjectsRequest.newBuilder().setParent(bucket);
+    ifNonNull(
+        (Long) optionsMap.get(StorageRpc.Option.MAX_RESULTS), Long::intValue, builder::setPageSize);
+    ifNonNull((String) optionsMap.get(StorageRpc.Option.PAGE_TOKEN), builder::setPageToken);
+    ifNonNull((String) optionsMap.get(StorageRpc.Option.PREFIX), builder::setPrefix);
+    ifNonNull((String) optionsMap.get(StorageRpc.Option.DELIMITER), builder::setDelimiter);
+    ifNonNull(
+        (String) optionsMap.get(StorageRpc.Option.START_OFF_SET), builder::setLexicographicStart);
+    ifNonNull((String) optionsMap.get(StorageRpc.Option.END_OFF_SET), builder::setLexicographicEnd);
+    // TODO(sydmunro) StorageRpc.Option.Fields
+    ListObjectsPagedResponse call = listObjectsCallable.call(builder.build());
+    ListObjectsPage page = call.getPage();
+    return new TransformingPageDecorator<>(page, syntaxDecoders.blob);
   }
 
   @Override
@@ -567,7 +609,25 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public Page<HmacKeyMetadata> listHmacKeys(ListHmacKeysOption... options) {
-    return todo();
+    UnaryCallable<ListHmacKeysRequest, ListHmacKeysPagedResponse> listHmacKeysCallable =
+        grpcStorageStub.listHmacKeysPagedCallable();
+    final Map<StorageRpc.Option, ?> optionsMap = StorageImpl.optionMap(options);
+    String projectId = (String) optionsMap.get(StorageRpc.Option.PROJECT_ID);
+    if (projectId == null) {
+      projectId = this.getOptions().getProjectId();
+    }
+    ListHmacKeysRequest.Builder builder = ListHmacKeysRequest.newBuilder().setProject(projectId);
+    ifNonNull(
+        (String) optionsMap.get(StorageRpc.Option.SERVICE_ACCOUNT_EMAIL),
+        builder::setServiceAccountEmail);
+    ifNonNull(
+        (Long) optionsMap.get(StorageRpc.Option.MAX_RESULTS), Long::intValue, builder::setPageSize);
+    ifNonNull((String) optionsMap.get(StorageRpc.Option.PAGE_TOKEN), builder::setPageToken);
+    ifNonNull(
+        (Boolean) optionsMap.get(StorageRpc.Option.SHOW_DELETED_KEYS), builder::setShowDeletedKeys);
+    ListHmacKeysPagedResponse call = listHmacKeysCallable.call(builder.build());
+    ListHmacKeysPage page = call.getPage();
+    return new TransformingPageDecorator<>(page, codecs.hmacKeyMetadata());
   }
 
   @Override
@@ -682,9 +742,78 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   /** Bind some decoders for our "Syntax" classes to this instance of GrpcStorageImpl */
   private final class SyntaxDecoders {
+
     final Decoder<Object, Blob> blob =
         o -> codecs.blobInfo().decode(o).asBlob(GrpcStorageImpl.this);
     final Decoder<com.google.storage.v2.Bucket, Bucket> bucket =
         b -> codecs.bucketInfo().decode(b).asBucket(GrpcStorageImpl.this);
   }
+
+  static final class TransformingPageDecorator<
+          RequestT,
+          ResponseT,
+          ResourceT,
+          PageT extends AbstractPage<RequestT, ResponseT, ResourceT, PageT>,
+          ModelT>
+      implements Page<ModelT> {
+
+    private final PageT page;
+    private final Decoder<ResourceT, ModelT> translator;
+
+    public TransformingPageDecorator(PageT page, Decoder<ResourceT, ModelT> translator) {
+      this.page = page;
+      this.translator = translator;
+    }
+
+    @Override
+    public boolean hasNextPage() {
+      return page.hasNextPage();
+    }
+
+    @Override
+    public String getNextPageToken() {
+      return page.getNextPageToken();
+    }
+
+    @Override
+    public Page<ModelT> getNextPage() {
+      return new TransformingPageDecorator<>(page.getNextPage(), translator);
+    }
+
+    @Override
+    public Iterable<ModelT> iterateAll() {
+      return () -> {
+        final Iterator<ResourceT> iter = page.iterateAll().iterator();
+        return new TransformingIterator(iter);
+      };
+    }
+
+    @Override
+    public Iterable<ModelT> getValues() {
+      return () -> {
+        final Iterator<ResourceT> inter = page.getValues().iterator();
+        return new TransformingIterator(inter);
+      };
+    }
+
+    private class TransformingIterator implements Iterator<ModelT> {
+
+      private final Iterator<ResourceT> iter;
+
+      public TransformingIterator(Iterator<ResourceT> iter) {
+        this.iter = iter;
+      }
+
+      @Override
+      public boolean hasNext() {
+        return iter.hasNext();
+      }
+
+      @Override
+      public ModelT next() {
+        ResourceT next = iter.next();
+        return translator.decode(next);
+      }
+    }
+  }
 }


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/Crc32cUtilityPropertyTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/Crc32cUtilityPropertyTest.java
new file mode 100644
index 00000000..c5c86230
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/Crc32cUtilityPropertyTest.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2022 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.hash.Hashing;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+
+public class Crc32cUtilityPropertyTest {
+  @Property
+  public void testCrc32cCombinePropertyTest(
+      @ForAll byte[] firstObject, @ForAll byte[] secondObject) {
+    int firstPartHash = Hashing.crc32c().hashBytes(firstObject).asInt();
+    int secondPartHash = Hashing.crc32c().hashBytes(secondObject).asInt();
+    int expected =
+        Hashing.crc32c().newHasher().putBytes(firstObject).putBytes(secondObject).hash().asInt();
+    int actual =
+        Crc32cUtility.crc32cCombineGoogle(firstPartHash, secondPartHash, secondObject.length);
+    assertThat(actual).isEqualTo(expected);
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultBufferedReadableByteChannelTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultBufferedReadableByteChannelTest.java
new file mode 100644
index 00000000..f6c29d5f
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultBufferedReadableByteChannelTest.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2022 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;
+
+import static com.google.cloud.storage.ChunkSegmenterTest.TestData.fmt;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertThrows;
+
+import com.google.cloud.storage.BufferedReadableByteChannelSession.BufferedReadableByteChannel;
+import com.google.cloud.storage.UnbufferedReadableByteChannelSession.UnbufferedReadableByteChannel;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.ClosedChannelException;
+import java.nio.channels.ReadableByteChannel;
+import java.util.stream.IntStream;
+import net.jqwik.api.Arbitraries;
+import net.jqwik.api.Arbitrary;
+import net.jqwik.api.Combinators;
+import net.jqwik.api.Example;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+import net.jqwik.api.Provide;
+import net.jqwik.api.providers.TypeUsage;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public final class DefaultBufferedReadableByteChannelTest {
+
+  @Example
+  void edgeCases() {
+    JqwikTest.report(TypeUsage.of(ReadOps.class), arbitraryReadOps());
+  }
+
+  @Example
+  void _61bytes_should_read_16_16_16_13_whenRead16AtATime() throws IOException {
+    byte[] bytes = DataGenerator.base64Characters().genBytes(61);
+
+    ByteBuffer buf = ByteBuffer.allocate(16);
+    ByteBuffer buffer = ByteBuffer.allocate(3);
+    try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
+        UnbufferedReadableByteChannelAdapter adapter =
+            new UnbufferedReadableByteChannelAdapter(Channels.newChannel(bais));
+        BufferedReadableByteChannel c = new DefaultBufferedReadableByteChannel(buffer, adapter)) {
+      int read1 = c.read(buf);
+      assertThat(buffer.remaining()).isEqualTo(1);
+      assertThat(read1).isEqualTo(16);
+      buf.clear();
+      int read2 = c.read(buf);
+      assertThat(buffer.remaining()).isEqualTo(2);
+      assertThat(read2).isEqualTo(16);
+      buf.clear();
+      int read3 = c.read(buf);
+      assertThat(buffer.remaining()).isEqualTo(3);
+      assertThat(read3).isEqualTo(16);
+      buf.clear();
+      int read4 = c.read(buf);
+      assertThat(buffer.remaining()).isEqualTo(3);
+      assertThat(read4).isEqualTo(13);
+    }
+  }
+
+  @Property
+  void bufferingOnlyRequiresExpectedReads(@ForAll("ReadOps") ReadOps readOps) throws IOException {
+    System.out.println("readOps = " + readOps);
+    byte[] bytes = readOps.bytes;
+
+    ByteBuffer buf = ByteBuffer.allocate(readOps.readSize);
+    ByteBuffer buffer = ByteBuffer.allocate(readOps.bufferSize);
+    try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
+        UnbufferedReadableByteChannelAdapter adapter =
+            new UnbufferedReadableByteChannelAdapter(Channels.newChannel(bais));
+        BufferedReadableByteChannel c = new DefaultBufferedReadableByteChannel(buffer, adapter)) {
+      ImmutableList<Integer> expectedReadSizes = readOps.expectedReadSizes;
+      int maxReadIdx = expectedReadSizes.size() - 1;
+
+      int i = 0;
+      int read;
+      // repeatedly read from `c` until we read EOF
+      while ((read = c.read(buf)) != -1) {
+        // ensure the read that just succeeded matches the expected size
+        if (i <= maxReadIdx) {
+          int expectedReadSize = expectedReadSizes.get(i);
+          assertThat(read).isEqualTo(expectedReadSize);
+          buf.clear();
+        } else {
+          assertWithMessage("More reads than expected").that(i).isAtMost(maxReadIdx);
+        }
+        i++;
+      }
+      assertThrows(ClosedChannelException.class, () -> c.read(null));
+    }
+  }
+
+  @Provide("ReadOps")
+  static Arbitrary<ReadOps> arbitraryReadOps() {
+    return Combinators.combine(
+            Arbitraries.integers().between(1, 256 * 1024),
+            Arbitraries.integers().between(1, 16 * 1024),
+            Arbitraries.integers().between(1, 64 * 1024))
+        .as(ReadOps::of);
+  }
+
+  private static final class ReadOps {
+    private final byte[] bytes;
+    private final int bufferSize;
+    private final int readSize;
+
+    private final ImmutableList<Integer> expectedReadSizes;
+    private final String dbgExpectedReadSizes;
+
+    ReadOps(byte[] bytes, int bufferSize, int readSize) {
+      this.bytes = bytes;
+      this.bufferSize = bufferSize;
+      this.readSize = readSize;
+
+      int fullReadCount = bytes.length / readSize;
+      int remainingRead = (bytes.length - fullReadCount * readSize);
+      expectedReadSizes =
+          Streams.concat(
+                  IntStream.range(0, fullReadCount).map(i -> readSize),
+                  IntStream.of(remainingRead).filter(i -> i > 0))
+              .boxed()
+              .collect(ImmutableList.toImmutableList());
+
+      if (fullReadCount > 0 && remainingRead > 0) {
+        dbgExpectedReadSizes =
+            String.format("[%s * %d, %s]", readSize, fullReadCount, remainingRead);
+      } else if (remainingRead > 0) {
+        dbgExpectedReadSizes = String.format("[%s]", remainingRead);
+      } else {
+        dbgExpectedReadSizes = String.format("[%s * %d]", readSize, fullReadCount);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "ReadOps{"
+          + "bytes.length="
+          + fmt(bytes.length)
+          + ", bufferSize="
+          + fmt(bufferSize)
+          + ", readSize="
+          + fmt(readSize)
+          + ", expectedReadSizes="
+          + dbgExpectedReadSizes
+          + '}';
+    }
+
+    @NonNull
+    static ReadOps of(int byteSize, int bufferSize, int readSize) {
+      byte[] bytes = DataGenerator.base64Characters().genBytes(byteSize);
+      return new ReadOps(bytes, bufferSize, readSize);
+    }
+  }
+
+  /**
+   * Adapter to make any {@link ReadableByteChannel} into an {@link UnbufferedReadableByteChannel}
+   */
+  private static final class UnbufferedReadableByteChannelAdapter
+      implements UnbufferedReadableByteChannel {
+
+    private final ReadableByteChannel c;
+
+    private UnbufferedReadableByteChannelAdapter(ReadableByteChannel c) {
+      this.c = c;
+    }
+
+    @Override
+    public boolean isComplete() {
+      return c.isOpen();
+    }
+
+    @Override
+    public int read(ByteBuffer dst) throws IOException {
+      return Math.toIntExact(read(new ByteBuffer[] {dst}, 0, 1));
+    }
+
+    @Override
+    public long read(ByteBuffer[] dsts) throws IOException {
+      return read(dsts, 0, dsts.length);
+    }
+
+    @Override
+    public long read(ByteBuffer[] dsts, int offset, int length) throws IOException {
+      if (!c.isOpen()) {
+        return -1;
+      }
+
+      long totalBytesRead = 0;
+      for (int i = offset; i < length; i++) {
+        ByteBuffer dst = dsts[i];
+        if (dst.hasRemaining()) {
+          int read = c.read(dst);
+          if (read == -1) {
+            if (totalBytesRead == 0) {
+              c.close();
+              return -1;
+            } else {
+              break;
+            }
+          }
+          totalBytesRead += read;
+        }
+      }
+      return totalBytesRead;
+    }
+
+    @Override
+    public boolean isOpen() {
+      return c.isOpen();
+    }
+
+    @Override
+    public void close() throws IOException {
+      c.close();
+    }
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/GrpcTransformPageDecoratorTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/GrpcTransformPageDecoratorTest.java
new file mode 100644
index 00000000..56f50015
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/GrpcTransformPageDecoratorTest.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2022 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.api.gax.paging.AbstractPage;
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.PageContext;
+import com.google.api.gax.rpc.PagedListDescriptor;
+import com.google.api.gax.rpc.UnaryCallable;
+import com.google.cloud.storage.GrpcStorageImpl.TransformingPageDecorator;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class GrpcTransformPageDecoratorTest {
+
+  @Test
+  public void valueTranslationTest() {
+    // Initial values for the first page
+    List<String> initialValues = ImmutableList.of("string1", "string2", "string3");
+    // Request which will be appended to the second page
+    String request = "string4";
+
+    // Expected values after the translation
+    List<String> expectedValuesPageOne = ImmutableList.of("STRING1", "STRING2", "STRING3");
+    List<String> expectedValuesPageTwo = ImmutableList.of("STRING4");
+    List<String> expectedPagesValuesMerged =
+        Streams.concat(expectedValuesPageOne.stream(), expectedValuesPageTwo.stream())
+            .collect(ImmutableList.toImmutableList());
+
+    StringPagedListDescriptor descriptor = new StringPagedListDescriptor();
+    UnaryCallable<String, List<String>> callable = new StringPagedCallable();
+    PageContext<String, List<String>, String> context =
+        PageContext.create(callable, descriptor, request, Mockito.mock(ApiCallContext.class));
+    ListStringPage page = new ListStringPage(context, initialValues);
+    TransformingPageDecorator<String, List<String>, String, ListStringPage, String> decorator =
+        new TransformingPageDecorator<>(page, String::toUpperCase);
+
+    assertThat(ImmutableList.copyOf(decorator.getValues().iterator()))
+        .containsExactlyElementsIn(expectedValuesPageOne);
+    assertThat(ImmutableList.copyOf(decorator.iterateAll().iterator()))
+        .containsExactlyElementsIn(expectedPagesValuesMerged);
+  }
+
+  private static class ListStringPage
+      extends AbstractPage<String, List<String>, String, ListStringPage> {
+
+    public ListStringPage(
+        PageContext<String, List<String>, String> context, List<String> response) {
+      super(context, response);
+    }
+
+    @Override
+    public ListStringPage createPage(
+        PageContext<String, List<String>, String> context, List<String> response) {
+      return new ListStringPage(context, response);
+    }
+  }
+
+  private static class StringPagedListDescriptor
+      implements PagedListDescriptor<String, List<String>, String> {
+    @Override
+    public String emptyToken() {
+      return "";
+    }
+
+    @Override
+    public String injectToken(String payload, String token) {
+      return payload;
+    }
+
+    @Override
+    public String injectPageSize(String payload, int pageSize) {
+      return null;
+    }
+
+    @Override
+    public Integer extractPageSize(String payload) {
+      return null;
+    }
+
+    @Override
+    public String extractNextToken(List<String> payload) {
+      return payload.size() > 0 ? payload.get(0) : emptyToken();
+    }
+
+    @Override
+    public Iterable<String> extractResources(List<String> payload) {
+      return payload;
+    }
+  }
+
+  private static class StringPagedCallable extends UnaryCallable<String, List<String>> {
+    // We only want to add one additional page with the same value as the request,
+    // this is kind of hacky, but I wanted to validate we are performing iterate all
+    // properly.
+    private int numberOfPages = 1;
+
+    @Override
+    public ApiFuture<List<String>> futureCall(String request, ApiCallContext context) {
+      if (numberOfPages-- > 0) {
+        return ApiFutures.immediateFuture(ImmutableList.of(request));
+      }
+      return ApiFutures.immediateFuture(Collections.emptyList());
+    }
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
index a09fa567..d8e649de 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
@@ -55,9 +55,16 @@ public final class BucketArbitraryProvider implements ArbitraryProvider {
                         StorageArbitraries.buckets().encryption(),
                         StorageArbitraries.buckets().retentionPolicy(),
                         StorageArbitraries.buckets().lifecycle())
+                    .as(Tuple::of),
+                Combinators.combine(
+                        StorageArbitraries.buckets().logging(),
+                        StorageArbitraries.buckets().cors(),
+                        StorageArbitraries.buckets().objectAccessControl(),
+                        StorageArbitraries.buckets().owner(),
+                        StorageArbitraries.buckets().iamConfig())
                     .as(Tuple::of))
             .as(
-                (t1, t2) ->
+                (t1, t2, t3) ->
                     Bucket.newBuilder()
                         .setBucketId(t1.get1().get())
                         .setName(t1.get2().get())
@@ -75,6 +82,11 @@ public final class BucketArbitraryProvider implements ArbitraryProvider {
                         .setEncryption(t2.get6())
                         .setRetentionPolicy(t2.get7())
                         .setLifecycle(t2.get8())
+                        .setLogging(t3.get1())
+                        .addAllCors(t3.get2())
+                        .addAllDefaultObjectAcl(t3.get3())
+                        .setOwner(t3.get4())
+                        .setIamConfig(t3.get5())
                         .build());
     return Collections.singleton(as);
   }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
index 842cec59..4db0b213 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
@@ -27,9 +27,12 @@ import com.google.storage.v2.Bucket.RetentionPolicy;
 import com.google.storage.v2.Bucket.Versioning;
 import com.google.storage.v2.Bucket.Website;
 import com.google.storage.v2.CustomerEncryption;
+import com.google.storage.v2.ObjectAccessControl;
 import com.google.storage.v2.ObjectChecksums;
+import com.google.storage.v2.Owner;
 import com.google.type.Date;
 import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
 import java.time.ZoneOffset;
 import net.jqwik.api.Arbitraries;
 import net.jqwik.api.Arbitrary;
@@ -163,36 +166,42 @@ public final class StorageArbitraries {
       Arbitrary<Date> conditionCustomTime = date();
       ListArbitrary<String> storageClassMatches = storageClass().list().uniqueElements();
 
-      return Combinators.combine(
-              actions(),
-              Combinators.combine(
-                      conditionIsLive,
-                      conditionAgeDays,
-                      conditionNumberOfNewVersions,
-                      conditionCreatedBeforeTime,
-                      conditionDaysSinceNoncurrentTime,
-                      conditionNoncurrentTime,
-                      conditionDaysSinceCustomTime,
-                      conditionCustomTime)
-                  .as(Tuple::of),
-              storageClassMatches)
-          .as(
-              (a, ct, s) ->
-                  Bucket.Lifecycle.Rule.newBuilder()
-                      .setAction(a)
-                      .setCondition(
-                          Bucket.Lifecycle.Rule.Condition.newBuilder()
-                              .setIsLive(ct.get1())
-                              .setAgeDays(ct.get2())
-                              .setNumNewerVersions(ct.get3())
-                              .setCreatedBefore(ct.get4())
-                              .setDaysSinceNoncurrentTime(ct.get5())
-                              .setNoncurrentTimeBefore(ct.get6())
-                              .setDaysSinceCustomTime(ct.get7())
-                              .setCustomTimeBefore(ct.get8())
-                              .addAllMatchesStorageClass(s)
-                              .build())
-                      .build());
+      return Arbitraries.oneOf(
+          Arbitraries.of(
+              Bucket.Lifecycle.Rule.newBuilder()
+                  .setAction(Bucket.Lifecycle.Rule.Action.newBuilder().setType("Delete").build())
+                  .setCondition(Bucket.Lifecycle.Rule.Condition.newBuilder().setAgeDays(10).build())
+                  .build()),
+          Combinators.combine(
+                  actions(),
+                  Combinators.combine(
+                          conditionIsLive,
+                          conditionAgeDays,
+                          conditionNumberOfNewVersions,
+                          conditionCreatedBeforeTime,
+                          conditionDaysSinceNoncurrentTime,
+                          conditionNoncurrentTime,
+                          conditionDaysSinceCustomTime,
+                          conditionCustomTime)
+                      .as(Tuple::of),
+                  storageClassMatches)
+              .as(
+                  (a, ct, s) ->
+                      Bucket.Lifecycle.Rule.newBuilder()
+                          .setAction(a)
+                          .setCondition(
+                              Bucket.Lifecycle.Rule.Condition.newBuilder()
+                                  .setIsLive(ct.get1())
+                                  .setAgeDays(ct.get2())
+                                  .setNumNewerVersions(ct.get3())
+                                  .setCreatedBefore(ct.get4())
+                                  .setDaysSinceNoncurrentTime(ct.get5())
+                                  .setNoncurrentTimeBefore(ct.get6())
+                                  .setDaysSinceCustomTime(ct.get7())
+                                  .setCustomTimeBefore(ct.get8())
+                                  .addAllMatchesStorageClass(s)
+                                  .build())
+                          .build()));
     }
 
     public Arbitrary<Bucket.Lifecycle> lifecycle() {
@@ -200,6 +209,7 @@ public final class StorageArbitraries {
           .list()
           .ofMinSize(0)
           .ofMaxSize(100)
+          .uniqueElements()
           .map((r) -> Bucket.Lifecycle.newBuilder().addAllRule(r).build());
     }
 
@@ -210,10 +220,85 @@ public final class StorageArbitraries {
           .as((i, n) -> Website.newBuilder().setMainPageSuffix(i).setNotFoundPage(n).build());
     }
 
+    public Arbitrary<Bucket.Logging> logging() {
+      Arbitrary<BucketName> loggingBucketName = StorageArbitraries.bucketName();
+      Arbitrary<String> loggingPrefix = Arbitraries.strings().all().ofMinLength(1).ofMaxLength(10);
+      return Combinators.combine(loggingBucketName, loggingPrefix)
+          .as(
+              (b, p) ->
+                  Bucket.Logging.newBuilder().setLogBucket(b.get()).setLogObjectPrefix(p).build());
+    }
+
+    public ListArbitrary<Bucket.Cors> cors() {
+      Arbitrary<Integer> maxAgeSeconds =
+          Arbitraries.integers().between(0, OffsetDateTime.MAX.getSecond());
+      ListArbitrary<String> methods =
+          Arbitraries.of("GET", "DELETE", "UPDATE", "PATCH").list().uniqueElements();
+      ListArbitrary<String> responseHeaders =
+          Arbitraries.of("Content-Type", "Origin").list().uniqueElements();
+      ListArbitrary<String> origins = Arbitraries.of("*", "google.com").list().uniqueElements();
+      return Combinators.combine(methods, responseHeaders, origins, maxAgeSeconds)
+          .as(
+              (m, r, o, a) -> {
+                return Bucket.Cors.newBuilder()
+                    .addAllMethod(m)
+                    .addAllResponseHeader(r)
+                    .addAllOrigin(o)
+                    .setMaxAgeSeconds(a)
+                    .build();
+              })
+          .list()
+          .ofMinSize(0)
+          .ofMaxSize(10);
+    }
+
     public Arbitrary<Bucket.Billing> billing() {
       return bool().map(b -> Billing.newBuilder().setRequesterPays(b).build());
     }
 
+    public ListArbitrary<ObjectAccessControl> objectAccessControl() {
+      Arbitrary<String> entity = Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(1024);
+      Arbitrary<String> role = Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(1024);
+      return Combinators.combine(entity, role)
+          .as((e, r) -> ObjectAccessControl.newBuilder().setEntity(e).setRole(r).build())
+          .list();
+    }
+
+    public Arbitrary<Owner> owner() {
+      Arbitrary<String> entity = Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(1024);
+      return entity.map(e -> Owner.newBuilder().setEntity(e).build());
+    }
+
+    public Arbitrary<Bucket.IamConfig.UniformBucketLevelAccess> uniformBucketLevelAccess() {
+      return Combinators.combine(bool(), timestamp())
+          .as(
+              (e, l) -> {
+                Bucket.IamConfig.UniformBucketLevelAccess.Builder ublaBuilder =
+                    Bucket.IamConfig.UniformBucketLevelAccess.newBuilder();
+                ublaBuilder.setEnabled(e);
+                if (e) {
+                  ublaBuilder.setLockTime(l);
+                }
+                return ublaBuilder.build();
+              });
+    }
+
+    public Arbitrary<Bucket.IamConfig> iamConfig() {
+      Arbitrary<Bucket.IamConfig.UniformBucketLevelAccess> uniformBucketLevelAccess =
+          uniformBucketLevelAccess();
+      Arbitrary<String> pap = Arbitraries.of("enforced", "inherited");
+      return Combinators.combine(pap, uniformBucketLevelAccess())
+          .as(
+              (p, u) -> {
+                Bucket.IamConfig.Builder iamConfigBuilder = Bucket.IamConfig.newBuilder();
+                iamConfigBuilder.setUniformBucketLevelAccess(u);
+                if (u.getEnabled()) {
+                  iamConfigBuilder.setPublicAccessPrevention(p);
+                }
+                return iamConfigBuilder.build();
+              });
+    }
+
     public Arbitrary<Bucket.Encryption> encryption() {
       return Arbitraries.strings()
           .all()