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

Commit 6993c87

Browse files
huwmrbbot
andauthored
Fix KV and R2 list() with long (≥50ch) prefixes (#691)
* Add failing test for KV `.list()` with long prefix * Remove `LIKE` clause from `KV#list` * Remove escaping code * Fix linter errors * Use lower-case for SQL functions * Ensure KV long-prefix test uses exact namespace length * Allow max-length R2 keys * Fix R2 `list()` with long prefix --------- Co-authored-by: bcoll <[email protected]>
1 parent 4033b24 commit 6993c87

File tree

7 files changed

+28
-26
lines changed

7 files changed

+28
-26
lines changed

packages/miniflare/src/workers/r2/bucket.worker.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
all,
1616
base64Decode,
1717
base64Encode,
18-
escapeLike,
1918
get,
2019
maybeApply,
2120
readPrefix,
@@ -231,12 +230,12 @@ function sqlStmts(db: TypedSql) {
231230
];
232231
// TODO: consider applying same `:start_after IS NULL` trick to KeyValueStore
233232
return db.stmt<
234-
{ limit: number; escaped_prefix: string; start_after: string | null },
233+
{ limit: number; prefix: string; start_after: string | null },
235234
Omit<ObjectRow, "blob_id"> & Pick<ObjectRow, ExtraColumns[number]>
236235
>(`
237236
SELECT ${columns.join(", ")}
238237
FROM _mf_objects
239-
WHERE key LIKE :escaped_prefix || '%' ESCAPE '\\'
238+
WHERE substr(key, 1, length(:prefix)) = :prefix
240239
AND (:start_after IS NULL OR key > :start_after)
241240
ORDER BY key LIMIT :limit
242241
`);
@@ -380,7 +379,6 @@ function sqlStmts(db: TypedSql) {
380379
listMetadata: db.stmt<
381380
{
382381
limit: number;
383-
escaped_prefix: string;
384382
start_after: string | null;
385383
prefix: string;
386384
delimiter: string;
@@ -407,7 +405,7 @@ function sqlStmts(db: TypedSql) {
407405
-- NOTE: we'll ignore metadata for delimited prefix rows, so it doesn't matter which keys' we return
408406
version, size, etag, uploaded, checksums, http_metadata, custom_metadata
409407
FROM _mf_objects
410-
WHERE key LIKE :escaped_prefix || '%' ESCAPE '\\'
408+
WHERE substr(key, 1, length(:prefix)) = :prefix
411409
AND (:start_after IS NULL OR key > :start_after)
412410
GROUP BY delimited_prefix_or_key -- Group keys with same delimited prefix into a row, leaving others in their own rows
413411
ORDER BY last_key LIMIT :limit;
@@ -878,7 +876,7 @@ export class R2BucketObject extends MiniflareDurableObject {
878876

879877
// Run appropriate query depending on options
880878
const params = {
881-
escaped_prefix: escapeLike(prefix),
879+
prefix,
882880
start_after: startAfter ?? null,
883881
// Increase the queried limit by 1, if we return this many results, we
884882
// know there are more rows. We'll truncate to the original limit before
@@ -891,9 +889,7 @@ export class R2BucketObject extends MiniflareDurableObject {
891889
let nextCursorStartAfter: string | undefined;
892890

893891
if (delimiter !== undefined) {
894-
const rows = all(
895-
this.#stmts.listMetadata({ ...params, prefix, delimiter })
896-
);
892+
const rows = all(this.#stmts.listMetadata({ ...params, delimiter }));
897893

898894
// If there are more results, we'll be returning a cursor
899895
const hasMoreRows = rows.length === limit + 1;

packages/miniflare/src/workers/r2/validator.worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export class Validator {
178178

179179
key(key: string): Validator {
180180
const keyLength = Buffer.byteLength(key);
181-
if (keyLength >= R2Limits.MAX_KEY_SIZE) {
181+
if (keyLength > R2Limits.MAX_KEY_SIZE) {
182182
throw new InvalidObjectName();
183183
}
184184
return this;

packages/miniflare/src/workers/shared/index.worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export {
4545
} from "./router.worker";
4646
export type { RouteHandler } from "./router.worker";
4747

48-
export { get, all, drain, escapeLike } from "./sql.worker";
48+
export { get, all, drain } from "./sql.worker";
4949
export type {
5050
TypedValue,
5151
TypedResult,

packages/miniflare/src/workers/shared/keyvalue.worker.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,26 +81,21 @@ function sqlStmts(db: TypedSql) {
8181
list: db.stmt<
8282
{
8383
now: number;
84-
escaped_prefix: string;
84+
prefix: string;
8585
start_after: string;
8686
limit: number;
8787
},
8888
Omit<Row, "blob_id">
8989
>(
9090
`SELECT key, expiration, metadata FROM _mf_entries
91-
WHERE key LIKE :escaped_prefix || '%' ESCAPE '\\'
91+
WHERE substr(key, 1, length(:prefix)) = :prefix
9292
AND key > :start_after
9393
AND (expiration IS NULL OR expiration >= :now)
9494
ORDER BY key LIMIT :limit`
9595
),
9696
};
9797
}
9898

99-
function escapePrefix(prefix: string) {
100-
// Prefix all instances of `\`, `_` and `%` with `\`
101-
return prefix.replace(/[\\_%]/g, "\\$&");
102-
}
103-
10499
function rowEntry<Metadata>(entry: Omit<Row, "blob_id">): KeyEntry<Metadata> {
105100
return {
106101
key: entry.key,
@@ -238,7 +233,7 @@ export class KeyValueStorage<Metadata = unknown> {
238233
async list(opts: KeyEntriesQuery): Promise<KeyEntries<Metadata>> {
239234
// Find non-expired entries matching query after cursor
240235
const now = this.#timers.now();
241-
const escaped_prefix = escapePrefix(opts.prefix ?? "");
236+
const prefix = opts.prefix ?? "";
242237
// Note the "" default here prohibits empty string keys. The consumers
243238
// of this class are KV and Cache. KV validates keys are non-empty.
244239
// Cache keys are usually URLs, but can be customised with `cf.cacheKey`.
@@ -252,7 +247,7 @@ export class KeyValueStorage<Metadata = unknown> {
252247
const limit = opts.limit + 1;
253248
const rowsCursor = this.#stmts.list({
254249
now,
255-
escaped_prefix,
250+
prefix,
256251
start_after,
257252
limit,
258253
});

packages/miniflare/src/workers/shared/sql.worker.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,3 @@ export function drain<R extends TypedResult>(cursor: TypedSqlStorageCursor<R>) {
124124
for (const _ of cursor) {
125125
}
126126
}
127-
128-
export function escapeLike(prefix: string) {
129-
// Prefix all instances of `\`, `_` and `%` with `\`
130-
return prefix.replace(/[\\_%]/g, "\\$&");
131-
}

packages/miniflare/test/plugins/kv/index.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,14 @@ test("paginates keys matching prefix", listMacro, {
461461
[{ name: "section1key3" }],
462462
],
463463
});
464+
test("list: accepts long prefix", async (t) => {
465+
const { kv, ns } = t.context;
466+
// Max key length, minus padding for `context.ns`
467+
const longKey = "x".repeat(512 - ns.length);
468+
await kv.put(longKey, "value");
469+
const page = await kv.list({ prefix: ns + longKey });
470+
t.deepEqual(page.keys, [{ name: ns + longKey }]);
471+
});
464472
test("list: paginates with variable limit", async (t) => {
465473
const { kv, ns } = t.context;
466474
await kv.put("key1", "value1");

packages/miniflare/test/plugins/r2/index.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,15 @@ test(
737737
],
738738
}
739739
);
740-
740+
test("list: accepts long prefix", async (t) => {
741+
const { r2, ns } = t.context;
742+
// Max key length, minus padding for `context.ns`
743+
const longKey = "x".repeat(1024 - ns.length);
744+
await r2.put(longKey, "value");
745+
const { objects } = await r2.list({ prefix: ns + longKey });
746+
t.is(objects.length, 1);
747+
t.is(objects[0].key, ns + longKey);
748+
});
741749
test("list: returns metadata with objects", async (t) => {
742750
const { r2, ns } = t.context;
743751
const start = Date.now();

0 commit comments

Comments
 (0)