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

Commit 4f95fa3

Browse files
committed
Support secondsGranularity in R2Conditional and add tests
1 parent e164a92 commit 4f95fa3

File tree

3 files changed

+150
-41
lines changed

3 files changed

+150
-41
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,4 @@ export const R2_PLUGIN: Plugin<
4646
export * from "./r2Object";
4747
export * from "./gateway";
4848
export * from "./schemas";
49+
export { _testR2Conditional } from "./validator";

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

Lines changed: 35 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,51 +17,45 @@ const UNPAIRED_SURROGATE_PAIR_REGEX =
1717
/^(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])$/;
1818
const MAX_VALUE_SIZE = 5 * 1_000 * 1_000 * 1_000 - 5 * 1_000 * 1_000;
1919

20-
// false -> the condition testing "failed"
21-
function testR2Conditional(
22-
conditional?: R2Conditional,
23-
metadata?: R2ObjectMetadata
20+
function identity(ms: number) {
21+
return ms;
22+
}
23+
function truncateToSeconds(ms: number) {
24+
return Math.floor(ms / 1000) * 1000;
25+
}
26+
27+
// Returns `true` iff the condition passed
28+
/** @internal */
29+
export function _testR2Conditional(
30+
cond: R2Conditional,
31+
metadata?: Pick<R2ObjectMetadata, "etag" | "uploaded">
2432
): boolean {
25-
const { etagMatches, etagDoesNotMatch, uploadedBefore, uploadedAfter } =
26-
conditional ?? {};
33+
// Adapted from internal R2 gateway implementation.
34+
// See also https://datatracker.ietf.org/doc/html/rfc7232#section-6.
2735

28-
// If the object doesn't exist
2936
if (metadata === undefined) {
30-
// the etagDoesNotMatch and uploadedBefore automatically pass
31-
// etagMatches and uploadedAfter automatically fail if they exist
32-
return etagMatches === undefined && uploadedAfter === undefined;
33-
}
34-
35-
const { etag, uploaded } = metadata;
36-
37-
// ifMatch check
38-
const ifMatch = etagMatches ? etagMatches === etag : null;
39-
if (ifMatch === false) return false;
40-
41-
// ifNoMatch check
42-
const ifNoneMatch = etagDoesNotMatch ? etagDoesNotMatch !== etag : null;
43-
44-
if (ifNoneMatch === false) return false;
45-
46-
// ifUnmodifiedSince check
47-
if (
48-
ifMatch !== true && // if "ifMatch" is true, we ignore date checking
49-
uploadedBefore !== undefined &&
50-
uploaded > uploadedBefore.getTime()
51-
) {
52-
return false;
53-
}
54-
55-
// ifModifiedSince check
56-
if (
57-
ifNoneMatch !== true && // if "ifNoneMatch" is true, we ignore date checking
58-
uploadedAfter !== undefined &&
59-
uploaded < uploadedAfter.getTime()
60-
) {
61-
return false;
37+
const ifMatch = cond.etagMatches === undefined;
38+
const ifModifiedSince = cond.uploadedAfter === undefined;
39+
return ifMatch && ifModifiedSince;
6240
}
6341

64-
return true;
42+
const { etag, uploaded: lastModifiedRaw } = metadata;
43+
const ifMatch = cond.etagMatches === undefined || cond.etagMatches === etag;
44+
const ifNoneMatch =
45+
cond.etagDoesNotMatch === undefined || cond.etagDoesNotMatch !== etag;
46+
47+
const maybeTruncate = cond.secondsGranularity ? truncateToSeconds : identity;
48+
const lastModified = maybeTruncate(lastModifiedRaw);
49+
const ifModifiedSince =
50+
cond.uploadedAfter === undefined ||
51+
maybeTruncate(cond.uploadedAfter.getTime()) < lastModified ||
52+
(cond.etagDoesNotMatch !== undefined && ifNoneMatch);
53+
const ifUnmodifiedSince =
54+
cond.uploadedBefore === undefined ||
55+
lastModified < maybeTruncate(cond.uploadedBefore.getTime()) ||
56+
(cond.etagMatches !== undefined && ifMatch);
57+
58+
return ifMatch && ifNoneMatch && ifModifiedSince && ifUnmodifiedSince;
6559
}
6660

6761
export const R2_HASH_ALGORITHMS = [
@@ -95,7 +89,7 @@ export class Validator {
9589
}
9690

9791
condition(meta?: R2Object, onlyIf?: R2Conditional): Validator {
98-
if (onlyIf !== undefined && !testR2Conditional(onlyIf, meta)) {
92+
if (onlyIf !== undefined && !_testR2Conditional(onlyIf, meta)) {
9993
let error = new PreconditionFailed();
10094
if (meta !== undefined) error = error.attach(meta);
10195
throw error;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { R2Conditional } from "@cloudflare/workers-types/experimental";
2+
import { R2ObjectMetadata, _testR2Conditional } from "@miniflare/tre";
3+
import test from "ava";
4+
5+
test("testR2Conditional: matches various conditions", (t) => {
6+
// Adapted from internal R2 gateway tests
7+
const etag = "test";
8+
const badEtag = "not-test";
9+
10+
const uploadedDate = new Date("2023-02-24T00:09:00.500Z");
11+
const pastDate = new Date(uploadedDate.getTime() - 30_000);
12+
const futureDate = new Date(uploadedDate.getTime() + 30_000);
13+
14+
const metadata: Pick<R2ObjectMetadata, "etag" | "uploaded"> = {
15+
etag,
16+
uploaded: uploadedDate.getTime(),
17+
};
18+
19+
const using = (cond: R2Conditional) => _testR2Conditional(cond, metadata);
20+
const usingMissing = (cond: R2Conditional) => _testR2Conditional(cond);
21+
22+
// Check single conditions
23+
t.true(using({ etagMatches: etag }));
24+
t.false(using({ etagMatches: badEtag }));
25+
26+
t.true(using({ etagDoesNotMatch: badEtag }));
27+
t.false(using({ etagDoesNotMatch: etag }));
28+
29+
t.false(using({ uploadedBefore: pastDate }));
30+
t.true(using({ uploadedBefore: futureDate }));
31+
32+
t.true(using({ uploadedAfter: pastDate }));
33+
t.false(using({ uploadedBefore: pastDate }));
34+
35+
// Check multiple conditions that evaluate to false
36+
t.false(using({ etagMatches: etag, etagDoesNotMatch: etag }));
37+
t.false(using({ etagMatches: etag, uploadedAfter: futureDate }));
38+
t.false(
39+
using({
40+
// `etagMatches` pass makes `uploadedBefore` pass, but `uploadedAfter` fails
41+
etagMatches: etag,
42+
uploadedAfter: futureDate,
43+
uploadedBefore: pastDate,
44+
})
45+
);
46+
t.false(using({ etagDoesNotMatch: badEtag, uploadedBefore: pastDate }));
47+
t.false(
48+
using({
49+
// `etagDoesNotMatch` pass makes `uploadedAfter` pass, but `uploadedBefore` fails
50+
etagDoesNotMatch: badEtag,
51+
uploadedAfter: futureDate,
52+
uploadedBefore: pastDate,
53+
})
54+
);
55+
t.false(
56+
using({
57+
etagMatches: badEtag,
58+
etagDoesNotMatch: badEtag,
59+
uploadedAfter: pastDate,
60+
uploadedBefore: futureDate,
61+
})
62+
);
63+
64+
// Check multiple conditions that evaluate to true
65+
t.true(using({ etagMatches: etag, etagDoesNotMatch: badEtag }));
66+
// `etagMatches` pass makes `uploadedBefore` pass
67+
t.true(using({ etagMatches: etag, uploadedBefore: pastDate }));
68+
// `etagDoesNotMatch` pass makes `uploadedAfter` pass
69+
t.true(using({ etagDoesNotMatch: badEtag, uploadedAfter: futureDate }));
70+
t.true(
71+
using({
72+
// `etagMatches` pass makes `uploadedBefore` pass
73+
etagMatches: etag,
74+
uploadedBefore: pastDate,
75+
// `etagDoesNotMatch` pass makes `uploadedAfter` pass
76+
etagDoesNotMatch: badEtag,
77+
uploadedAfter: futureDate,
78+
})
79+
);
80+
t.true(
81+
using({
82+
uploadedBefore: futureDate,
83+
// `etagDoesNotMatch` pass makes `uploadedAfter` pass
84+
etagDoesNotMatch: badEtag,
85+
uploadedAfter: futureDate,
86+
})
87+
);
88+
t.true(
89+
using({
90+
uploadedAfter: pastDate,
91+
// `etagMatches` pass makes `uploadedBefore` pass
92+
etagMatches: etag,
93+
uploadedBefore: pastDate,
94+
})
95+
);
96+
97+
// Check missing metadata fails with either `etagMatches` and `uploadedAfter`
98+
t.false(usingMissing({ etagMatches: etag }));
99+
t.false(usingMissing({ uploadedAfter: pastDate }));
100+
t.false(usingMissing({ etagMatches: etag, uploadedAfter: pastDate }));
101+
t.true(usingMissing({ etagDoesNotMatch: etag }));
102+
t.true(usingMissing({ uploadedBefore: pastDate }));
103+
t.true(usingMissing({ etagDoesNotMatch: etag, uploadedBefore: pastDate }));
104+
t.false(usingMissing({ etagMatches: etag, uploadedBefore: pastDate }));
105+
t.false(usingMissing({ etagDoesNotMatch: etag, uploadedAfter: pastDate }));
106+
107+
// Check with second granularity
108+
const justPastDate = new Date(uploadedDate.getTime() - 250);
109+
const justFutureDate = new Date(uploadedDate.getTime() + 250);
110+
t.true(using({ uploadedAfter: justPastDate }));
111+
t.false(using({ uploadedAfter: justPastDate, secondsGranularity: true }));
112+
t.true(using({ uploadedBefore: justFutureDate }));
113+
t.false(using({ uploadedBefore: justFutureDate, secondsGranularity: true }));
114+
});

0 commit comments

Comments
 (0)