Skip to content
Open
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
13 changes: 9 additions & 4 deletions docs/services/gcs.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ The embedded DNS server resolves `*.localhost.floci.io` to floci-gcp's container
**Cloud Storage gRPC v2:**

- Buckets: `CreateBucket`, `GetBucket`, `ListBuckets`, `UpdateBucket`, `DeleteBucket`
- Objects: `ComposeObject`, `GetObject`, `ListObjects`, `UpdateObject`, `DeleteObject`
- Objects: `ComposeObject`, `GetObject`, `ListObjects`, `UpdateObject`, `DeleteObject`;
object system metadata (`cacheControl`, `contentDisposition`, `contentEncoding`,
`contentLanguage`, `customTime`, `storageClass`) round-trips with the REST path
- Data path: `ReadObject`, `WriteObject`, `BidiWriteObject`
- Resumable writes: `StartResumableWrite`, `QueryWriteStatus`

Expand Down Expand Up @@ -323,10 +325,13 @@ handles, or redirection. Unsupported RPCs return gRPC `UNIMPLEMENTED`.
- `CopyObject`
- `MoveObject`
- `HeadObject`
- `PatchObject` (update metadata: `contentType`, `contentDisposition`, `contentEncoding`, `contentLanguage`, `customTime`, custom metadata)
- `PatchObject` (update metadata: `contentType`, `contentDisposition`, `contentEncoding`, `contentLanguage`, `cacheControl`, `customTime`, custom metadata)
- System metadata at upload time (`contentEncoding`, `contentDisposition`, `contentLanguage`,
`customTime`, `storageClass`) as query parameters or in the JSON metadata part of a
multipart/resumable upload
`cacheControl`, `customTime`, `storageClass`) in the JSON metadata part of a
multipart/resumable upload; on the upload URL only `contentEncoding` is honoured, as on GCS
- `customTime` follows the GCS rules: rendered in UTC, never removed once set (a `null`
patch or an unset gRPC `custom_time` under the mask is a no-op), and a decrease is
rejected with `400` / `INVALID_ARGUMENT`
- `ComposeObject` (concatenate 1 to 32 source objects; 0 or more than 32 is a 400)
- `RewriteObject` (multi-call when the source and destination span storage classes or bucket
locations: a `maxBytesRewrittenPerCall`, which must be a multiple of 1 MiB, below the object
Expand Down
93 changes: 93 additions & 0 deletions src/main/java/io/floci/gcp/services/gcs/GcsCustomTime.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package io.floci.gcp.services.gcs;

import com.google.protobuf.Timestamp;
import io.floci.gcp.core.common.GcpException;

import java.math.BigInteger;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.function.Function;

/**
* customTime rules shared by the JSON and gRPC paths. GCS stores the value as a protobuf
* Timestamp, renders it in UTC with 0, 3, 6 or 9 fraction digits, never removes it once set,
* and refuses to move it backwards.
*/
final class GcsCustomTime {

private static final String PARSE_ERROR = """
Parse Error: Invalid value for type.googleapis.com/google.protobuf.Timestamp field: \
'Field 'customTime', Illegal timestamp format; timestamps must end with 'Z' or have \
a valid timezone offset.'.""";

private static final DateTimeFormatter ERROR_SECONDS = DateTimeFormatter
.ofPattern("uuuu-MM-dd'T'HH:mm:ss").withZone(ZoneOffset.UTC);

// GCS holds the value as int64 nanoseconds. A seconds field it cannot multiply is
// "too large"; a Timestamp that breaks the protobuf rules (nanos outside 0..999999999,
// or before 0001-01-01) is an internal error; anything else saturates at the int64 limits.
private static final long MAX_SECONDS = Long.MAX_VALUE / 1_000_000_000L;
private static final long MIN_SECONDS = -62_135_596_800L;
private static final String TOO_LARGE = "Invalid timestamp - too large to convert to nanoseconds.";
private static final String INTERNAL = "We encountered an internal error. Please try again.";
private static final BigInteger NANOS_PER_SECOND = BigInteger.valueOf(1_000_000_000L);
private static final BigInteger INT64_MIN = BigInteger.valueOf(Long.MIN_VALUE);
private static final BigInteger INT64_MAX = BigInteger.valueOf(Long.MAX_VALUE);

private GcsCustomTime() {
}

static String normalize(String value) {
try {
return Instant.parse(value).toString();
} catch (DateTimeParseException e) {
throw GcpException.invalidArgument(PARSE_ERROR);
}
}

/** WriteObject reports a too-large custom_time as INVALID_ARGUMENT. */
static String fromWrite(Timestamp value) {
return fromProto(value, GcpException::invalidArgument);
}

/** UpdateObject reports the same failure as INTERNAL. */
static String fromUpdate(Timestamp value) {
return fromProto(value, GcpException::internal);
}

private static String fromProto(Timestamp value, Function<String, GcpException> tooLarge) {
if (value.getSeconds() > MAX_SECONDS) {
throw tooLarge.apply(TOO_LARGE);
}
if (value.getNanos() < 0 || value.getNanos() > 999_999_999 || value.getSeconds() < MIN_SECONDS) {
throw GcpException.internal(INTERNAL);
}
var nanos = BigInteger.valueOf(value.getSeconds()).multiply(NANOS_PER_SECOND)
.add(BigInteger.valueOf(value.getNanos()))
.max(INT64_MIN).min(INT64_MAX);
return Instant.EPOCH.plusNanos(nanos.longValueExact()).toString();
}

static void requireNotDecreased(String previous, String next) {
if (previous == null) {
return;
}
var before = Instant.parse(previous);
var after = Instant.parse(next);
if (after.isBefore(before)) {
throw GcpException.invalidArgument("Custom time cannot be decreased. Previously: "
+ errorFormat(before) + ". Attempting to set: " + errorFormat(after) + ".");
}
}

// The message renders the fraction without trailing zeros and with an explicit +00:00.
private static String errorFormat(Instant instant) {
var text = new StringBuilder(ERROR_SECONDS.format(instant));
if (instant.getNano() != 0) {
text.append('.').append(String.format("%09d", instant.getNano()).replaceFirst("0+$", ""));
}
return text.append("+00:00").toString();
}
}
14 changes: 13 additions & 1 deletion src/main/java/io/floci/gcp/services/gcs/GcsGrpcMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,14 @@ static com.google.storage.v2.Object toProto(GcsObjectMeta stored) {
.setContentDisposition(orEmpty(stored.getContentDisposition()))
.setContentEncoding(orEmpty(stored.getContentEncoding()))
.setContentLanguage(orEmpty(stored.getContentLanguage()))
.setCacheControl(orEmpty(stored.getCacheControl()))
.setTemporaryHold(Boolean.TRUE.equals(stored.getTemporaryHold()))
.setEventBasedHold(Boolean.TRUE.equals(stored.getEventBasedHold()));
timestamp(stored.getTimeCreated()).ifPresent(value::setCreateTime);
timestamp(stored.getUpdated()).ifPresent(value::setUpdateTime);
timestamp(stored.getTimeDeleted()).ifPresent(value::setDeleteTime);
timestamp(stored.getRetentionExpirationTime()).ifPresent(value::setRetentionExpireTime);
timestamp(stored.getCustomTime()).ifPresent(value::setCustomTime);
Comment thread
exoego marked this conversation as resolved.
if (stored.getMetadata() != null) {
value.putAllMetadata(stored.getMetadata());
}
Expand All @@ -156,6 +158,8 @@ static GcsObjectMeta fromProto(com.google.storage.v2.Object value) {
meta.setContentDisposition(blankToNull(value.getContentDisposition()));
meta.setContentEncoding(blankToNull(value.getContentEncoding()));
meta.setContentLanguage(blankToNull(value.getContentLanguage()));
meta.setCacheControl(blankToNull(value.getCacheControl()));
meta.setCustomTime(value.hasCustomTime() ? GcsCustomTime.fromWrite(value.getCustomTime()) : null);
meta.setTemporaryHold(value.getTemporaryHold());
meta.setEventBasedHold(value.hasEventBasedHold() ? value.getEventBasedHold() : null);
if (value.getMetadataCount() > 0) {
Expand All @@ -169,14 +173,22 @@ static Map<String, java.lang.Object> objectUpdateFields(com.google.storage.v2.Ob
Map<String, java.lang.Object> patch = new LinkedHashMap<>();
java.util.Set<String> selected = paths.contains("*")
? java.util.Set.of("content_type", "content_disposition", "content_encoding",
"content_language", "metadata", "temporary_hold", "event_based_hold")
"content_language", "cache_control", "custom_time", "metadata",
"temporary_hold", "event_based_hold")
: new java.util.LinkedHashSet<>(paths);
for (String path : selected) {
switch (path) {
case "content_type" -> patch.put("contentType", value.getContentType());
case "content_disposition" -> patch.put("contentDisposition", value.getContentDisposition());
case "content_encoding" -> patch.put("contentEncoding", value.getContentEncoding());
case "content_language" -> patch.put("contentLanguage", value.getContentLanguage());
case "cache_control" -> patch.put("cacheControl", blankToNull(value.getCacheControl()));
// GCS never removes a custom time. An unset custom_time under the mask is a no-op.
case "custom_time" -> {
if (value.hasCustomTime()) {
patch.put("customTime", GcsCustomTime.fromUpdate(value.getCustomTime()));
}
}
case "metadata" -> patch.put("metadata", new LinkedHashMap<>(value.getMetadataMap()));
case "temporary_hold" -> patch.put("temporaryHold", value.getTemporaryHold());
case "event_based_hold" -> patch.put("eventBasedHold", value.getEventBasedHold());
Expand Down
10 changes: 8 additions & 2 deletions src/main/java/io/floci/gcp/services/gcs/GcsService.java
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,12 @@ private GcsObjectMeta patchObjectLocked(String bucket, String objectName, Map<St
GcsObjectMeta meta = getLiveObjectMeta(bucket, objectName)
.orElseThrow(() -> GcpException.notFound("Object not found: " + objectName));

// GCS never removes a custom time, so a null here leaves the field alone.
String customTime = null;
if (patch.get("customTime") instanceof String requested) {
customTime = GcsCustomTime.normalize(requested);
GcsCustomTime.requireNotDecreased(meta.getCustomTime(), customTime);
}
if (patch.containsKey("contentType")) {
meta.setContentType((String) patch.get("contentType"));
}
Expand All @@ -779,8 +785,8 @@ private GcsObjectMeta patchObjectLocked(String bucket, String objectName, Map<St
if (patch.containsKey("cacheControl")) {
meta.setCacheControl((String) patch.get("cacheControl"));
}
if (patch.containsKey("customTime")) {
meta.setCustomTime((String) patch.get("customTime"));
if (customTime != null) {
meta.setCustomTime(customTime);
}
meta.setUpdated(nowTimestamp());
long mg = Long.parseLong(meta.getMetageneration() != null ? meta.getMetageneration() : "1");
Expand Down
35 changes: 17 additions & 18 deletions src/main/java/io/floci/gcp/services/gcs/GcsUploadController.java
Original file line number Diff line number Diff line change
Expand Up @@ -135,37 +135,36 @@ private static Response.ResponseBuilder resumeIncomplete(HttpHeaders headers) {
return Response.status(308);
}

// System metadata a client can set at upload time. GCS accepts these both as
// query parameters on the upload URL and, for multipart/resumable, as fields
// of the JSON metadata part. They are carried as a template rather than
// patched afterwards so the object lands with them already set, without the
// spurious metageneration bump a follow-up patch would cause.
// System metadata a client can set in the JSON metadata part of a multipart or
// resumable upload. They are carried as a template rather than patched afterwards
// so the object lands with them already set, without the spurious metageneration
// bump a follow-up patch would cause.
private static final String[] SYSTEM_METADATA_FIELDS = {
"contentEncoding", "contentDisposition", "contentLanguage", "cacheControl",
"customTime", "storageClass"
};

// System metadata objects.insert accepts on the upload URL. Today that is only
// contentEncoding; GCS ignores the other fields there.
private static GcsObjectMeta systemMetadataFromQuery(UriInfo uriInfo) {
var params = uriInfo.getQueryParameters();
GcsObjectMeta meta = null;
for (String field : SYSTEM_METADATA_FIELDS) {
String value = params.getFirst(field);
if (value == null || value.isBlank()) {
continue;
}
if (meta == null) {
meta = new GcsObjectMeta();
}
assignSystemMetadata(meta, field, value);
var contentEncoding = uriInfo.getQueryParameters().getFirst("contentEncoding");
if (contentEncoding == null || contentEncoding.isBlank()) {
return null;
}
var meta = new GcsObjectMeta();
meta.setContentEncoding(contentEncoding);
return meta;
}

// Fields in the JSON metadata part take precedence over the query string.
private static GcsObjectMeta mergeSystemMetadata(GcsObjectMeta base, Map<?, ?> metadata) {
GcsObjectMeta merged = base;
for (String field : SYSTEM_METADATA_FIELDS) {
if (!(metadata.get(field) instanceof String value) || value.isBlank()) {
if (!(metadata.get(field) instanceof String value)) {
continue;
}
// GCS rejects an empty customTime instead of ignoring it like the other fields.
if (value.isBlank() && !field.equals("customTime")) {
continue;
}
if (merged == null) {
Expand All @@ -182,7 +181,7 @@ private static void assignSystemMetadata(GcsObjectMeta meta, String field, Strin
case "contentDisposition" -> meta.setContentDisposition(value);
case "contentLanguage" -> meta.setContentLanguage(value);
case "cacheControl" -> meta.setCacheControl(value);
case "customTime" -> meta.setCustomTime(value);
case "customTime" -> meta.setCustomTime(GcsCustomTime.normalize(value));
case "storageClass" -> meta.setStorageClass(value);
default -> { /* unreachable: every SYSTEM_METADATA_FIELDS entry is handled above */ }
}
Expand Down
Loading