@filefreak.io/sdk 0.4.0 → 0.5.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
@@ -33,7 +33,9 @@ const bytes = await ff.bytes(file.id); // for a file small enough to hold in mem
33
33
  than memory is fine. `toPath` writes beside the file and gives it its name
34
34
  only once every byte is there: a download that fails leaves nothing behind.
35
35
  A file under Shard Protection has no address, and `download` and `bytes`
36
- read it through its map instead, each fragment checked as it arrives.
36
+ read it through its map instead, each fragment checked as it arrives. One
37
+ written in version 2 of the shard format, in blocks cut by its content, is
38
+ read the same way, its outline checked before any block is fetched.
37
39
  Into a folder under Shard Protection, `upload` seals each part with the
38
40
  file's own key before it is sent, so none of the file is ever plain in
39
41
  storage. The key is asked of the API for the upload and kept nowhere, and
package/dist/client.js CHANGED
@@ -6,6 +6,7 @@ import { bind, OPERATIONS, } from "./generated.js";
6
6
  import { sendFrame } from "./frames.js";
7
7
  import { fetchRanges } from "./ranges.js";
8
8
  import { asItIsKept, readProtected } from "./reader.js";
9
+ import { READS_FORMATS } from "./shards.js";
9
10
  import { FileFreakError, sleep, Transport } from "./transport.js";
10
11
  import { upload } from "./uploads.js";
11
12
  /** How long a follow of the changes waits for one before asking again, unless told. */
