@stowage/conformance 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ npm install --save-dev @stowage/conformance
14
14
  A `ConformanceTarget` tells the suite how to construct the storage under test. `createStorage`
15
15
  returns a storage the run may write to below any prefix, constructed from outside the adapter the
16
16
  way a caller would construct it
17
- ([spec 8.3](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.1.0/docs/spec.md#83-what-the-target-promises)).
17
+ ([spec 9.3](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.2.0/docs/spec.md#93-what-the-target-promises)).
18
18
 
19
19
  ```ts
20
20
  import { memoryStorage } from "@stowage/adapter-memory";
@@ -41,14 +41,15 @@ The optional members widen the run:
41
41
  The suite reads what the storage supports from its `capabilities`, once per run, before the first
42
42
  case. The storage lists every name out of `capabilityNames` it implements, each once, and the list
43
43
  is fixed when it is constructed
44
- ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.1.0/docs/spec.md#49-capabilities)).
44
+ ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.2.0/docs/spec.md#49-capabilities)).
45
45
  Every storage the target creates in one run declares the same; two configurations are two targets.
46
46
 
47
- A case whose `requires` the storage declares runs its `run` half. A case it does not declare runs
48
- the `runWithout` half, which checks what the storage does without the capability: most refuse the
49
- call with `Unsupported` naming it, `presignedUrls` has `presignGet` and `presignPut` absent from the
50
- storage, and a few check the weaker behavior, such as a copy whose destination reads `{}`
51
- ([spec 8.5](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.1.0/docs/spec.md#85-cases)).
47
+ A case whose `requires` the storage declares runs its `run` half. A case missing a name of its
48
+ `requires` runs the `runWithout` half, which checks what the storage does without the capability:
49
+ most refuse the call with `Unsupported` naming it, `presignedUrls` has `presignGet` and
50
+ `presignPut` absent from the storage, and a few check the weaker behavior, such as a copy whose
51
+ destination reads `{}`
52
+ ([spec 9.5](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.2.0/docs/spec.md#95-cases)).
52
53
  An adapter refuses such a call like this:
53
54
 
54
55
  ```ts
@@ -73,7 +74,7 @@ export function refuseUserMetadata(bucket: string): StorageError {
73
74
 
74
75
  `describeConformance(target, framework)` maps every case onto the `describe` and `test` of the
75
76
  framework it is handed
76
- ([spec 8.2](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.1.0/docs/spec.md#82-running-the-suite)).
77
+ ([spec 9.2](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.2.0/docs/spec.md#92-running-the-suite)).
77
78
  It runs the `fast` cases, and both tiers with `includeSlow: true`.
78
79
 
79
80
  Vitest:
@@ -156,27 +157,29 @@ A failed result carries the error as `name`, `message`, `stack` and, for a `Stor
156
157
 
157
158
  ## Read an adapter
158
159
 
159
- [`@stowage/adapter-memory`](https://github.com/stowage-js/stowage/tree/@stowage/adapter-memory@0.1.0/packages/adapter-memory/src)
160
- is the implementation a third-party adapter is read against. It enforces the key rule exactly,
161
- declares three of the four capabilities, and has no network in the way. The cases themselves are
160
+ [`@stowage/adapter-memory`](https://github.com/stowage-js/stowage/tree/@stowage/adapter-memory@0.2.0/packages/adapter-memory/src)
161
+ is the implementation a third-party adapter is read against, out of the four adapters of this
162
+ repository that pass these cases: `@stowage/adapter-memory`, `@stowage/adapter-fs`,
163
+ `@stowage/adapter-s3` and `@stowage/adapter-azure-blob`. It enforces the key rule exactly,
164
+ declares four of the five capabilities, and has no network in the way. The cases themselves are
162
165
  written against the behavior of S3, not against it.
163
166
 
164
167
  What the suite leaves to an adapter's own tests, such as flat memory during a large upload or the
165
168
  retry curve, is listed in
166
- [spec 8.4](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.1.0/docs/spec.md#84-what-the-suite-does-not-assert).
169
+ [spec 9.4](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.2.0/docs/spec.md#94-what-the-suite-does-not-assert).
167
170
 
168
171
  ## Runtimes
169
172
 
170
- Node 24 and later, Bun, Deno and `workerd` at the compatibility date `2026-09-01`. CI last ran green on Bun 1.4.2 and Deno 2.9.6.
173
+ Node 24 and later, Bun, Deno and `workerd` at the compatibility date `2026-09-01` without Node APIs. CI last ran green on Bun 1.4.2 and Deno 2.9.6.
171
174
 
172
- The bundle measures 10.7 kB minified and gzipped, `@stowage/core` included.
175
+ The bundle measures 11.6 kB minified and gzipped, `@stowage/core` included.
173
176
 
174
177
  ## Specification
175
178
 
176
- [`docs/spec.md` at `@stowage/conformance@0.1.0`](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.1.0/docs/spec.md#8-stowageconformance)
179
+ [`docs/spec.md` at `@stowage/conformance@0.2.0`](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.2.0/docs/spec.md#9-stowageconformance)
177
180
  is the contract: a caller may rely on what it states and on nothing else this package happens to
178
- export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.1.0/CONTEXT.md)
179
- and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/conformance@0.1.0/docs/adr)
181
+ export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/conformance@0.2.0/CONTEXT.md)
182
+ and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/conformance@0.2.0/docs/adr)
180
183
  are at the same tag.
181
184
 
182
185
  ## License
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { CapabilityName, Storage, StorageErrorCode } from "@stowage/core";
2
2
  //#region src/assertions.d.ts
3
3
  /**
4
4
  * Spec 4.9 leaves a call needing a capability the storage does not declare with an
5
- * `Unsupported` error naming it, and spec 8.2 has most of the `runWithout` halves assert
5
+ * `Unsupported` error naming it, and spec 9.2 has most of the `runWithout` halves assert
6
6
  * that through this.
7
7
  */
8
8
  export declare function expectUnsupported(call: () => Promise<unknown>, capability: CapabilityName): Promise<void>;
@@ -43,7 +43,7 @@ interface ConformanceCaseMetadata {
43
43
  }
44
44
  //#endregion
45
45
  //#region src/cases/index.d.ts
46
- /** The same cases as the package publishes them, without the factory of spec 8.3. */
46
+ /** The same cases as the package publishes them, without the factory of spec 9.3. */
47
47
  export declare const conformanceCases: readonly ConformanceCase[];
48
48
  //#endregion
49
49
  //#region src/result.d.ts
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ function assertDate(value, what) {
61
61
  assert(value instanceof Date && Number.isFinite(value.getTime()), `${what} is ${JSON.stringify(value)}, which is no \`Date\``);
62
62
  }
63
63
  /**
64
- * Two descriptions of one object, on the fields the row of spec 8.5 names. Spec 4.4
64
+ * Two descriptions of one object, on the fields the row of spec 9.5 names. Spec 4.4
65
65
  * leaves `lastModified` free to differ between a write and a later read by the
66
66
  * provider's rounding, so a row names its fields rather than the whole description.
67
67
  */
@@ -69,7 +69,7 @@ function assertSameDescription(one, other, fields, what) {
69
69
  for (const field of fields) assert(one[field] === other[field], `${what} report \`${field}\` as ${JSON.stringify(one[field])} and ${JSON.stringify(other[field])}`);
70
70
  }
71
71
  /**
72
- * A header of the answer a case read, which is how spec 8.5 and 8.6 have the two cases
72
+ * A header of the answer a case read, which is how spec 9.5 and 9.6 have the two cases
73
73
  * that leave the API — a signed URL and the `Response` of flow 4 — read a content type.
74
74
  */
75
75
  function assertHeader(response, name, expected) {
@@ -78,7 +78,7 @@ function assertHeader(response, name, expected) {
78
78
  }
79
79
  /**
80
80
  * The `StorageError` the call is expected to reject with, handed back so that a case can
81
- * read it for what its row of spec 8.5 states beyond the fields it named here. A case
81
+ * read it for what its row of spec 9.5 states beyond the fields it named here. A case
82
82
  * running the same call over a list of keys names the one it is at in `what`.
83
83
  */
84
84
  async function expectStorageError(call, expected, what) {
@@ -114,7 +114,7 @@ async function expectRuntimeError(call, name) {
114
114
  }
115
115
  /**
116
116
  * Spec 4.9 leaves a call needing a capability the storage does not declare with an
117
- * `Unsupported` error naming it, and spec 8.2 has most of the `runWithout` halves assert
117
+ * `Unsupported` error naming it, and spec 9.2 has most of the `runWithout` halves assert
118
118
  * that through this.
119
119
  */
120
120
  async function expectUnsupported(call, capability) {
@@ -138,7 +138,7 @@ async function rejectionOf(call, expectation) {
138
138
  //#endregion
139
139
  //#region src/cases/keys.ts
140
140
  const utf8$6 = new TextEncoder();
141
- /** Spec 8.7 measures a key in UTF-8 bytes, and `adapter-fs` bounds one segment at 255. */
141
+ /** Spec 9.7 measures a key in UTF-8 bytes, and `adapter-fs` bounds one segment at 255. */
142
142
  const segmentLimit = 255;
143
143
  /**
144
144
  * The characters spec 4.8 refuses anywhere in a key, at both ends of the range it names
@@ -150,6 +150,11 @@ const refusedControlCharacters = [
150
150
  31,
151
151
  127
152
152
  ];
153
+ /**
154
+ * A lone high and a lone low surrogate, which spec 4.8 refuses under every rule: a
155
+ * JavaScript string holds either, and neither is a Unicode character with a UTF-8 form.
156
+ */
157
+ const refusedLoneSurrogates = [55296, 56320];
153
158
  /** The byte length spec 4.8 allows a writable key, which two key lists sit on either side of. */
154
159
  const writableKeyLimit = 1024;
155
160
  /** The prefix one case writes below, which no other case reads or writes. */
@@ -161,7 +166,7 @@ function keyFor(ctx, caseName, name = "object") {
161
166
  }
162
167
  /**
163
168
  * A key of exactly `bytes` UTF-8 bytes below `prefix`, in segments of at most 255 bytes:
164
- * the boundary key of spec 8.2 and the over-long key of spec 8.7 are both built here, so
169
+ * the boundary key of spec 9.2 and the over-long key of spec 9.7 are both built here, so
165
170
  * that the run's prefix counts toward the length rather than being added to it.
166
171
  */
167
172
  function keyOfBytes(prefix, bytes) {
@@ -171,7 +176,7 @@ function keyOfBytes(prefix, bytes) {
171
176
  const characters = fill - (count - 1);
172
177
  return `${prefix}${Array.from({ length: count }, (_, index) => "k".repeat(Math.floor(characters / count) + (index < characters % count ? 1 : 0))).join("/")}`;
173
178
  }
174
- /** The accepted list of spec 8.7, below the prefix of the case that writes it. */
179
+ /** The accepted list of spec 9.7, below the prefix of the case that writes it. */
175
180
  function acceptedKeys(prefix) {
176
181
  return [
177
182
  ...acceptedNames.map((name) => ({
@@ -200,7 +205,7 @@ const acceptedNames = [
200
205
  "Grüße/日本語/ключ.txt"
201
206
  ];
202
207
  /**
203
- * The list of spec 8.7 that `put` refuses, below the prefix of the case wherever the
208
+ * The list of spec 9.7 that `put` refuses, below the prefix of the case wherever the
204
209
  * prefix leaves the violated rule as it is: a key that is about the start of a key is
205
210
  * given as the spec writes it, and every other one goes below the run's prefix, so that
206
211
  * a run against a shared bucket asks `exists` about its own key space alone.
@@ -247,11 +252,14 @@ function refusedWritableKeys(prefix) {
247
252
  key: `${prefix}a\\b`,
248
253
  existsAnswers: "false"
249
254
  },
250
- ...refusedControlCharacters.map((code) => ({
251
- label: `a key holding U+${code.toString(16).toUpperCase().padStart(4, "0")}`,
252
- key: `${prefix}a${String.fromCharCode(code)}b`,
253
- existsAnswers: "unasked"
254
- })),
255
+ ...[...refusedControlCharacters, ...refusedLoneSurrogates].map((code) => {
256
+ const { label, key } = keyHolding(prefix, code);
257
+ return {
258
+ label,
259
+ key,
260
+ existsAnswers: "unasked"
261
+ };
262
+ }),
255
263
  {
256
264
  label: "a key of 1025 bytes",
257
265
  key: keyOfBytes(prefix, 1025),
@@ -259,6 +267,46 @@ function refusedWritableKeys(prefix) {
259
267
  }
260
268
  ];
261
269
  }
270
+ /**
271
+ * The list of spec 9.7 that `get`, `stat` and `exists` refuse, placed as the refused
272
+ * writable list places its keys.
273
+ */
274
+ function refusedAddressableKeys(prefix) {
275
+ return [
276
+ {
277
+ label: "the empty string",
278
+ key: ""
279
+ },
280
+ {
281
+ label: "/a",
282
+ key: "/a"
283
+ },
284
+ {
285
+ label: "a//b",
286
+ key: `${prefix}a//b`
287
+ },
288
+ {
289
+ label: "./a",
290
+ key: `${prefix}./a`
291
+ },
292
+ {
293
+ label: "a/../b",
294
+ key: `${prefix}a/../b`
295
+ },
296
+ {
297
+ label: ".",
298
+ key: `${prefix}.`
299
+ },
300
+ keyHolding(prefix, 0),
301
+ ...refusedLoneSurrogates.map((code) => keyHolding(prefix, code))
302
+ ];
303
+ }
304
+ function keyHolding(prefix, code) {
305
+ return {
306
+ label: `a key holding U+${code.toString(16).toUpperCase().padStart(4, "0")}`,
307
+ key: `${prefix}a${String.fromCharCode(code)}b`
308
+ };
309
+ }
262
310
  //#endregion
263
311
  //#region src/cases/objects.ts
264
312
  /**
@@ -274,7 +322,7 @@ const textContentType = "text/plain";
274
322
  */
275
323
  const inFlight = 32;
276
324
  /**
277
- * What the `slow` cases of spec 8.5 write: one past the thousand objects a provider
325
+ * What the `slow` cases of spec 9.5 write: one past the thousand objects a provider
278
326
  * answers a listing with and takes in a delete at the most, so that the case reaches
279
327
  * past the one page and the one batch rather than filling them.
280
328
  */
@@ -311,6 +359,11 @@ async function assertNothingBelow(ctx, prefix) {
311
359
  const left = await collectEntries(ctx.storage.list({ prefix }));
312
360
  assert(left.length === 0, `${left.length} objects are left below ${JSON.stringify(prefix)}, the first of them ${JSON.stringify(left[0]?.key)}`);
313
361
  }
362
+ /** A report of spec 4.7 over keys the provider took: the count it covered, and no failure. */
363
+ function assertAccepted(report, requested, what) {
364
+ assert(report.requested === requested, `${what} reports \`requested: ${report.requested}\` and not ${requested}`);
365
+ assert(report.failed.length === 0, `${what} reports the failure ${JSON.stringify(report.failed[0]?.message)}`);
366
+ }
314
367
  //#endregion
315
368
  //#region src/cases/copy.ts
316
369
  const utf8$5 = new TextEncoder();
@@ -391,11 +444,11 @@ const copyAndMoveCases = [
391
444
  const to = `${prefix}destination.txt`;
392
445
  await ctx.storage.put(from, "the body to copy", {
393
446
  contentType: textContentType,
394
- userMetadata: { "Written-By": "stowage" }
447
+ userMetadata: { WrittenBy: "stowage" }
395
448
  });
396
449
  await ctx.storage.copy(from, to);
397
450
  const described = await ctx.storage.stat(to);
398
- assert(new Map(Object.entries(described.userMetadata).map(([name, value]) => [name.toLowerCase(), value])).get("written-by") === "stowage", `The destination carries the user metadata ${JSON.stringify(described.userMetadata)} and not the source's`);
451
+ assert(new Map(Object.entries(described.userMetadata).map(([name, value]) => [name.toLowerCase(), value])).get("writtenby") === "stowage", `The destination carries the user metadata ${JSON.stringify(described.userMetadata)} and not the source's`);
399
452
  },
400
453
  async runWithout(ctx) {
401
454
  const prefix = prefixFor(ctx, "copy/user-metadata");
@@ -480,18 +533,19 @@ function assertNonEmptyString(value, field) {
480
533
  //#endregion
481
534
  //#region src/cases/bytes.ts
482
535
  const kibibyte = 1024;
483
- /** The bytes a body is measured in, where a case names a size spec 8.5 or 8.6 states. */
536
+ /** The bytes a body is measured in, where a case names a size spec 9.5 or 9.6 states. */
484
537
  const mebibyte = 1024 * kibibyte;
485
538
  /** ADR 0016 makes one part the threshold for a stream, so 17 MiB provokes a multipart upload. */
486
539
  const multipartSize = 17 * mebibyte;
487
540
  /**
488
541
  * The bytes a case writes: a pattern it rebuilds rather than holds, so that a body the
489
542
  * provider reassembled out of order or lost a part of shows up as a mismatch, which one
490
- * of the same byte repeated would not.
543
+ * of the same byte repeated would not. Two patterns of different `seed` differ at every
544
+ * byte, so an object holding parts of both matches neither.
491
545
  */
492
- function patternOf(size) {
546
+ function patternOf(size, seed = 0) {
493
547
  const bytes = new Uint8Array(size);
494
- for (let index = 0; index < size; index += 1) bytes[index] = index * 7 % 251;
548
+ for (let index = 0; index < size; index += 1) bytes[index] = (index * 7 + seed) % 251;
495
549
  return bytes;
496
550
  }
497
551
  /** The same bytes as a stream, which is the body spec 4.2 has an adapter send in parts. */
@@ -507,6 +561,44 @@ function streamOf(bytes, chunkSize, onChunk) {
507
561
  onChunk?.(Math.min(sent, bytes.byteLength));
508
562
  } });
509
563
  }
564
+ /**
565
+ * The bodies of `put/concurrent-writers` as streams that each hold back their end until
566
+ * every one was read to its last byte. An adapter cannot complete an upload before its
567
+ * stream ends, so before either completes, each writer has read every byte and started
568
+ * every full part, whatever the part size, and the pacing names no adapter. Whether a
569
+ * started part was answered yet is the adapter's timing, which no stream can see.
570
+ */
571
+ function streamsEndingTogether(bodies, chunkSize) {
572
+ const { promise: everyEndReached, resolve } = Promise.withResolvers();
573
+ let unended = bodies.length;
574
+ return bodies.map((bytes) => {
575
+ let sent = 0;
576
+ let reachedEnd = false;
577
+ let canceled = false;
578
+ const reachEnd = () => {
579
+ if (reachedEnd) return;
580
+ reachedEnd = true;
581
+ unended -= 1;
582
+ if (unended === 0) resolve();
583
+ };
584
+ return new ReadableStream({
585
+ async pull(controller) {
586
+ if (sent < bytes.byteLength) {
587
+ controller.enqueue(bytes.subarray(sent, Math.min(sent + chunkSize, bytes.byteLength)));
588
+ sent += chunkSize;
589
+ return;
590
+ }
591
+ reachEnd();
592
+ await everyEndReached;
593
+ if (!canceled) controller.close();
594
+ },
595
+ cancel() {
596
+ canceled = true;
597
+ reachEnd();
598
+ }
599
+ }, { highWaterMark: 0 });
600
+ });
601
+ }
510
602
  async function collect(stream) {
511
603
  const chunks = [];
512
604
  let size = 0;
@@ -642,11 +734,6 @@ const deleteCases = [
642
734
  }
643
735
  }
644
736
  ];
645
- /** A report of spec 4.7 over keys the provider took: the count it covered, and no failure. */
646
- function assertAccepted(report, requested, what) {
647
- assert(report.requested === requested, `${what} reports \`requested: ${report.requested}\` and not ${requested}`);
648
- assert(report.failed.length === 0, `${what} reports the failure ${JSON.stringify(report.failed[0]?.message)}`);
649
- }
650
737
  //#endregion
651
738
  //#region src/cases/errors.ts
652
739
  /**
@@ -697,11 +784,11 @@ const errorCases = [
697
784
  async run(ctx) {
698
785
  const prefix = prefixFor(ctx, "errors/bad-credentials");
699
786
  const storage = await storageFrom(ctx, "createStorageWithBadCredentials");
700
- await expectStorageError(() => storage.get(`${prefix}object`), {
787
+ const refused = await expectStorageError(() => storage.get(`${prefix}object`), {
701
788
  code: "InvalidCredentials",
702
- retryable: false,
703
- attempts: 1
789
+ retryable: false
704
790
  });
791
+ assert(refused.attempts === 1 || refused.attempts === 2, `Expected \`InvalidCredentials\` from \`get\`, and the error carries \`attempts: ${refused.attempts}\` rather than 1 or 2`);
705
792
  await expectAnyStorageError(() => storage.exists(`${prefix}object`), "`exists` under a credential the provider refuses");
706
793
  await expectAnyStorageError(() => storage.list({ prefix }).page(), "`list` under a credential the provider refuses");
707
794
  }
@@ -787,7 +874,7 @@ function assertField(thrown, field, expected, failure) {
787
874
  const held = thrown[field];
788
875
  assert(held === expected, `The error for ${failure.what} reports \`${field}: ${JSON.stringify(held)}\` and not ${JSON.stringify(expected)}`);
789
876
  }
790
- /** The storage of a credential factory, which spec 8.2 has kept the case out of a run without. */
877
+ /** The storage of a credential factory, which spec 9.2 has kept the case out of a run without. */
791
878
  async function storageFrom(ctx, factory) {
792
879
  const create = ctx.target[factory];
793
880
  assert(create !== void 0, `The target supplies no \`${factory}\``);
@@ -820,7 +907,7 @@ const existsCases = [{
820
907
  //#endregion
821
908
  //#region src/cases/presign.ts
822
909
  const utf8$4 = new TextEncoder();
823
- /** Past the second `presign/expired-url` signs for, which is what spec 8.5 waits out. */
910
+ /** Past the second `presign/expired-url` signs for, which is what spec 9.5 waits out. */
824
911
  const pastTheLifetime = 2e3;
825
912
  /** The seconds spec 7.10 allows `expiresIn`, which the two refused values sit outside. */
826
913
  const refusedLifetimes = [0, 604801];
@@ -834,7 +921,7 @@ const presignCases = [
834
921
  const key = keyFor(ctx, "presign/get", "object.txt");
835
922
  const body = "the body a signed `GET` hands out";
836
923
  await ctx.storage.put(key, body, { contentType: textContentType });
837
- const response = await fetch(await presignedUrl(ctx, "presignGet", key, { expiresIn: 300 }));
924
+ const response = await fetch(await presignedGet(ctx, key, { expiresIn: 300 }));
838
925
  assertStatus(response.status, 200, "`fetch` on a signed `GET`");
839
926
  assertHeader(response, "content-type", textContentType);
840
927
  assertSameBytes(new Uint8Array(await response.arrayBuffer()), utf8$4.encode(body), "the body a signed `GET` answered with");
@@ -848,7 +935,7 @@ const presignCases = [
848
935
  async run(ctx) {
849
936
  const key = keyFor(ctx, "presign/put", "object.txt");
850
937
  const body = utf8$4.encode("the body a signed `PUT` takes");
851
- const url = await presignedUrl(ctx, "presignPut", key, {
938
+ const { url, headers } = await presignedPut(ctx, key, {
852
939
  expiresIn: 300,
853
940
  contentType: textContentType,
854
941
  contentLength: body.byteLength
@@ -856,7 +943,7 @@ const presignCases = [
856
943
  const response = await fetch(url, {
857
944
  method: "PUT",
858
945
  body,
859
- headers: { "content-type": textContentType }
946
+ headers
860
947
  });
861
948
  assert(response.ok, `\`fetch\` on a signed \`PUT\` answered ${response.status} and not a success`);
862
949
  await response.arrayBuffer();
@@ -884,15 +971,17 @@ const presignCases = [
884
971
  async run(ctx) {
885
972
  const key = keyFor(ctx, "presign/put-rejects-type", "object.txt");
886
973
  const body = utf8$4.encode("a body of another type than the signature binds");
887
- const url = await presignedUrl(ctx, "presignPut", key, {
974
+ const presigned = await presignedPut(ctx, key, {
888
975
  expiresIn: 300,
889
976
  contentType: textContentType,
890
977
  contentLength: body.byteLength
891
978
  });
892
- assertStatus(await statusOf(await fetch(url, {
979
+ const headers = new Headers(presigned.headers);
980
+ headers.set("content-type", "application/json");
981
+ assertStatus(await statusOf(await fetch(presigned.url, {
893
982
  method: "PUT",
894
983
  body,
895
- headers: { "content-type": "application/json" }
984
+ headers
896
985
  })), 403, "a signed `PUT` carrying another content type");
897
986
  },
898
987
  runWithout: assertNeitherMethod
@@ -904,7 +993,7 @@ const presignCases = [
904
993
  async run(ctx) {
905
994
  const key = keyFor(ctx, "presign/put-rejects-length", "object.txt");
906
995
  const body = utf8$4.encode("a body longer than the signature binds");
907
- const url = await presignedUrl(ctx, "presignPut", key, {
996
+ const { url, headers } = await presignedPut(ctx, key, {
908
997
  expiresIn: 300,
909
998
  contentType: textContentType,
910
999
  contentLength: body.byteLength - 1
@@ -912,7 +1001,7 @@ const presignCases = [
912
1001
  const status = await statusOf(await fetch(url, {
913
1002
  method: "PUT",
914
1003
  body,
915
- headers: { "content-type": textContentType }
1004
+ headers
916
1005
  }));
917
1006
  assert(status >= 400 && status < 500, `A signed \`PUT\` carrying another length was answered ${status} and not refused`);
918
1007
  },
@@ -925,7 +1014,7 @@ const presignCases = [
925
1014
  async run(ctx) {
926
1015
  const key = keyFor(ctx, "presign/expired-url", "object.txt");
927
1016
  await ctx.storage.put(key, "the body the URL stops handing out", { contentType: textContentType });
928
- const url = await presignedUrl(ctx, "presignGet", key, { expiresIn: 1 });
1017
+ const url = await presignedGet(ctx, key, { expiresIn: 1 });
929
1018
  await delay(pastTheLifetime);
930
1019
  assertStatus(await statusOf(await fetch(url)), 403, "a signed `GET` that has expired");
931
1020
  },
@@ -940,12 +1029,41 @@ const presignCases = [
940
1029
  async function assertNeitherMethod(ctx) {
941
1030
  for (const name of presignNames) assert(!(name in ctx.storage), `The storage declares no \`presignedUrls\` and carries \`${name}\``);
942
1031
  }
943
- /** The URL the method handed back, which a target may report as anything at runtime. */
944
- async function presignedUrl(ctx, name, key, options) {
945
- const url = await presignerOf(ctx.storage, name)(key, options);
946
- assert(typeof url === "string" && url !== "", `\`${name}\` handed back ${JSON.stringify(url)} and not a URL`);
1032
+ /** The URL `presignGet` handed back, which a target may report as anything at runtime. */
1033
+ async function presignedGet(ctx, key, options) {
1034
+ const url = await presignerOf(ctx.storage, "presignGet")(key, options);
1035
+ assert(isUrl(url), `\`presignGet\` handed back ${JSON.stringify(url)} and not a URL`);
947
1036
  return url;
948
1037
  }
1038
+ /**
1039
+ * What `presignPut` handed back, which a target may report as anything at runtime. The
1040
+ * upload sends its headers as they are, so the suite names no provider (spec 4.13).
1041
+ */
1042
+ async function presignedPut(ctx, key, options) {
1043
+ const presigned = await presignerOf(ctx.storage, "presignPut")(key, options);
1044
+ assert(isPresignedPut(presigned), `\`presignPut\` handed back ${JSON.stringify(presigned)} and not a URL with the headers that go beside the body`);
1045
+ return presigned;
1046
+ }
1047
+ function isUrl(value) {
1048
+ return typeof value === "string" && value !== "";
1049
+ }
1050
+ /** The shape spec 4.13 gives `PresignedPut`, its rule on `Content-Length` included. */
1051
+ function isPresignedPut(value) {
1052
+ if (typeof value !== "object" || value === null) return false;
1053
+ const url = Reflect.get(value, "url");
1054
+ const headers = Reflect.get(value, "headers");
1055
+ if (!isUrl(url) || !isPlainObject(headers)) return false;
1056
+ return Object.entries(headers).every(([name, field]) => typeof field === "string" && name.toLowerCase() !== "content-length");
1057
+ }
1058
+ /**
1059
+ * An array or a `Headers` would pass the entries check above: the one lists its indices,
1060
+ * the other lists nothing at all.
1061
+ */
1062
+ function isPlainObject(value) {
1063
+ if (typeof value !== "object" || value === null) return false;
1064
+ const prototype = Object.getPrototypeOf(value);
1065
+ return prototype === Object.prototype || prototype === null;
1066
+ }
949
1067
  function presignerOf(storage, name) {
950
1068
  const held = Reflect.get(storage, name);
951
1069
  assert(typeof held === "function", `The storage declares \`presignedUrls\` and carries no \`${name}\``);
@@ -1023,7 +1141,7 @@ const flowCases = [
1023
1141
  async run(ctx) {
1024
1142
  const key = keyFor(ctx, "flow/2-presigned-put", "upload.txt");
1025
1143
  const body = utf8$3.encode("the body a browser uploads to the provider");
1026
- const url = await presignedUrl(ctx, "presignPut", key, {
1144
+ const { url, headers } = await presignedPut(ctx, key, {
1027
1145
  expiresIn: 300,
1028
1146
  contentType: textContentType,
1029
1147
  contentLength: body.byteLength
@@ -1031,7 +1149,7 @@ const flowCases = [
1031
1149
  const response = await fetch(url, {
1032
1150
  method: "PUT",
1033
1151
  body,
1034
- headers: { "content-type": textContentType }
1152
+ headers
1035
1153
  });
1036
1154
  assert(response.ok, `The upload to the presigned URL was answered ${response.status}`);
1037
1155
  await response.arrayBuffer();
@@ -1238,6 +1356,23 @@ const getCases = [
1238
1356
  await Promise.all(absent.map(async ({ label, key }) => await expectStorageError(() => ctx.storage.get(key), { code: "NotFound" }, label)));
1239
1357
  }
1240
1358
  },
1359
+ {
1360
+ name: "get/refused-keys",
1361
+ requires: [],
1362
+ cost: "fast",
1363
+ async run(ctx) {
1364
+ const refused = refusedAddressableKeys(prefixFor(ctx, "get/refused-keys"));
1365
+ const reads = [
1366
+ ["get", (key) => ctx.storage.get(key)],
1367
+ ["stat", (key) => ctx.storage.stat(key)],
1368
+ ["exists", (key) => ctx.storage.exists(key)]
1369
+ ];
1370
+ await Promise.all(refused.flatMap(({ label, key }) => reads.map(async ([operation, read]) => await expectStorageError(() => read(key), {
1371
+ code: "InvalidKey",
1372
+ attempts: 0
1373
+ }, `\`${operation}\` of ${label}`))));
1374
+ }
1375
+ },
1241
1376
  {
1242
1377
  name: "get/aborted-signal",
1243
1378
  requires: [],
@@ -1539,6 +1674,23 @@ const listCases = [
1539
1674
  assert(listed?.normalize() === key.normalize(), `The listing names the ${form} key as ${JSON.stringify(listed)}, which is no Unicode equivalent of ${JSON.stringify(key)}`);
1540
1675
  }));
1541
1676
  }
1677
+ },
1678
+ {
1679
+ name: "list/noncharacter-key",
1680
+ requires: [],
1681
+ cost: "fast",
1682
+ async run(ctx) {
1683
+ const prefix = prefixFor(ctx, "list/noncharacter-key");
1684
+ const key = `${prefix}noncharacter-${String.fromCodePoint(65534)}.txt`;
1685
+ const body = patternOf(16);
1686
+ await ctx.storage.put(key, body);
1687
+ assertNamesEachOnce((await collectEntries(ctx.storage.list({ prefix }))).map((entry) => entry.key), [key], "The iteration below the prefix");
1688
+ const stored = await ctx.storage.get(key);
1689
+ assert(stored.stat.key === key, `\`get\` describes the object as ${JSON.stringify(stored.stat.key)} and not as the key it was asked for`);
1690
+ assertSameBytes(await stored.bytes(), body, "the object under the key holding U+FFFE");
1691
+ assertAccepted(await ctx.storage.delete(key), 1, "Deleting the key holding U+FFFE");
1692
+ await assertNothingBelow(ctx, prefix);
1693
+ }
1542
1694
  }
1543
1695
  ];
1544
1696
  /**
@@ -1561,6 +1713,10 @@ function assertNamesTheOption(message, option) {
1561
1713
  const utf8$1 = new TextEncoder();
1562
1714
  /** Spec 4.3 takes an ASCII HTTP token as a user metadata key, which this is not. */
1563
1715
  const metadataKeyAboveAscii = { grüße: "hallo" };
1716
+ /** No Unicode character, so no UTF-8 form for the 2 KB of spec 4.3 to measure. */
1717
+ const metadataValueWithLoneSurrogate = { note: "lone-\ud800" };
1718
+ /** An ASCII HTTP token and no identifier, which spec 4.9 promises under `userMetadataTokenKeys`. */
1719
+ const metadataTokenKey = { "content-hash": "sha256-conformance" };
1564
1720
  /** Over the 2 KB of encoded header bytes spec 4.3 allows a whole user metadata set. */
1565
1721
  const metadataOverTheLimit = { note: "x".repeat(2 * kibibyte) };
1566
1722
  const putCases = [
@@ -1761,7 +1917,7 @@ const putCases = [
1761
1917
  async run(ctx) {
1762
1918
  const key = keyFor(ctx, "put/user-metadata");
1763
1919
  const userMetadata = {
1764
- "Written-By": "stowage",
1920
+ WrittenBy: "stowage",
1765
1921
  run: "conformance"
1766
1922
  };
1767
1923
  await ctx.storage.put(key, patternOf(16), { userMetadata });
@@ -1771,7 +1927,7 @@ const putCases = [
1771
1927
  async runWithout(ctx) {
1772
1928
  const refused = keyFor(ctx, "put/user-metadata", "refused");
1773
1929
  const empty = keyFor(ctx, "put/user-metadata", "empty");
1774
- await expectUnsupported(() => ctx.storage.put(refused, patternOf(16), { userMetadata: { "Written-By": "stowage" } }), "userMetadata");
1930
+ await expectUnsupported(() => ctx.storage.put(refused, patternOf(16), { userMetadata: { WrittenBy: "stowage" } }), "userMetadata");
1775
1931
  await ctx.storage.put(empty, patternOf(16), { userMetadata: {} });
1776
1932
  assertNoMetadata((await ctx.storage.stat(empty)).userMetadata, "`stat`");
1777
1933
  assertNoMetadata((await ctx.storage.get(empty)).stat.userMetadata, "`get`");
@@ -1788,6 +1944,10 @@ const putCases = [
1788
1944
  code: "InvalidRequest",
1789
1945
  attempts: 0
1790
1946
  }, "a user metadata key above ASCII");
1947
+ await expectStorageError(() => ctx.storage.put(key, bytes, { userMetadata: metadataValueWithLoneSurrogate }), {
1948
+ code: "InvalidRequest",
1949
+ attempts: 0
1950
+ }, "a user metadata value holding a lone surrogate");
1791
1951
  await expectStorageError(() => ctx.storage.put(key, bytes, { userMetadata: metadataOverTheLimit }), {
1792
1952
  code: "InvalidRequest",
1793
1953
  attempts: 0
@@ -1797,10 +1957,50 @@ const putCases = [
1797
1957
  const key = keyFor(ctx, "put/user-metadata-limits");
1798
1958
  const bytes = patternOf(16);
1799
1959
  await expectUnsupported(() => ctx.storage.put(key, bytes, { userMetadata: metadataKeyAboveAscii }), "userMetadata");
1960
+ await expectUnsupported(() => ctx.storage.put(key, bytes, { userMetadata: metadataValueWithLoneSurrogate }), "userMetadata");
1800
1961
  await expectUnsupported(() => ctx.storage.put(key, bytes, { userMetadata: metadataOverTheLimit }), "userMetadata");
1801
1962
  }
1963
+ },
1964
+ {
1965
+ name: "put/user-metadata-token-keys",
1966
+ requires: ["userMetadata", "userMetadataTokenKeys"],
1967
+ cost: "fast",
1968
+ async run(ctx) {
1969
+ const key = keyFor(ctx, "put/user-metadata-token-keys");
1970
+ await ctx.storage.put(key, patternOf(16), { userMetadata: metadataTokenKey });
1971
+ assertHoldsMetadata((await ctx.storage.stat(key)).userMetadata, metadataTokenKey, "`stat`");
1972
+ assertHoldsMetadata((await ctx.storage.get(key)).stat.userMetadata, metadataTokenKey, "`get`");
1973
+ },
1974
+ async runWithout(ctx) {
1975
+ const key = keyFor(ctx, "put/user-metadata-token-keys");
1976
+ await expectStorageError(() => ctx.storage.put(key, patternOf(16), { userMetadata: metadataTokenKey }), {
1977
+ code: "Unsupported",
1978
+ attempts: 0,
1979
+ capability: ctx.declares("userMetadata") ? "userMetadataTokenKeys" : "userMetadata"
1980
+ }, "a user metadata key beyond identifiers");
1981
+ }
1982
+ },
1983
+ {
1984
+ name: "put/concurrent-writers",
1985
+ requires: [],
1986
+ cost: "fast",
1987
+ async run(ctx) {
1988
+ const key = keyFor(ctx, "put/concurrent-writers");
1989
+ const patterns = [patternOf(multipartSize), patternOf(multipartSize, 1)];
1990
+ const bodies = streamsEndingTogether(patterns, mebibyte);
1991
+ const outcomes = await Promise.allSettled(bodies.map((body) => ctx.storage.put(key, body)));
1992
+ assert(outcomes.some((outcome) => outcome.status === "fulfilled"), `Both writers rejected: ${outcomes.map(describeOutcome).join("; ")}`);
1993
+ const held = await collect((await ctx.storage.get(key)).stream());
1994
+ assert(patterns.some((pattern) => sameBytes(held, pattern)), `The key holds ${held.byteLength} bytes that are neither writer's object whole`);
1995
+ }
1802
1996
  }
1803
1997
  ];
1998
+ function describeOutcome(outcome) {
1999
+ return outcome.status === "fulfilled" ? "resolved" : String(outcome.reason);
2000
+ }
2001
+ function sameBytes(one, other) {
2002
+ return one.byteLength === other.byteLength && one.every((byte, index) => byte === other[index]);
2003
+ }
1804
2004
  function assertContentType(described, expected, where) {
1805
2005
  assert(described.contentType === expected, `${where} reports the content type ${JSON.stringify(described.contentType)} and not ${JSON.stringify(expected)}`);
1806
2006
  }
@@ -1878,11 +2078,11 @@ const conformanceCaseSources = [
1878
2078
  ...presignCases,
1879
2079
  ...flowCases
1880
2080
  ];
1881
- /** The same cases as the package publishes them, without the factory of spec 8.3. */
2081
+ /** The same cases as the package publishes them, without the factory of spec 9.3. */
1882
2082
  const conformanceCases = conformanceCaseSources;
1883
2083
  //#endregion
1884
2084
  //#region src/run.ts
1885
- /** The cases a run performs: the `fast` tier alone unless it asks for both (spec 8.2). */
2085
+ /** The cases a run performs: the `fast` tier alone unless it asks for both (spec 9.2). */
1886
2086
  function selectedCases(options) {
1887
2087
  if (options?.includeSlow === true) return conformanceCaseSources;
1888
2088
  return conformanceCaseSources.filter((source) => source.cost === "fast");
@@ -1892,7 +2092,7 @@ function createKeyPrefix() {
1892
2092
  return `${keyPrefixRoot}/${crypto.randomUUID()}/`;
1893
2093
  }
1894
2094
  /**
1895
- * Spec 8.2: the declaration is read once per run, before the first case, because every
2095
+ * Spec 9.2: the declaration is read once per run, before the first case, because every
1896
2096
  * storage the target creates in one run declares the same.
1897
2097
  */
1898
2098
  async function startRun(target, keyPrefix) {
@@ -1906,7 +2106,7 @@ async function startRun(target, keyPrefix) {
1906
2106
  };
1907
2107
  }
1908
2108
  /**
1909
- * Spec 8.2: the default deletes below the prefix on a storage of its own, so that a run
2109
+ * Spec 9.2: the default deletes below the prefix on a storage of its own, so that a run
1910
2110
  * whose storage broke on the way still clears what it wrote.
1911
2111
  */
1912
2112
  async function cleanUp(target, keyPrefix) {
@@ -1914,13 +2114,13 @@ async function cleanUp(target, keyPrefix) {
1914
2114
  const [failure] = (await (await target.createStorage()).deleteAll(keyPrefix)).failed;
1915
2115
  if (failure !== void 0) throw failure;
1916
2116
  }
1917
- /** The factory the case needs and the target left out, which spec 8.2 skips it for. */
2117
+ /** The factory the case needs and the target left out, which spec 9.2 skips it for. */
1918
2118
  function skipReasonFor(source, target) {
1919
2119
  if (source.factory === void 0 || target[source.factory] !== void 0) return void 0;
1920
2120
  return source.factory;
1921
2121
  }
1922
2122
  /**
1923
- * Spec 8.2: a case whose requirements are all declared runs `run`, and one missing a
2123
+ * Spec 9.2: a case whose requirements are all declared runs `run`, and one missing a
1924
2124
  * name runs `runWithout`.
1925
2125
  */
1926
2126
  function selectHalf(source, context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stowage/conformance",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The cases every stowage adapter has to pass, as plain cases a test framework maps onto its own runner.",
5
5
  "keywords": [
6
6
  "adapter",
@@ -33,7 +33,7 @@
33
33
  "provenance": true
34
34
  },
35
35
  "dependencies": {
36
- "@stowage/core": "^0.1.0"
36
+ "@stowage/core": "^0.2.0"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=24"