Add R2 storage contracts
This commit is contained in:
@@ -2,6 +2,21 @@ export interface ElyKvNamespace {
|
|||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElyR2Object {
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElyR2PutOptions {
|
||||||
|
httpMetadata?: { contentType?: string };
|
||||||
|
customMetadata?: Record<string, string>;
|
||||||
|
sha256?: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElyR2Bucket {
|
||||||
|
get(key: string): Promise<ElyR2Object | null>;
|
||||||
|
put(key: string, value: ArrayBuffer, options?: ElyR2PutOptions): Promise<ElyR2Object>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElyD1PreparedStatement {
|
export interface ElyD1PreparedStatement {
|
||||||
bind(...values: unknown[]): ElyD1PreparedStatement;
|
bind(...values: unknown[]): ElyD1PreparedStatement;
|
||||||
first<T = unknown>(): Promise<T | null>;
|
first<T = unknown>(): Promise<T | null>;
|
||||||
@@ -32,6 +47,7 @@ export interface ElyAnalyticsDataset {
|
|||||||
export interface Env {
|
export interface Env {
|
||||||
ELY_DB: ElyD1Database;
|
ELY_DB: ElyD1Database;
|
||||||
ELY_KV: ElyKvNamespace;
|
ELY_KV: ElyKvNamespace;
|
||||||
|
ELY_STORAGE: ElyR2Bucket;
|
||||||
ELY_RATE_LIMITER: ElyRateLimit;
|
ELY_RATE_LIMITER: ElyRateLimit;
|
||||||
ELY_API_AUDIT: ElyAnalyticsDataset;
|
ELY_API_AUDIT: ElyAnalyticsDataset;
|
||||||
ELY_ENVIRONMENT: string;
|
ELY_ENVIRONMENT: string;
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import type { ElyR2Bucket } from "./bindings.js";
|
||||||
|
|
||||||
|
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
||||||
|
const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
||||||
|
const SEGMENT = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||||
|
const SYNC_OBJECT_TYPES = new Set([
|
||||||
|
"spaces",
|
||||||
|
"tabs",
|
||||||
|
"bookmarks",
|
||||||
|
"notes",
|
||||||
|
"reading-list",
|
||||||
|
"profiles",
|
||||||
|
"site-permissions",
|
||||||
|
"history",
|
||||||
|
"plugin-settings",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class StorageObjectError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "StorageObjectError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredObject {
|
||||||
|
key: string;
|
||||||
|
sha256: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncPayloadKey(params: {
|
||||||
|
region: string;
|
||||||
|
userHash: string;
|
||||||
|
objectType: string;
|
||||||
|
objectId: string;
|
||||||
|
payloadHash: string;
|
||||||
|
}): string {
|
||||||
|
assertRegion(params.region);
|
||||||
|
assertSha256Hex(params.userHash, "user_hash");
|
||||||
|
assertSyncObjectType(params.objectType);
|
||||||
|
assertSegment(params.objectId, "object_id");
|
||||||
|
assertSha256Hex(params.payloadHash, "payload_hash");
|
||||||
|
return [
|
||||||
|
"sync-payloads",
|
||||||
|
params.region,
|
||||||
|
params.userHash,
|
||||||
|
params.objectType,
|
||||||
|
params.objectId,
|
||||||
|
`${params.payloadHash}.bin`,
|
||||||
|
].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncSnapshotKey(params: {
|
||||||
|
region: string;
|
||||||
|
userHash: string;
|
||||||
|
snapshotId: string;
|
||||||
|
}): string {
|
||||||
|
assertRegion(params.region);
|
||||||
|
assertSha256Hex(params.userHash, "user_hash");
|
||||||
|
assertSegment(params.snapshotId, "snapshot_id");
|
||||||
|
return ["sync-snapshots", params.region, params.userHash, `${params.snapshotId}.bin`].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginPackageKey(params: { pluginId: string; packageHash: string }): string {
|
||||||
|
assertSegment(params.pluginId, "plugin_id");
|
||||||
|
assertSha256Hex(params.packageHash, "package_hash");
|
||||||
|
return ["plugin-packages", params.pluginId, `${params.packageHash}.rplug`].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginAssetKey(params: { pluginId: string; assetHash: string }): string {
|
||||||
|
assertSegment(params.pluginId, "plugin_id");
|
||||||
|
assertSha256Hex(params.assetHash, "asset_hash");
|
||||||
|
return ["plugin-assets", params.pluginId, params.assetHash].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userAvatarKey(params: { userHash: string; avatarHash: string }): string {
|
||||||
|
assertSha256Hex(params.userHash, "user_hash");
|
||||||
|
assertSha256Hex(params.avatarHash, "avatar_hash");
|
||||||
|
return ["user-avatars", params.userHash, params.avatarHash].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crashAttachmentKey(params: {
|
||||||
|
reportId: string;
|
||||||
|
attachmentHash: string;
|
||||||
|
}): string {
|
||||||
|
assertSegment(params.reportId, "report_id");
|
||||||
|
assertSha256Hex(params.attachmentHash, "attachment_hash");
|
||||||
|
return ["crash-attachments", params.reportId, params.attachmentHash].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportObjectKey(params: { userHash: string; exportId: string }): string {
|
||||||
|
assertSha256Hex(params.userHash, "user_hash");
|
||||||
|
assertSegment(params.exportId, "export_id");
|
||||||
|
return ["exports", params.userHash, `${params.exportId}.bin`].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putVerifiedObject(
|
||||||
|
bucket: ElyR2Bucket,
|
||||||
|
key: string,
|
||||||
|
payload: ArrayBuffer,
|
||||||
|
expectedSha256: string,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<StoredObject> {
|
||||||
|
assertKnownObjectKey(key);
|
||||||
|
assertSha256Hex(expectedSha256, "sha256");
|
||||||
|
assertKeyChecksum(key, expectedSha256);
|
||||||
|
const actualSha256 = await sha256Hex(payload);
|
||||||
|
if (actualSha256 !== expectedSha256) {
|
||||||
|
throw new StorageObjectError("r2_checksum_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
await bucket.put(key, payload, {
|
||||||
|
httpMetadata: { contentType },
|
||||||
|
customMetadata: { sha256: expectedSha256 },
|
||||||
|
sha256: hexToArrayBuffer(expectedSha256),
|
||||||
|
});
|
||||||
|
return { key, sha256: expectedSha256, sizeBytes: payload.byteLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVerifiedObject(
|
||||||
|
bucket: ElyR2Bucket,
|
||||||
|
key: string,
|
||||||
|
expectedSha256: string,
|
||||||
|
): Promise<ArrayBuffer | null> {
|
||||||
|
assertKnownObjectKey(key);
|
||||||
|
assertSha256Hex(expectedSha256, "sha256");
|
||||||
|
assertKeyChecksum(key, expectedSha256);
|
||||||
|
const object = await bucket.get(key);
|
||||||
|
if (object === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await object.arrayBuffer();
|
||||||
|
const actualSha256 = await sha256Hex(payload);
|
||||||
|
if (actualSha256 !== expectedSha256) {
|
||||||
|
throw new StorageObjectError("r2_checksum_mismatch");
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertKnownObjectKey(key: string): void {
|
||||||
|
const matches = [
|
||||||
|
/^sync-payloads\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/,
|
||||||
|
/^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/,
|
||||||
|
/^plugin-packages\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.rplug$/,
|
||||||
|
/^plugin-assets\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/,
|
||||||
|
/^user-avatars\/[a-f0-9]{64}\/[a-f0-9]{64}$/,
|
||||||
|
/^crash-attachments\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/,
|
||||||
|
/^exports\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/,
|
||||||
|
];
|
||||||
|
if (!matches.some((pattern) => pattern.test(key))) {
|
||||||
|
throw new StorageObjectError("r2_key_invalid");
|
||||||
|
}
|
||||||
|
if (key.startsWith("sync-payloads/")) {
|
||||||
|
assertSyncObjectType(key.split("/")[3] ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertKeyChecksum(key: string, expectedSha256: string): void {
|
||||||
|
const keyChecksum = checksumFromKey(key);
|
||||||
|
if (keyChecksum !== null && keyChecksum !== expectedSha256) {
|
||||||
|
throw new StorageObjectError("r2_key_checksum_mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksumFromKey(key: string): string | null {
|
||||||
|
const segments = key.split("/");
|
||||||
|
const prefix = segments[0];
|
||||||
|
const lastSegment = segments[segments.length - 1];
|
||||||
|
if (lastSegment === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefix === "sync-payloads") {
|
||||||
|
return lastSegment.slice(0, -".bin".length);
|
||||||
|
}
|
||||||
|
if (prefix === "plugin-packages") {
|
||||||
|
return lastSegment.slice(0, -".rplug".length);
|
||||||
|
}
|
||||||
|
if (prefix === "plugin-assets" || prefix === "user-avatars" || prefix === "crash-attachments") {
|
||||||
|
return lastSegment;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRegion(value: string): void {
|
||||||
|
if (!REGION.test(value)) {
|
||||||
|
throw new StorageObjectError("region_invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSegment(value: string, name: string): void {
|
||||||
|
if (!SEGMENT.test(value)) {
|
||||||
|
throw new StorageObjectError(`${name}_invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSyncObjectType(value: string): void {
|
||||||
|
if (!SYNC_OBJECT_TYPES.has(value)) {
|
||||||
|
throw new StorageObjectError("object_type_invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSha256Hex(value: string, name: string): void {
|
||||||
|
if (!SHA256_HEX.test(value)) {
|
||||||
|
throw new StorageObjectError(`${name}_invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(payload: ArrayBuffer): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", payload);
|
||||||
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToArrayBuffer(hex: string): ArrayBuffer {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
@@ -103,6 +103,7 @@ function testEnv(options: TestEnvOptions = {}): Env {
|
|||||||
return Promise.resolve(values.get(key) ?? null);
|
return Promise.resolve(values.get(key) ?? null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ELY_STORAGE: testR2Bucket(),
|
||||||
ELY_RATE_LIMITER: {
|
ELY_RATE_LIMITER: {
|
||||||
limit(input: { key: string }): Promise<{ success: boolean }> {
|
limit(input: { key: string }): Promise<{ success: boolean }> {
|
||||||
options.rateLimitKeys?.push(input.key);
|
options.rateLimitKeys?.push(input.key);
|
||||||
@@ -119,6 +120,21 @@ function testEnv(options: TestEnvOptions = {}): Env {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testR2Bucket(): Env["ELY_STORAGE"] {
|
||||||
|
return {
|
||||||
|
get() {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
},
|
||||||
|
put() {
|
||||||
|
return Promise.resolve({
|
||||||
|
arrayBuffer() {
|
||||||
|
return Promise.resolve(new ArrayBuffer(0));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function testD1Database(): Env["ELY_DB"] {
|
function testD1Database(): Env["ELY_DB"] {
|
||||||
return {
|
return {
|
||||||
prepare() {
|
prepare() {
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ function testEnv(
|
|||||||
return Promise.resolve(values.get(key) ?? null);
|
return Promise.resolve(values.get(key) ?? null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ELY_STORAGE: testR2Bucket(),
|
||||||
ELY_RATE_LIMITER: {
|
ELY_RATE_LIMITER: {
|
||||||
limit(): Promise<{ success: boolean }> {
|
limit(): Promise<{ success: boolean }> {
|
||||||
return Promise.resolve({ success: true });
|
return Promise.resolve({ success: true });
|
||||||
@@ -358,6 +359,21 @@ function testEnv(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testR2Bucket(): Env["ELY_STORAGE"] {
|
||||||
|
return {
|
||||||
|
get() {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
},
|
||||||
|
put() {
|
||||||
|
return Promise.resolve({
|
||||||
|
arrayBuffer() {
|
||||||
|
return Promise.resolve(new ArrayBuffer(0));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function releaseManifestDocument(): string {
|
function releaseManifestDocument(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import type { ElyR2Bucket, ElyR2PutOptions } from "../src/bindings.js";
|
||||||
|
import {
|
||||||
|
StorageObjectError,
|
||||||
|
crashAttachmentKey,
|
||||||
|
exportObjectKey,
|
||||||
|
getVerifiedObject,
|
||||||
|
pluginAssetKey,
|
||||||
|
pluginPackageKey,
|
||||||
|
putVerifiedObject,
|
||||||
|
syncPayloadKey,
|
||||||
|
syncSnapshotKey,
|
||||||
|
userAvatarKey,
|
||||||
|
} from "../src/storage.js";
|
||||||
|
|
||||||
|
const USER_HASH = "a".repeat(64);
|
||||||
|
const PAYLOAD_HASH = "b".repeat(64);
|
||||||
|
const PACKAGE_HASH = "c".repeat(64);
|
||||||
|
const ASSET_HASH = "d".repeat(64);
|
||||||
|
const AVATAR_HASH = "e".repeat(64);
|
||||||
|
const ATTACHMENT_HASH = "f".repeat(64);
|
||||||
|
|
||||||
|
describe("R2 storage contracts", () => {
|
||||||
|
it("builds PRD object keys without sensitive path segments", () => {
|
||||||
|
assert.equal(
|
||||||
|
syncPayloadKey({
|
||||||
|
region: "us-east",
|
||||||
|
userHash: USER_HASH,
|
||||||
|
objectType: "tabs",
|
||||||
|
objectId: "tab-01",
|
||||||
|
payloadHash: PAYLOAD_HASH,
|
||||||
|
}),
|
||||||
|
`sync-payloads/us-east/${USER_HASH}/tabs/tab-01/${PAYLOAD_HASH}.bin`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
syncSnapshotKey({ region: "us-east", userHash: USER_HASH, snapshotId: "snapshot-01" }),
|
||||||
|
`sync-snapshots/us-east/${USER_HASH}/snapshot-01.bin`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
pluginPackageKey({ pluginId: "elydora.reader", packageHash: PACKAGE_HASH }),
|
||||||
|
`plugin-packages/elydora.reader/${PACKAGE_HASH}.rplug`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
pluginAssetKey({ pluginId: "elydora.reader", assetHash: ASSET_HASH }),
|
||||||
|
`plugin-assets/elydora.reader/${ASSET_HASH}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
userAvatarKey({ userHash: USER_HASH, avatarHash: AVATAR_HASH }),
|
||||||
|
`user-avatars/${USER_HASH}/${AVATAR_HASH}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
crashAttachmentKey({ reportId: "report-01", attachmentHash: ATTACHMENT_HASH }),
|
||||||
|
`crash-attachments/report-01/${ATTACHMENT_HASH}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
exportObjectKey({ userHash: USER_HASH, exportId: "export-01" }),
|
||||||
|
`exports/${USER_HASH}/export-01.bin`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects R2 keys with path traversal or unsupported sync object types", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
syncPayloadKey({
|
||||||
|
region: "us-east",
|
||||||
|
userHash: USER_HASH,
|
||||||
|
objectType: "passwords",
|
||||||
|
objectId: "tab-01",
|
||||||
|
payloadHash: PAYLOAD_HASH,
|
||||||
|
}),
|
||||||
|
StorageObjectError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => crashAttachmentKey({ reportId: "../report-01", attachmentHash: ATTACHMENT_HASH }),
|
||||||
|
StorageObjectError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects raw sync payload keys with unsupported object types before R2 writes", async () => {
|
||||||
|
const bucket = recordedR2Bucket();
|
||||||
|
const payload = bytes("encrypted sync payload");
|
||||||
|
const checksum = sha256(payload);
|
||||||
|
const key = `sync-payloads/us-east/${USER_HASH}/passwords/item-01/${checksum}.bin`;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => putVerifiedObject(bucket, key, payload, checksum, "application/octet-stream"),
|
||||||
|
StorageObjectError,
|
||||||
|
);
|
||||||
|
assert.equal(bucket.puts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes R2 objects only after checksum verification", async () => {
|
||||||
|
const bucket = recordedR2Bucket();
|
||||||
|
const payload = bytes("encrypted sync payload");
|
||||||
|
const checksum = sha256(payload);
|
||||||
|
const key = syncPayloadKey({
|
||||||
|
region: "us-east",
|
||||||
|
userHash: USER_HASH,
|
||||||
|
objectType: "tabs",
|
||||||
|
objectId: "tab-01",
|
||||||
|
payloadHash: checksum,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = await putVerifiedObject(bucket, key, payload, checksum, "application/octet-stream");
|
||||||
|
|
||||||
|
assert.deepEqual(stored, { key, sha256: checksum, sizeBytes: payload.byteLength });
|
||||||
|
assert.equal(bucket.puts.length, 1);
|
||||||
|
assert.equal(bucket.puts[0]?.key, key);
|
||||||
|
assert.equal(bucket.puts[0]?.options.httpMetadata?.contentType, "application/octet-stream");
|
||||||
|
assert.equal(bucket.puts[0]?.options.customMetadata?.sha256, checksum);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Uint8Array(bucket.puts[0]?.options.sha256 ?? new ArrayBuffer(0)),
|
||||||
|
new Uint8Array(Buffer.from(checksum, "hex")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects payload checksums that disagree with hashed R2 key segments", async () => {
|
||||||
|
const bucket = recordedR2Bucket();
|
||||||
|
const payload = bytes("encrypted sync payload");
|
||||||
|
const checksum = sha256(payload);
|
||||||
|
const key = syncPayloadKey({
|
||||||
|
region: "us-east",
|
||||||
|
userHash: USER_HASH,
|
||||||
|
objectType: "tabs",
|
||||||
|
objectId: "tab-01",
|
||||||
|
payloadHash: PAYLOAD_HASH,
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => putVerifiedObject(bucket, key, payload, checksum, "application/octet-stream"),
|
||||||
|
StorageObjectError,
|
||||||
|
);
|
||||||
|
assert.equal(bucket.puts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects payload checksum mismatches before R2 writes", async () => {
|
||||||
|
const bucket = recordedR2Bucket();
|
||||||
|
const payload = bytes("encrypted sync payload");
|
||||||
|
const key = syncPayloadKey({
|
||||||
|
region: "us-east",
|
||||||
|
userHash: USER_HASH,
|
||||||
|
objectType: "tabs",
|
||||||
|
objectId: "tab-01",
|
||||||
|
payloadHash: PAYLOAD_HASH,
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => putVerifiedObject(bucket, key, payload, PAYLOAD_HASH, "application/octet-stream"),
|
||||||
|
StorageObjectError,
|
||||||
|
);
|
||||||
|
assert.equal(bucket.puts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies R2 downloads against the expected checksum", async () => {
|
||||||
|
const payload = bytes("encrypted snapshot");
|
||||||
|
const checksum = sha256(payload);
|
||||||
|
const bucket = recordedR2Bucket(payload);
|
||||||
|
const key = syncSnapshotKey({
|
||||||
|
region: "us-east",
|
||||||
|
userHash: USER_HASH,
|
||||||
|
snapshotId: "snapshot-01",
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloaded = await getVerifiedObject(bucket, key, checksum);
|
||||||
|
|
||||||
|
assert.deepEqual(new Uint8Array(downloaded ?? new ArrayBuffer(0)), new Uint8Array(payload));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RecordedPut {
|
||||||
|
key: string;
|
||||||
|
payload: ArrayBuffer;
|
||||||
|
options: ElyR2PutOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordedR2Bucket extends ElyR2Bucket {
|
||||||
|
puts: RecordedPut[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordedR2Bucket(payload?: ArrayBuffer): RecordedR2Bucket {
|
||||||
|
const puts: RecordedPut[] = [];
|
||||||
|
return {
|
||||||
|
puts,
|
||||||
|
get() {
|
||||||
|
if (payload === undefined) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
arrayBuffer() {
|
||||||
|
return Promise.resolve(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
put(key: string, value: ArrayBuffer, options: ElyR2PutOptions = {}) {
|
||||||
|
puts.push({ key, payload: value, options });
|
||||||
|
return Promise.resolve({
|
||||||
|
arrayBuffer() {
|
||||||
|
return Promise.resolve(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes(value: string): ArrayBuffer {
|
||||||
|
return new TextEncoder().encode(value).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(payload: ArrayBuffer): string {
|
||||||
|
return createHash("sha256").update(new Uint8Array(payload)).digest("hex");
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ database_name = "elydora-db"
|
|||||||
database_id = "6cf37748-b5ff-4765-ba91-69f3780dcbd7"
|
database_id = "6cf37748-b5ff-4765-ba91-69f3780dcbd7"
|
||||||
migrations_dir = "migrations"
|
migrations_dir = "migrations"
|
||||||
|
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = "ELY_STORAGE"
|
||||||
|
bucket_name = "ely-browser-storage"
|
||||||
|
|
||||||
[[kv_namespaces]]
|
[[kv_namespaces]]
|
||||||
binding = "ELY_KV"
|
binding = "ELY_KV"
|
||||||
id = "5eff92ba31c94fcf83e1b6d5e79ce070"
|
id = "5eff92ba31c94fcf83e1b6d5e79ce070"
|
||||||
|
|||||||
Reference in New Issue
Block a user