@@ -139,7 +140,7 @@ export class FileFreak {
139
140
  const { url } = await this.files.downloadUrl(item.id, {}, { signal });
140
141
  await fetchRanges({ url, sizeBytes: item.sizeBytes, sink, signal, progress, ranges: options.ranges, fetch: this.transport.fetch, sleep: (ms) => sleep(ms, signal) });
141
142
  }, () => readProtected({
142
- pages: (from, pageSignal) => this.files.shards(item.id, { from }, { signal: pageSignal }),
143
+ pages: (from, pageSignal) => this.files.shards(item.id, { from, formats: READS_FORMATS }, { signal: pageSignal }),
143
144
  sink,
144
145
  sizeBytes: item.sizeBytes,
145
146
  signal,
@@ -157,11 +157,13 @@ export interface SendTo {
157
157
  }
158
158
  /** A page of a protected file's map: its key, and where each fragment is. */
159
159
  export interface ShardMap {
160
- /** The content's own id, which each fragment's check is bound to with its place and the count. */
160
+ /** The content's own id, which each fragment's check is bound to with its place and the count, and in version 2 the outline. */
161
161
  contentId: string;
162
+ /** The version of the shard format the file is written in: 1, or 2, in which the file is cut into blocks by its content and its next version keeps the blocks that did not change. Only a version named in formats is answered. */
163
+ format: number;
162
164
  /** The file's size. */
163
165
  sizeBytes: number;
164
- /** How many bytes of the file each fragment holds, except the last, which holds what remains. */
166
+ /** How many bytes of the file each fragment holds, except the last, which holds what remains. In version 2, the largest block's: each block's own length is in the outline. */
165
167
  fragmentBytes: number;
166
168
  /** How many fragments the file is. */
167
169
  count: number;
@@ -171,11 +173,23 @@ export interface ShardMap {
171
173
  contentType: string | null;
172
174
  /** The file's revision whose content this is. Its fragments never change, however late they are read. */
173
175
  revision: number;
176
+ /** Version 2, on the first page: every block of the file and the MAC over them. Null on a later page, and in version 1. */
177
+ outline: {
178
+ /** The MAC over the content id, the size and every block's id and length, in base64. Check it with the file's key, as the SDK's checkOutline does, before using a byte of the file. */
179
+ mac: string;
180
+ /** Every block of the file, in order of place. */
181
+ blocks: Array<{
182
+ /** The block's id in base64: 32 bytes, the HMAC of its bytes under the file's key. Each block is checked against it once opened. */
183
+ id: string;
184
+ /** How many bytes of the file the block holds. */
185
+ length: number;
186
+ }>;
187
+ } | null;
174
188
  /** The first place on this page. */
175
189
  from: number;
176
190
  /** Up to 1000, in order of place. */
177
191
  fragments: Array<{
178
- /** Counted from zero. Its bytes of the file begin at place times fragmentBytes. */
192
+ /** Counted from zero. In version 1 its bytes of the file begin at place times fragmentBytes; in version 2 after the blocks before it in the outline. */
179
193
  place: number;
180
194
  /** Where to fetch the stored fragment, with a plain GET and no key: the address is the permission. */
181
195
  url: string;
@@ -314,6 +328,8 @@ export type UploadsBeginBody = {
314
328
  }>;
315
329
  /** true when this program can seal fragments as it sends them, as the SDK and the CLI do. Into a protected place the answer's shards is then set, and each part is a fragment. */
316
330
  protectable?: boolean;
331
+ /** With protectable: the versions of the shard format this program can write. Left out, 1 alone. With 2, a sealed file of more than 16 MiB is written in version 2. */
332
+ shardFormats?: Array<number>;
317
333
  };
318
334
  /** What POST /v1/uploads answers with. */
319
335
  export type UploadsBeginAnswer = {
@@ -324,11 +340,13 @@ export type UploadsBeginAnswer = {
324
340
  reused: Array<number>;
325
341
  /** Set when the upload is sealed as it is sent: how the file is cut into fragments. The key to seal them with is given by uploads.get. Null for an upload sent as it is. */
326
342
  shards: {
327
- /** The content's own id, which each fragment's seal is bound to with its place and the count. */
343
+ /** The version of the shard format to write it in: 1, or 2 when shardFormats names it and the file is large enough. */
344
+ format: number;
345
+ /** The content's own id, which each fragment's seal is bound to with its place and the count, and in version 2 the outline. */
328
346
  contentId: string;
329
- /** How many bytes of the file each fragment holds, except the last, which holds what remains. */
347
+ /** How many bytes of the file each fragment holds, except the last, which holds what remains. In version 2, 0: the blocks are declared with uploads.blocks. */
330
348
  fragmentBytes: number;
331
- /** How many fragments, one to each part. */
349
+ /** How many fragments, one to each part. In version 2, 0 until the blocks are declared. */
332
350
  count: number;
333
351
  } | null;
334
352
  };
@@ -346,16 +364,42 @@ export type UploadsGetAnswer = {
346
364
  reused: Array<number>;
347
365
  /** For an upload sealed as it is sent: how the file is cut, and the key to seal each fragment with. Null for an upload sent as it is, and for one that has ended. */
348
366
  shards: {
349
- /** The content's own id, which each fragment's seal is bound to with its place and the count. */
367
+ /** The version of the shard format to write it in. */
368
+ format: number;
369
+ /** The content's own id, which each fragment's seal is bound to with its place and the count, and in version 2 the outline. */
350
370
  contentId: string;
351
371
  /** The file's own key, in base64: 32 bytes, which open its fragments. In this answer and nowhere else. */
352
372
  key: string;
353
- /** How many bytes of the file each fragment holds, except the last, which holds what remains. */
373
+ /** How many bytes of the file each fragment holds, except the last, which holds what remains. In version 2, the largest block's once they are declared, and 0 before. */
354
374
  fragmentBytes: number;
355
- /** How many fragments, one to each part. */
375
+ /** How many fragments, one to each part. In version 2, how many blocks once they are declared, and 0 before. */
356
376
  count: number;
377
+ /** Version 2: the blocks as declared, in order, or null until they are. Null in version 1. */
378
+ blocks: Array<{
379
+ size: number;
380
+ /** Its id, in base64. */
381
+ id: string;
382
+ }> | null;
357
383
  } | null;
358
384
  };
385
+ /** What POST /v1/uploads/:id/blocks takes as its body. */
386
+ export type UploadsBlocksBody = {
387
+ /** Every block of the file, in order. */
388
+ blocks: Array<{
389
+ /** How many bytes of the file the block holds. */
390
+ size: number;
391
+ /** The block's id, in base64: 32 bytes. */
392
+ id: string;
393
+ }>;
394
+ };
395
+ /** What POST /v1/uploads/:id/blocks answers with. */
396
+ export type UploadsBlocksAnswer = {
397
+ upload: Upload;
398
+ /** The places whose block the file has already, under its key. Never sent, and counted as arrived. */
399
+ reused: Array<number>;
400
+ /** Addresses for the first blocks to send, each at its place. Places in reused are not among them. */
401
+ parts: Array<Part>;
402
+ };
359
403
  /** What GET /v1/uploads/:id/parts takes in its query. */
360
404
  export type UploadsPartsQuery = {
361
405
  /** The first part wanted, counted from zero. */
@@ -384,6 +428,8 @@ export type UploadsFragmentsAnswer = {
384
428
  export type UploadsCompleteBody = {
385
429
  /** The file's SHA-256 in hex, as you worked it out. Kept with the file as your word, since this service never sees the bytes, and given back as clientSha256, so a copy can be checked later without reading the file again. */
386
430
  sha256?: string;
431
+ /** For an upload written in version 2 of the shard format, and required for one: the outline's MAC in base64, as the SDK's outlineMac makes it from the content id, the size and the declared blocks. Checked with the file's key. */
432
+ outline?: string;
387
433
  };
388
434
  /** What POST /v1/uploads/:id/complete answers with. */
389
435
  export type UploadsCompleteAnswer = {
@@ -417,6 +463,8 @@ export type FilesDownloadUrlAnswer = {
417
463
  export type FilesShardsQuery = {
418
464
  /** The first fragment wanted, counted from zero: 0, or the `next` of the page before. */
419
465
  from?: number;
466
+ /** The versions of the shard format this program reads, as 1,2. Left out, 1 alone. */
467
+ formats?: string;
420
468
  };
421
469
  /** What GET /v1/files/:id/shards answers with. */
422
470
  export type FilesShardsAnswer = ShardMap;
@@ -689,6 +737,12 @@ export declare const OPERATIONS: {
689
737
  readonly idempotent: false;
690
738
  readonly repeatable: false;
691
739
  };
740
+ readonly "uploads.blocks": {
741
+ readonly method: "POST";
742
+ readonly path: "/v1/uploads/:id/blocks";
743
+ readonly idempotent: false;
744
+ readonly repeatable: true;
745
+ };
692
746
  readonly "uploads.parts": {
693
747
  readonly method: "GET";
694
748
  readonly path: "/v1/uploads/:id/parts";
@@ -910,6 +964,8 @@ export declare function bind(call: Call): {
910
964
  *
911
965
  * A file under Shard Protection is kept as fragments, each sealed with the file's own key and stored across several storage accounts. Its map says where each fragment is, up to 1,000 at a time, with the key. Fetch each fragment from its address (no key: the address is the permission), check and open it as the SDK's openFragment does, and write it at its place. The first page counts the file's size toward the month's downloads; later pages count nothing. Addresses work for four hours: before then, ask for the page again from the first fragment not yet fetched. The key is in this answer and nowhere else. This API never keeps it, and neither should a program, beyond the read.
912
966
  *
967
+ * A file in version 2 of the shard format (format 2) is kept as blocks of their own lengths, cut by its content. The first page carries its outline: check it with the key before using a byte, open each block as the SDK's openBlock does, and write it after the blocks before it. A program says which versions it reads with formats, and a file in another is answered with 409 format_unsupported, so a program made before version 2 is told to update rather than failing on the file.
968
+ *
913
969
  * GET /v1/files/:id/shards. Needs the scope files:read.
914
970
  */
915
971
  shards: (id: string, query?: FilesShardsQuery, options?: CallOptions) => Promise<FilesShardsAnswer>;
@@ -966,6 +1022,8 @@ export declare function bind(call: Call): {
966
1022
  *
967
1023
  * Into a place under Shard Protection, an upload begun with protectable true is sealed as it is sent. A place is protected by the setting nearest it (files.update's protection), or with none by the drive's default: an app's, which its owner turns on from the app's page, or for a device the account's. The answer's shards then says how the file is cut, and uploads.get gives its key. Each part is a fragment: the file's bytes from its number times fragmentBytes, sealed with that key as the SDK's sealFragment does, under a nonce of its own, and sent to the part's address. uploads.fragments confirms them, and completing makes the file, protected from its first byte, with a null clientSha256. A sealed upload sets blocks aside: none is kept, and none reused. Begun without protectable, a file lands as an ordinary file and the service protects it within a few minutes; from then on it is read through its map (files.shards).
968
1024
  *
1025
+ * A program that writes version 2 of the shard format says so with shardFormats. A sealed file of more than 16 MiB is then written in it: shards says format 2, with no fragments and no parts yet. Get the key from uploads.get, cut the file into blocks by its content with it, and declare them with uploads.blocks, which answers with the parts to send. Replacing a file whose content is in version 2, with a key that may read the file, the key is the file's own, and the blocks the file has already are not sent again. Complete it with outline, the outline's MAC.
1026
+ *
969
1027
  * POST /v1/uploads. Needs the scope files:write. Honours an Idempotency-Key.
970
1028
  */
971
1029
  begin: (body: UploadsBeginBody, options?: CallOptions) => Promise<UploadsBeginAnswer>;
@@ -977,6 +1035,14 @@ export declare function bind(call: Call): {
977
1035
  * GET /v1/uploads/:id. Needs the scope files:write.
978
1036
  */
979
1037
  get: (id: string, options?: CallOptions) => Promise<UploadsGetAnswer>;
1038
+ /**
1039
+ * Declare a sealed upload's blocks.
1040
+ *
1041
+ * For an upload written in version 2 of the shard format (its shards say format 2): the blocks the file is cut into by its content with the file's key, in order, each its size and its id, the HMAC of its bytes under the file's key. From 2 to 10,000 blocks of 1 byte to 64 MiB, adding up to sizeBytes. Answers with the places not to send, whose block the file has already under its key, and addresses for the first of the rest: ask for more with uploads.parts, and confirm them with uploads.fragments. Each part is its block, sealed as the SDK's sealBlock does. Declared once: the same blocks again are answered the same, and other blocks are refused with 409 blocks_declared.
1042
+ *
1043
+ * POST /v1/uploads/:id/blocks. Needs the scope files:write. Safe to send again.
1044
+ */
1045
+ blocks: (id: string, body: UploadsBlocksBody, options?: CallOptions) => Promise<UploadsBlocksAnswer>;
980
1046
  /**
981
1047
  * Addresses for more parts.
982
1048
  *
package/dist/generated.js CHANGED
@@ -16,6 +16,7 @@ export const OPERATIONS = {
16
16
  "changes.list": { method: "GET", path: "/v1/changes", idempotent: false, repeatable: false },
17
17
  "uploads.begin": { method: "POST", path: "/v1/uploads", idempotent: true, repeatable: false },
18
18
  "uploads.get": { method: "GET", path: "/v1/uploads/:id", idempotent: false, repeatable: false },
19
+ "uploads.blocks": { method: "POST", path: "/v1/uploads/:id/blocks", idempotent: false, repeatable: true },
19
20
  "uploads.parts": { method: "GET", path: "/v1/uploads/:id/parts", idempotent: false, repeatable: false },
20
21
  "uploads.fragments": { method: "POST", path: "/v1/uploads/:id/fragments", idempotent: false, repeatable: true },
21
22
  "uploads.complete": { method: "POST", path: "/v1/uploads/:id/complete", idempotent: false, repeatable: true },
@@ -115,6 +116,8 @@ export function bind(call) {
115
116
  *
116
117
  * A file under Shard Protection is kept as fragments, each sealed with the file's own key and stored across several storage accounts. Its map says where each fragment is, up to 1,000 at a time, with the key. Fetch each fragment from its address (no key: the address is the permission), check and open it as the SDK's openFragment does, and write it at its place. The first page counts the file's size toward the month's downloads; later pages count nothing. Addresses work for four hours: before then, ask for the page again from the first fragment not yet fetched. The key is in this answer and nowhere else. This API never keeps it, and neither should a program, beyond the read.
117
118
  *
119
+ * A file in version 2 of the shard format (format 2) is kept as blocks of their own lengths, cut by its content. The first page carries its outline: check it with the key before using a byte, open each block as the SDK's openBlock does, and write it after the blocks before it. A program says which versions it reads with formats, and a file in another is answered with 409 format_unsupported, so a program made before version 2 is told to update rather than failing on the file.
120
+ *
118
121
  * GET /v1/files/:id/shards. Needs the scope files:read.
119
122
  */
120
123
  shards: (id, query, options) => call("files.shards", { params: { id }, query }, options),
@@ -171,6 +174,8 @@ export function bind(call) {
171
174
  *
172
175
  * Into a place under Shard Protection, an upload begun with protectable true is sealed as it is sent. A place is protected by the setting nearest it (files.update's protection), or with none by the drive's default: an app's, which its owner turns on from the app's page, or for a device the account's. The answer's shards then says how the file is cut, and uploads.get gives its key. Each part is a fragment: the file's bytes from its number times fragmentBytes, sealed with that key as the SDK's sealFragment does, under a nonce of its own, and sent to the part's address. uploads.fragments confirms them, and completing makes the file, protected from its first byte, with a null clientSha256. A sealed upload sets blocks aside: none is kept, and none reused. Begun without protectable, a file lands as an ordinary file and the service protects it within a few minutes; from then on it is read through its map (files.shards).
173
176
  *
177
+ * A program that writes version 2 of the shard format says so with shardFormats. A sealed file of more than 16 MiB is then written in it: shards says format 2, with no fragments and no parts yet. Get the key from uploads.get, cut the file into blocks by its content with it, and declare them with uploads.blocks, which answers with the parts to send. Replacing a file whose content is in version 2, with a key that may read the file, the key is the file's own, and the blocks the file has already are not sent again. Complete it with outline, the outline's MAC.
178
+ *
174
179
  * POST /v1/uploads. Needs the scope files:write. Honours an Idempotency-Key.
175
180
  */
176
181
  begin: (body, options) => call("uploads.begin", { body }, options),
@@ -182,6 +187,14 @@ export function bind(call) {
182
187
  * GET /v1/uploads/:id. Needs the scope files:write.
183
188
  */
184
189
  get: (id, options) => call("uploads.get", { params: { id } }, options),
190
+ /**
191
+ * Declare a sealed upload's blocks.
192
+ *
193
+ * For an upload written in version 2 of the shard format (its shards say format 2): the blocks the file is cut into by its content with the file's key, in order, each its size and its id, the HMAC of its bytes under the file's key. From 2 to 10,000 blocks of 1 byte to 64 MiB, adding up to sizeBytes. Answers with the places not to send, whose block the file has already under its key, and addresses for the first of the rest: ask for more with uploads.parts, and confirm them with uploads.fragments. Each part is its block, sealed as the SDK's sealBlock does. Declared once: the same blocks again are answered the same, and other blocks are refused with 409 blocks_declared.
194
+ *
195
+ * POST /v1/uploads/:id/blocks. Needs the scope files:write. Safe to send again.
196
+ */
197
+ blocks: (id, body, options) => call("uploads.blocks", { params: { id }, body }, options),
185
198
  /**
186
199
  * Addresses for more parts.
187
200
  *
package/dist/index.d.ts CHANGED
@@ -3,6 +3,6 @@ export * from "./generated.js";
3
3
  export { FRAME_MAX_BYTES, takesFrame } from "./frames.js";
4
4
  export { get, type GetOptions, type Got, type Into, type LinkedFile } from "./links.js";
5
5
  export { type Sink } from "./ranges.js";
6
- export { checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, type FileKeys, type FragmentAt, type Outline, type OutlineBlock, type ShardErrorCode, type ShardKey, } from "./shards.js";
6
+ export { BlockLayout, checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, READS_FORMATS, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, type FileKeys, type FragmentAt, type Layout, type Outline, type OutlineBlock, type ShardErrorCode, type ShardKey, } from "./shards.js";
7
7
  export { FileFreakError, type CallOptions, type ClientOptions, type TokenSource } from "./transport.js";
8
8
  export { UploadStopped, type Source, type UploadOptions } from "./uploads.js";
package/dist/index.js CHANGED
@@ -13,6 +13,6 @@ export { FileFreak, Walk } from "./client.js";
13
13
  export * from "./generated.js";
14
14
  export { FRAME_MAX_BYTES, takesFrame } from "./frames.js";
15
15
  export { get } from "./links.js";
16
- export { checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, } from "./shards.js";
16
+ export { BlockLayout, checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, READS_FORMATS, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, } from "./shards.js";
17
17
  export { FileFreakError } from "./transport.js";
18
18
  export { UploadStopped } from "./uploads.js";
package/dist/links.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // API call is counted, since no key is used.
18
18
  import { fetchRanges } from "./ranges.js";
19
19
  import { asItIsKept, readProtected } from "./reader.js";
20
+ import { READS_FORMATS, SHARD_VERSION } from "./shards.js";
20
21
  import { backoff, FileFreakError, sleep } from "./transport.js";
21
22
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
23
  /** As the API's route takes a token (api/src/routes/public.ts). */
@@ -106,8 +107,30 @@ class Reader {
106
107
  this.fail();
107
108
  if (!isCount(v.sizeBytes) || !isCount(v.fragmentBytes) || !isCount(v.count) || !Array.isArray(v.fragments))
108
109
  this.fail();
110
+ // A site from before version 2 of the format says no version, and every file it has is in version 1.
111
+ const format = v.format === undefined ? SHARD_VERSION : v.format;
112
+ if (!isCount(format))
113
+ this.fail();
114
+ const outline = v.outline === undefined || v.outline === null ? null : this.outline(v.outline);
109
115
  const fragments = v.fragments.map((f) => (isObject(f) && isCount(f.place) && isWebAddress(f.url) ? { place: f.place, url: f.url } : this.fail()));
110
- return { contentId: v.contentId, sizeBytes: v.sizeBytes, fragmentBytes: v.fragmentBytes, count: v.count, key: v.key, fragments, expiresAt: v.expiresAt, grant: v.grant };
116
+ return {
117
+ contentId: v.contentId,
118
+ format,
119
+ sizeBytes: v.sizeBytes,
120
+ fragmentBytes: v.fragmentBytes,
121
+ count: v.count,
122
+ key: v.key,
123
+ outline,
124
+ fragments,
125
+ expiresAt: v.expiresAt,
126
+ grant: v.grant,
127
+ };
128
+ }
129
+ outline(v) {
130
+ if (!isObject(v) || typeof v.mac !== "string" || !Array.isArray(v.blocks))
131
+ this.fail();
132
+ const blocks = v.blocks.map((b) => (isObject(b) && typeof b.id === "string" && isCount(b.length) ? { id: b.id, length: b.length } : this.fail()));
133
+ return { mac: v.mac, blocks };
111
134
  }
112
135
  }
113
136
  /** The link's own site, asked with nothing that says who is asking. */
@@ -216,7 +239,7 @@ async function openShare(site, token, password) {
216
239
  downloadsLeft: view.downloadsLeft,
217
240
  list: async (folder, offset) => site.read.listing(await site.ask(`${base}/items?parent=${folder}&offset=${offset}&limit=${PAGE}`, { share })),
218
241
  address: async (id) => site.read.address(await site.ask(`${base}/files/${id}/url`, { share })),
219
- page: async (id, from, read, signal) => site.read.map(await site.ask(`${base}/files/${id}/shards?from=${from}`, { share, read }, undefined, signal)),
242
+ page: async (id, from, read, signal) => site.read.map(await site.ask(`${base}/files/${id}/shards?from=${from}&formats=${READS_FORMATS}`, { share, read }, undefined, signal)),
220
243
  };
221
244
  }
222
245
  async function openPublic(site, id, username) {
@@ -232,7 +255,7 @@ async function openPublic(site, id, username) {
232
255
  downloadsLeft: null,
233
256
  list: async (folder, offset) => site.read.listing(await site.ask(`/api/public/files/${folder}/items?offset=${offset}&limit=${PAGE}`)),
234
257
  address: async (file) => site.read.address(await site.ask(`/api/public/files/${file}/url`)),
235
- page: async (file, from, read, signal) => site.read.map(await site.ask(`/api/public/files/${file}/shards?from=${from}`, { read }, undefined, signal)),
258
+ page: async (file, from, read, signal) => site.read.map(await site.ask(`/api/public/files/${file}/shards?from=${from}&formats=${READS_FORMATS}`, { read }, undefined, signal)),
236
259
  };
237
260
  }
238
261
  /** Every file in a shared folder, with the folders it is in, read folder by folder. */
package/dist/reader.d.ts CHANGED
@@ -10,11 +10,22 @@ export declare const NOT_FETCHED = "Part of this file could not be fetched from
10
10
  /** A page of a protected file's map, as the reader uses it: files.shards's answer, or a link's. */
11
11
  export interface MapPage {
12
12
  contentId: string;
13
+ /** The version of the shard format the file is in, 1 or 2. */
14
+ format: number;
13
15
  sizeBytes: number;
16
+ /** In version 2, the largest block. */
14
17
  fragmentBytes: number;
15
18
  count: number;
16
19
  /** The file's key, in base64. */
17
20
  key: string;
21
+ /** In version 2, on the first page only: the outline's MAC, and each block's id and length, in base64. */
22
+ outline: {
23
+ mac: string;
24
+ blocks: ReadonlyArray<{
25
+ id: string;
26
+ length: number;
27
+ }>;
28
+ } | null;
18
29
  fragments: ReadonlyArray<{
19
30
  place: number;
20
31
  url: string;
package/dist/reader.js CHANGED
@@ -28,7 +28,13 @@
28
28
  //
29
29
  // The key is a WebCrypto key that cannot be exported, held inside one read
30
30
  // and let go of with it. No error here carries it.
31
- import { openFragment, ShardError, shardKey, ShardLayout, SHARD_OVERHEAD } from "./shards.js";
31
+ //
32
+ // A file in version 2 of the format is read the same way, a block at each
33
+ // place, once its outline is checked: the map's first page carries it, and
34
+ // nothing is fetched until its MAC holds under the file's key, since a map
35
+ // whose blocks were reordered or swapped would otherwise be written out in
36
+ // the wrong order with every block passing its own check.
37
+ import { BlockLayout, checkOutline, fileKeys, openBlock, openFragment, ShardError, shardKey, ShardLayout, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, } from "./shards.js";
32
38
  import { backoff, FileFreakError, sleep } from "./transport.js";
33
39
  const MIB = 1024 * 1024;
34
40
  /** The most bytes of fragments being fetched, or fetched and not yet written, at once in this program: five of the largest. */
@@ -131,11 +137,17 @@ function unreadable(what) {
131
137
  const damaged = (cause) => stopped("fragment_damaged", NOT_WHAT_WAS_STORED, cause);
132
138
  const notFetched = (cause) => stopped("fragment_unavailable", NOT_FETCHED, cause);
133
139
  /**
134
- * A page whose count, or one of whose places, the file's size and fragment
135
- * size do not allow: a map that does not add up is not read from.
140
+ * A page whose count, or one of whose places, the file's layout does not
141
+ * allow: a map that does not add up is not read from. In version 2 its
142
+ * count and fragment size are the outline's, its block count and largest.
136
143
  */
137
144
  function agrees(page, layout) {
138
- if (page.count !== layout.count) {
145
+ if (layout instanceof BlockLayout) {
146
+ if (page.count !== layout.count || page.fragmentBytes !== layout.fragmentSize) {
147
+ throw stopped("layout", `This file's map does not add up: it says ${page.count} blocks of at most ${page.fragmentBytes} bytes, and its outline names ${layout.count} of at most ${layout.fragmentSize}. Nothing was saved.`);
148
+ }
149
+ }
150
+ else if (page.count !== layout.count) {
139
151
  throw stopped("layout", `This file's map does not add up: it says ${page.count} fragments, where ${layout.size} bytes in fragments of ${layout.fragmentSize} make ${layout.count}. Nothing was saved.`);
140
152
  }
141
153
  const wrong = page.fragments.find((f) => !Number.isInteger(f.place) || f.place < 0 || f.place >= layout.count);
@@ -143,17 +155,50 @@ function agrees(page, layout) {
143
155
  throw stopped("layout", `This file's map names fragment ${wrong.place}, and the file has ${layout.count}, numbered from 0. Nothing was saved.`);
144
156
  }
145
157
  }
146
- async function keyOf(text) {
147
- let raw;
158
+ /** Base64 from the map, as bytes, or the map is unreadable. */
159
+ function bytesOf(text, what) {
148
160
  try {
149
- raw = Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
161
+ return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
150
162
  }
151
163
  catch {
152
- throw unreadable("key");
164
+ throw unreadable(what);
153
165
  }
166
+ }
167
+ /**
168
+ * The file's pieces as its first page describes them. In version 2 the
169
+ * outline is checked under the key here, before a block is fetched, and one
170
+ * that fails is the same failure as a fragment that fails its check.
171
+ */
172
+ async function piecesOf(first) {
173
+ const raw = bytesOf(first.key, "key");
154
174
  if (raw.length !== 32)
155
175
  throw unreadable("key");
156
- return await shardKey(raw);
176
+ try {
177
+ if (first.format === SHARD_VERSION) {
178
+ const layout = new ShardLayout(first.sizeBytes, first.fragmentBytes);
179
+ const key = await shardKey(raw);
180
+ return { layout, open: (place, blob) => openFragment(key, { contentId: first.contentId, layout, place }, blob) };
181
+ }
182
+ if (first.format === SHARD_VERSION_2) {
183
+ if (!first.outline)
184
+ throw unreadable("outline");
185
+ const outline = {
186
+ contentId: first.contentId,
187
+ size: first.sizeBytes,
188
+ blocks: first.outline.blocks.map((b) => ({ id: bytesOf(b.id, "outline"), length: b.length })),
189
+ };
190
+ const layout = new BlockLayout(outline);
191
+ const keys = await fileKeys(raw);
192
+ await checkOutline(keys, outline, bytesOf(first.outline.mac, "outline"));
193
+ return { layout, open: (place, blob) => openBlock(keys, outline.blocks[place], blob) };
194
+ }
195
+ }
196
+ catch (e) {
197
+ if (e instanceof ShardError)
198
+ throw e.code === "layout" ? stopped("layout", `${e.message} Nothing was saved.`) : damaged(e);
199
+ throw e;
200
+ }
201
+ throw stopped("format_unsupported", `This file is in version ${first.format} of the shard format, which this version of the SDK does not read. Update it to read the file.`);
157
202
  }
158
203
  /**
159
204
  * Read the protected file whose map `pages` gives into `sink`. Resolves once
@@ -172,19 +217,11 @@ export async function readProtected(options) {
172
217
  const first = await options.pages(0, stop.signal);
173
218
  if (options.sizeBytes !== undefined && first.sizeBytes !== options.sizeBytes)
174
219
  throw stopped("content_changed", CHANGED);
175
- let layout;
176
- try {
177
- layout = new ShardLayout(first.sizeBytes, first.fragmentBytes);
178
- }
179
- catch (e) {
180
- if (e instanceof ShardError)
181
- throw stopped("layout", `${e.message} Nothing was saved.`);
182
- throw e;
183
- }
184
- agrees(first, layout);
185
220
  if (!UUID.test(first.contentId))
186
221
  throw unreadable("content id");
187
- const reading = new Reading(options, stop, layout, first.contentId, await keyOf(first.key));
222
+ const pieces = await piecesOf(first);
223
+ agrees(first, pieces.layout);
224
+ const reading = new Reading(options, stop, pieces, first.contentId, first.format);
188
225
  reading.hold(first);
189
226
  await reading.fill();
190
227
  }
@@ -216,9 +253,9 @@ export async function asItIsKept(isProtected, plain, mapped) {
216
253
  class Reading {
217
254
  options;
218
255
  stop;
219
- layout;
256
+ pieces;
220
257
  contentId;
221
- key;
258
+ format;
222
259
  addresses = new Map();
223
260
  /** Held while a page is asked for, so that fragments waiting on the same page ask once. */
224
261
  asking = new Gate(1);
@@ -230,13 +267,15 @@ class Reading {
230
267
  wait;
231
268
  now;
232
269
  quietMs;
270
+ layout;
233
271
  written = 0;
234
- constructor(options, stop, layout, contentId, key) {
272
+ constructor(options, stop, pieces, contentId, format) {
235
273
  this.options = options;
236
274
  this.stop = stop;
237
- this.layout = layout;
275
+ this.pieces = pieces;
238
276
  this.contentId = contentId;
239
- this.key = key;
277
+ this.format = format;
278
+ this.layout = pieces.layout;
240
279
  this.budget = options.budget ?? shared();
241
280
  this.fetcher = options.fetch ?? fetch.bind(globalThis);
242
281
  this.wait = options.sleep ?? sleep;
@@ -248,7 +287,10 @@ class Reading {
248
287
  }
249
288
  /** Keep a page's addresses, once it is known to be a page of this file's map. */
250
289
  hold(page) {
251
- if (page.contentId !== this.contentId || page.sizeBytes !== this.layout.size || page.fragmentBytes !== this.layout.fragmentSize) {
290
+ if (page.contentId !== this.contentId ||
291
+ page.format !== this.format ||
292
+ page.sizeBytes !== this.layout.size ||
293
+ page.fragmentBytes !== this.layout.fragmentSize) {
252
294
  throw stopped("content_changed", CHANGED);
253
295
  }
254
296
  agrees(page, this.layout);
@@ -424,7 +466,7 @@ class Reading {
424
466
  }
425
467
  let plain;
426
468
  try {
427
- plain = await openFragment(this.key, { contentId: this.contentId, layout: this.layout, place }, blob);
469
+ plain = await this.pieces.open(place, blob);
428
470
  }
429
471
  catch (e) {
430
472
  throw damaged(e);
package/dist/shards.d.ts CHANGED
@@ -25,6 +25,8 @@ export declare class ShardLayout {
25
25
  };
26
26
  /** How many bytes of the file fragment `place` holds. */
27
27
  length(place: number): number;
28
+ /** The place of the fragment that holds byte `offset` of the file. */
29
+ placeAt(offset: number): number;
28
30
  }
29
31
  /**
30
32
  * A content's key as WebCrypto holds it. Named through crypto.subtle and not
@@ -59,6 +61,8 @@ export declare function sealWithNonce(key: ShardKey, at: FragmentAt, plaintext:
59
61
  */
60
62
  export declare function openFragment(key: ShardKey, at: FragmentAt, blob: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
61
63
  export declare const SHARD_VERSION_2 = 2;
64
+ /** The versions of the format this reads, as a reader asks for a map in them (formats). */
65
+ export declare const READS_FORMATS = "1,2";
62
66
  /** The most bytes of a file one block holds, as the API takes blocks. */
63
67
  export declare const MOST_BLOCK_BYTES: number;
64
68
  /** A block id: the HMAC-SHA256 of its bytes under the file's block key. */
@@ -108,6 +112,33 @@ export declare function sealBlockWithNonce(keys: FileKeys, id: Uint8Array, plain
108
112
  * returned.
109
113
  */
110
114
  export declare function openBlock(keys: FileKeys, block: OutlineBlock, blob: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
115
+ /**
116
+ * Where each block of a file in version 2 is, from its outline: what
117
+ * ShardLayout is for version 1, to a reader that writes each block at its
118
+ * place in the file, or reads a range from the blocks that hold it. It
119
+ * refuses an outline that describes no file, before anything is fetched by
120
+ * it; that the outline is the file's is checkOutline's to say.
121
+ */
122
+ export declare class BlockLayout {
123
+ readonly size: number;
124
+ readonly count: number;
125
+ /** The largest block, which is what a map of a file in version 2 gives as its fragment size. */
126
+ readonly fragmentSize: number;
127
+ /** Where each block begins, then the file's size: count + 1 offsets, rising. */
128
+ private readonly starts;
129
+ constructor(outline: Outline);
130
+ /** Where in the file block `place` begins, and where it ends (exclusive). */
131
+ range(place: number): {
132
+ start: number;
133
+ end: number;
134
+ };
135
+ /** How many bytes of the file block `place` holds. */
136
+ length(place: number): number;
137
+ /** The place of the block that holds byte `offset` of the file: the last that begins at or before it. */
138
+ placeAt(offset: number): number;
139
+ }
140
+ /** Where each piece of a file is, in either version: the reader's view of it, whichever the map says. */
141
+ export type Layout = ShardLayout | BlockLayout;
111
142
  /** The outline's MAC, which the writer sends with the upload's completion and every reader checks before it releases a byte. */
112
143
  export declare function outlineMac(keys: FileKeys, outline: Outline): Promise<Uint8Array<ArrayBuffer>>;
113
144
  /**
package/dist/shards.js CHANGED
@@ -60,6 +60,13 @@ export class ShardLayout {
60
60
  const { start, end } = this.range(place);
61
61
  return end - start;
62
62
  }
63
+ /** The place of the fragment that holds byte `offset` of the file. */
64
+ placeAt(offset) {
65
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset >= this.size) {
66
+ throw new RangeError(`There is no byte ${offset}: this file has ${this.size}, numbered from 0.`);
67
+ }
68
+ return Math.floor(offset / this.fragmentSize);
69
+ }
63
70
  }
64
71
  /** A content's key, 32 bytes, made usable for its fragments and nothing else. */
65
72
  export function shardKey(raw) {
@@ -136,6 +143,8 @@ export async function openFragment(key, at, blob) {
136
143
  // Version 2: blocks cut by content, under a file's key that versions share
137
144
  // ---------------------------------------------------------------------------
138
145
  export const SHARD_VERSION_2 = 2;
146
+ /** The versions of the format this reads, as a reader asks for a map in them (formats). */
147
+ export const READS_FORMATS = `${SHARD_VERSION},${SHARD_VERSION_2}`;
139
148
  /** The most bytes of a file one block holds, as the API takes blocks. */
140
149
  export const MOST_BLOCK_BYTES = 64 * 1024 * 1024;
141
150
  /** A block id: the HMAC-SHA256 of its bytes under the file's block key. */
@@ -249,6 +258,61 @@ function checkShape(outline) {
249
258
  if (total !== size)
250
259
  throw new ShardError("layout", `The blocks hold ${total} bytes, and the file is ${size}.`);
251
260
  }
261
+ /**
262
+ * Where each block of a file in version 2 is, from its outline: what
263
+ * ShardLayout is for version 1, to a reader that writes each block at its
264
+ * place in the file, or reads a range from the blocks that hold it. It
265
+ * refuses an outline that describes no file, before anything is fetched by
266
+ * it; that the outline is the file's is checkOutline's to say.
267
+ */
268
+ export class BlockLayout {
269
+ size;
270
+ count;
271
+ /** The largest block, which is what a map of a file in version 2 gives as its fragment size. */
272
+ fragmentSize;
273
+ /** Where each block begins, then the file's size: count + 1 offsets, rising. */
274
+ starts;
275
+ constructor(outline) {
276
+ checkShape(outline);
277
+ this.size = outline.size;
278
+ this.count = outline.blocks.length;
279
+ this.starts = [0];
280
+ let largest = 0;
281
+ for (const b of outline.blocks) {
282
+ this.starts.push(this.starts[this.starts.length - 1] + b.length);
283
+ largest = Math.max(largest, b.length);
284
+ }
285
+ this.fragmentSize = largest;
286
+ }
287
+ /** Where in the file block `place` begins, and where it ends (exclusive). */
288
+ range(place) {
289
+ if (!Number.isInteger(place) || place < 0 || place >= this.count) {
290
+ throw new RangeError(`There is no block ${place}: this file has ${this.count}, numbered from 0.`);
291
+ }
292
+ return { start: this.starts[place], end: this.starts[place + 1] };
293
+ }
294
+ /** How many bytes of the file block `place` holds. */
295
+ length(place) {
296
+ const { start, end } = this.range(place);
297
+ return end - start;
298
+ }
299
+ /** The place of the block that holds byte `offset` of the file: the last that begins at or before it. */
300
+ placeAt(offset) {
301
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset >= this.size) {
302
+ throw new RangeError(`There is no byte ${offset}: this file has ${this.size}, numbered from 0.`);
303
+ }
304
+ let low = 0;
305
+ let high = this.count - 1;
306
+ while (low < high) {
307
+ const middle = Math.ceil((low + high) / 2);
308
+ if (this.starts[middle] <= offset)
309
+ low = middle;
310
+ else
311
+ high = middle - 1;
312
+ }
313
+ return low;
314
+ }
315
+ }
252
316
  /** The bytes the outline's MAC is over: the label, the content id, the size, the count, and each block's id and length in order. */
253
317
  function outlineBytes(outline) {
254
318
  const head = OUTLINE_LABEL.length + 16 + 8 + 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@filefreak.io/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "FileFreak's developer API for Node and the browser: every endpoint, uploads straight to storage in parts, downloads in ranges, and the changes to follow.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://filefreak.io/developers/reference",