Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 27432a4

Browse files
committed
Support SHA-* checksums and return from R2Bucket#{get,head}
Ref: https://community.cloudflare.com/t/2022-9-16-workers-runtime-release-notes/420496 Ref: cloudflare/workerd#103
1 parent 143ea15 commit 27432a4

File tree

5 files changed

+81
-34
lines changed

5 files changed

+81
-34
lines changed

packages/tre/src/plugins/r2/errors.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ enum CfCode {
1414
InternalError = 10001,
1515
NoSuchObjectKey = 10007,
1616
EntityTooLarge = 100100,
17-
InvalidDigest = 10014,
1817
InvalidObjectName = 10020,
1918
InvalidMaxKeys = 10022,
2019
InvalidArgument = 10029,
@@ -110,21 +109,21 @@ export class EntityTooLarge extends R2Error {
110109
}
111110
}
112111

113-
export class InvalidDigest extends R2Error {
114-
constructor() {
115-
super(
116-
Status.BadRequest,
117-
"The Content-MD5 you specified is not valid.",
118-
CfCode.InvalidDigest
119-
);
120-
}
121-
}
122-
123112
export class BadDigest extends R2Error {
124-
constructor() {
113+
constructor(
114+
algorithm: "MD5" | "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512",
115+
provided: Buffer,
116+
calculated: Buffer
117+
) {
125118
super(
126119
Status.BadRequest,
127-
"The Content-MD5 you specified did not match what we received.",
120+
[
121+
`The ${algorithm} checksum you specified did not match what we received.`,
122+
`You provided a ${algorithm} checksum with value: ${provided.toString(
123+
"hex"
124+
)}`,
125+
`Actual ${algorithm} was: ${calculated.toString("hex")}`,
126+
].join("\n"),
128127
CfCode.BadDigest
129128
);
130129
}

packages/tre/src/plugins/r2/gateway.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from "crypto";
12
import { z } from "zod";
23
import { Log } from "../../shared";
34
import { RangeStoredValueMeta, Storage } from "../../storage";
@@ -101,28 +102,24 @@ export class R2Gateway {
101102
value: Uint8Array,
102103
options: R2PutOptions
103104
): Promise<R2Object> {
104-
const { customMetadata, md5, httpMetadata } = options;
105-
106-
const hash = validate.key(key).size(value).md5(value, md5);
105+
const checksums = validate.key(key).size(value).hash(value, options);
107106

108107
// build metadata
108+
const md5Hash = crypto.createHash("md5").update(value).digest("hex");
109109
const metadata: R2ObjectMetadata = {
110110
key,
111111
size: value.byteLength,
112-
etag: hash.toString("hex"),
112+
etag: md5Hash,
113113
version: createVersion(),
114-
httpEtag: `"${hash}"`,
114+
httpEtag: `"${md5Hash}"`,
115115
uploaded: Date.now(),
116-
httpMetadata: httpMetadata ?? {},
117-
customMetadata: customMetadata ?? {},
116+
httpMetadata: options.httpMetadata ?? {},
117+
customMetadata: options.customMetadata ?? {},
118+
checksums,
118119
};
119120

120121
// Store value with expiration and metadata
121-
await this.storage.put<R2ObjectMetadata>(key, {
122-
value,
123-
metadata,
124-
});
125-
122+
await this.storage.put<R2ObjectMetadata>(key, { value, metadata });
126123
return new R2Object(metadata);
127124
}
128125

packages/tre/src/plugins/r2/r2Object.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export interface R2ObjectMetadata {
3333
customMetadata: Record<string, string>;
3434
// If a GET request was made with a range option, this will be added
3535
range?: R2Range;
36+
// Hashes used to check the received object’s integrity. At most one can be
37+
// specified.
38+
checksums?: R2StringChecksums;
3639
}
3740

3841
export interface EncodedMetadata {
@@ -60,6 +63,7 @@ export class R2Object implements R2ObjectMetadata {
6063
readonly httpMetadata: R2HttpFields;
6164
readonly customMetadata: Record<string, string>;
6265
readonly range?: R2Range;
66+
readonly checksums: R2StringChecksums;
6367

6468
constructor(metadata: R2ObjectMetadata) {
6569
this.key = metadata.key;
@@ -71,6 +75,22 @@ export class R2Object implements R2ObjectMetadata {
7175
this.httpMetadata = metadata.httpMetadata;
7276
this.customMetadata = metadata.customMetadata;
7377
this.range = metadata.range;
78+
79+
// For non-multipart uploads, we always need to store an MD5 hash in
80+
// `checksums`, but never explicitly stored one. Luckily, `R2Bucket#put()`
81+
// always makes `etag` an MD5 hash.
82+
const checksums: R2StringChecksums = { ...metadata.checksums };
83+
const etag = metadata.etag;
84+
if (etag.length === 32 && HEX_REGEXP.test(etag)) {
85+
checksums.md5 = metadata.etag;
86+
} else if (etag.length === 24 && BASE64_REGEXP.test(etag)) {
87+
// TODO: remove this when we switch underlying storage mechanisms
88+
// Previous versions of Miniflare 3 base64 encoded `etag` instead
89+
checksums.md5 = Buffer.from(etag, "base64").toString("hex");
90+
} else {
91+
assert.fail("Expected `etag` to be an MD5 hash");
92+
}
93+
this.checksums = checksums;
7494
}
7595

7696
// Format for return to the Workers Runtime
@@ -83,6 +103,13 @@ export class R2Object implements R2ObjectMetadata {
83103
k,
84104
v,
85105
})),
106+
checksums: {
107+
0: this.checksums.md5,
108+
1: this.checksums.sha1,
109+
2: this.checksums.sha256,
110+
3: this.checksums.sha384,
111+
4: this.checksums.sha512,
112+
},
86113
};
87114
}
88115

packages/tre/src/plugins/r2/schemas.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,10 @@ export const R2PutRequestSchema = z
111111
httpFields: R2HttpFieldsSchema.optional(), // (renamed in transform)
112112
onlyIf: R2ConditionalSchema.optional(),
113113
md5: Base64DataSchema.optional(), // (intentionally base64, not hex) // TODO: make sure we're testing this is base64
114-
sha1: HexDataSchema.optional(), // TODO: support
115-
sha256: HexDataSchema.optional(), // TODO: support
116-
sha384: HexDataSchema.optional(), // TODO: support
117-
sha512: HexDataSchema.optional(), // TODO: support
114+
sha1: HexDataSchema.optional(),
115+
sha256: HexDataSchema.optional(),
116+
sha384: HexDataSchema.optional(),
117+
sha512: HexDataSchema.optional(),
118118
})
119119
.transform((value) => ({
120120
method: value.method,

packages/tre/src/plugins/r2/validator.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from "crypto";
2+
import { R2StringChecksums } from "@cloudflare/workers-types/experimental";
23
import {
34
BadDigest,
45
EntityTooLarge,
@@ -62,14 +63,37 @@ function testR2Conditional(
6263

6364
return true;
6465
}
66+
67+
export const R2_HASH_ALGORITHMS = [
68+
{ name: "MD5", field: "md5" },
69+
{ name: "SHA-1", field: "sha1" },
70+
{ name: "SHA-256", field: "sha256" },
71+
{ name: "SHA-384", field: "sha384" },
72+
{ name: "SHA-512", field: "sha512" },
73+
] as const;
74+
export type R2Hashes = Record<
75+
typeof R2_HASH_ALGORITHMS[number]["field"],
76+
Buffer | undefined
77+
>;
78+
6579
export class Validator {
66-
md5(value: Uint8Array, md5?: Buffer): Buffer {
67-
const md5Hash = crypto.createHash("md5").update(value).digest();
68-
if (md5 !== undefined && !md5.equals(md5Hash)) {
69-
throw new BadDigest();
80+
hash(value: Uint8Array, hashes: R2Hashes): R2StringChecksums {
81+
const checksums: R2StringChecksums = {};
82+
for (const { name, field } of R2_HASH_ALGORITHMS) {
83+
const providedHash = hashes[field];
84+
if (providedHash !== undefined) {
85+
const computedHash = crypto.createHash(field).update(value).digest();
86+
if (!providedHash.equals(computedHash)) {
87+
throw new BadDigest(name, providedHash, computedHash);
88+
}
89+
// Store computed hash to ensure consistent casing in returned checksums
90+
// from `R2Object`
91+
checksums[field] = computedHash.toString("hex");
92+
}
7093
}
71-
return md5Hash;
94+
return checksums;
7295
}
96+
7397
condition(meta: R2Object, onlyIf?: R2Conditional): Validator {
7498
// test conditional should it exist
7599
if (!testR2Conditional(onlyIf, meta) || meta?.size === 0) {

0 commit comments

Comments
 (0)