@filefreak.io/sdk 0.5.0 → 0.6.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
@@ -5,7 +5,8 @@ later and in browsers. No dependencies.
5
5
 
6
6
  Every endpoint is a function, typed from the same table the API is built
7
7
  from. Around them: uploads sent straight to storage in parts and carried on
8
- after an interruption, downloads fetched in ranges, listings and changes read
8
+ after an interruption, a new version of a large file sending only what
9
+ changed, downloads fetched in ranges, listings and changes read
9
10
  to the end, files under Shard Protection sealed as they are sent and read
10
11
  through their maps, what a share link holds saved with no key, and requests sent again only when that
11
12
  cannot make anything twice. The reference is at
@@ -43,6 +44,28 @@ an upload carried on asks which fragments storage has before it sends any.
43
44
 
44
45
  Keep keys on a server. A key sent from a web page is refused.
45
46
 
47
+ ## A new version of a large file
48
+
49
+ A file of 16 MiB or more is read through before it is sent and cut where
50
+ its content says, and each part is one of its blocks. A new version of
51
+ it, sent with `onConflict: "replace"`, sends only the blocks the file does
52
+ not have already: those it has are put together with the new ones inside
53
+ storage. The SDK and the CLI cut a file alike, so the next version of a file
54
+ either one sent keeps its blocks whichever sends it.
55
+
56
+ ```ts
57
+ await ff.upload(await fromPath("disk.img"), { parentId: folder.id, onConflict: "replace" });
58
+ ```
59
+
60
+ Into a folder under Shard Protection the same holds, with every block
61
+ encrypted before it leaves. A protected file of more than 16 MiB is cut by
62
+ its own key, so that nobody without the key can cut a copy of it the same
63
+ way and compare, and its next version keeps the blocks the last one has
64
+ when the key may read files; a key that may only write starts a new file
65
+ key and sends every block. Each block is read again as it is sent and
66
+ checked against what the upload began with, so a file changed while it is
67
+ sent stops the upload (`source_changed`) rather than storing a mix of two.
68
+
46
69
  ## In a web page
47
70
 
48
71
  A page is given a token by your server instead. Your server makes it with its
@@ -190,7 +213,9 @@ await ff.files.delete(file.id, { ifRevision: file.revision });
190
213
 
191
214
  What reached storage is kept until the upload expires. Keep the upload's id
192
215
  from `onBegun`, and carry on with the same file: only the parts storage does
193
- not have are sent.
216
+ not have are sent. A file cut by its content is carried on block by block,
217
+ each checked against what the upload began with, so carrying on with another
218
+ file is refused at its first block.
194
219
 
195
220
  ```ts
196
221
  import { UploadStopped } from "@filefreak.io/sdk";
package/dist/cut.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { type FileKeys, type OutlineBlock } from "./shards.js";
2
+ /** How a file is cut. One rule for every file, so that a file cut once is cut the same way next time, which is what lets its blocks be found again. */
3
+ export interface Rule {
4
+ /** A smaller plain file is not cut: it goes in one or two parts, and there is nothing to gain. */
5
+ cutFrom: number;
6
+ /** FastCDC's sizes. No piece but a file's last is shorter than least, and none is longer than most. */
7
+ least: number;
8
+ average: number;
9
+ most: number;
10
+ /** FastCDC's patterns at level 1: the stricter until a piece reaches the average, the looser after. Within the low 48 bits. */
11
+ strict: bigint;
12
+ loose: bigint;
13
+ /** Past this size pieces are put together in runs, about four to a run, so that a file of 150 GB is still within the API's 10,000 blocks. */
14
+ groupFrom: number;
15
+ /** A run ends once it is this long, so that one more piece, 16 MiB at most, leaves it within the API's 64 MiB. */
16
+ groupMost: number;
17
+ /** The most blocks the API takes. */
18
+ mostBlocks: number;
19
+ /** How much of a Blob is read at a time. */
20
+ readBytes: number;
21
+ }
22
+ export declare const RULE: Rule;
23
+ /** FastCDC's 256 numbers: whole, for the hash at a cut, and their low 48 bits in halves of 24, for the hash as the bytes go by. */
24
+ export declare class CutTable {
25
+ readonly values: BigUint64Array;
26
+ readonly low: Int32Array<ArrayBuffer>;
27
+ readonly high: Int32Array<ArrayBuffer>;
28
+ constructor(values: BigUint64Array);
29
+ }
30
+ /**
31
+ * The fastcdc crate's own table, which a plain file is cut with: for each
32
+ * value, the first eight bytes, big-endian, of the MD5 of 64 bytes of it
33
+ * (the crate's examples/table64.rs, after the C implementation it follows).
34
+ * Made here rather than copied in.
35
+ */
36
+ export declare function publicTable(): CutTable;
37
+ /** A protected file's own table: 2,048 bytes of HKDF-SHA256 from its key, with no salt and the info "filefreak-shard-v2 cut", as 256 big-endian numbers. */
38
+ export declare function keyedTable(raw: Uint8Array): Promise<CutTable>;
39
+ /** A piece of a file: how many bytes, and the hash it was cut at. */
40
+ export interface Piece {
41
+ length: number;
42
+ hash: bigint;
43
+ }
44
+ /**
45
+ * The piece `data` begins with, where `data` runs to the end of the largest
46
+ * piece or of the file, whichever comes first. As the crate cuts: the hash
47
+ * starts `least` bytes in; places are tested two at a time, so the last byte
48
+ * of a piece cut short at an odd length is hashed and never tested; and a
49
+ * piece cut at a match ends before the byte that matched.
50
+ */
51
+ export declare function piece(data: Uint8Array, table: CutTable, rule?: Rule): Piece;
52
+ /**
53
+ * Whether a piece cut at `hash` ends a run, in a file past groupFrom: one in
54
+ * four, decided by the bytes where it was cut, so that a run ends at the
55
+ * same content wherever it moves to. Mixed first, because a hash at a cut
56
+ * matches a pattern and some of its bits are always zero.
57
+ */
58
+ export declare function endsRun(hash: bigint): boolean;
59
+ /** What is cut: its bytes, or a way to read them a window at a time. */
60
+ export type Bytes = Uint8Array | {
61
+ size: number;
62
+ read(from: number, to: number): Promise<Uint8Array>;
63
+ };
64
+ /** A block of a file: how many bytes, and what names it. */
65
+ export interface Named<T> {
66
+ length: number;
67
+ name: T;
68
+ }
69
+ /**
70
+ * A file cut into blocks with the table given: a piece to a block, or in a
71
+ * file past groupFrom, runs of pieces. `name` is given each block's bytes in
72
+ * order. Null as soon as there are more blocks than the API takes, without
73
+ * reading the rest. What is read is kept only while a piece or an open run
74
+ * still needs it.
75
+ */
76
+ export declare function cut<T>(bytes: Bytes, table: CutTable, name: (block: Uint8Array) => Promise<T>, rule?: Rule, signal?: AbortSignal): Promise<Array<Named<T>> | null>;
77
+ /** A plain block as beginning an upload takes it. */
78
+ export interface PlainBlock {
79
+ size: number;
80
+ /** In lower-case hex. */
81
+ sha256: string;
82
+ }
83
+ /**
84
+ * A plain file cut for beginning its upload, each block its size and
85
+ * SHA-256. Null for a file too small to be worth cutting, or cut into more
86
+ * blocks than the API takes: either is sent in parts of one size.
87
+ */
88
+ export declare function cutPlain(bytes: Bytes, rule?: Rule, signal?: AbortSignal): Promise<PlainBlock[] | null>;
89
+ /**
90
+ * A protected file cut for version 2 of the shard format, with its own key's
91
+ * table, each block its id and length, as uploads.blocks takes them and the
92
+ * outline holds them. Null for a file not cut into two to ten thousand
93
+ * blocks, which version 2 does not take: it is written in version 1.
94
+ */
95
+ export declare function cutSealed(bytes: Bytes, raw: Uint8Array, keys: FileKeys, rule?: Rule, signal?: AbortSignal): Promise<OutlineBlock[] | null>;
96
+ export declare function hex(bytes: Uint8Array): string;
package/dist/cut.js ADDED
@@ -0,0 +1,220 @@
1
+ // Cutting a file by its content, so that its next version sends only what
2
+ // changed. The rule is the CLI's (cli/crates/transfer/src/blocks.rs), so
3
+ // that of a file either one sent, the other's next version keeps the blocks.
4
+ //
5
+ // Where a piece ends is decided by the bytes there: FastCDC, as the fastcdc
6
+ // crate runs it (version 5, its 2020 form, normalised at level 1), hashes the
7
+ // bytes as it reads and cuts where the hash matches a pattern. A byte put in
8
+ // near the start of a file moves the end of the piece it falls in and leaves
9
+ // every later piece as it was, where parts of one size would all move. A
10
+ // plain file is cut with the crate's own table of numbers and each block
11
+ // named by its SHA-256, as beginning an upload takes them. A file under Shard
12
+ // Protection, written in version 2 of the shard format, is cut with a table
13
+ // made from the file's own key and each block named by its id
14
+ // (docs/reference/shard-format.md, "Cutting a file"): with the public table,
15
+ // whoever has the database could cut a file they hold and compare the
16
+ // lengths. docs/reference/shard-format-v2.json holds both to the crate.
17
+ //
18
+ // The hash runs to 64 bits, more than a JavaScript number holds exactly. The
19
+ // patterns look only at its low 48 bits, which nothing above them changes, so
20
+ // only those are kept as the bytes go by, as two numbers of 24 bits. The
21
+ // whole hash is wanted only at a cut, where it says whether a run ends in a
22
+ // file past 32 GiB, and it depends only on the last 64 bytes hashed: it is
23
+ // worked out again from those, as a BigInt.
24
+ import { Md5 } from "./md5.js";
25
+ import { blockId } from "./shards.js";
26
+ const MIB = 1024 * 1024;
27
+ const HALF = 0xff_ffff;
28
+ export const RULE = {
29
+ cutFrom: 16 * MIB,
30
+ least: MIB,
31
+ average: 4 * MIB,
32
+ most: 16 * MIB,
33
+ strict: 0x0000d93777537000n,
34
+ loose: 0x0000d91767537000n,
35
+ groupFrom: 32 * 1024 * MIB,
36
+ groupMost: 48 * MIB,
37
+ mostBlocks: 10_000,
38
+ readBytes: 32 * MIB,
39
+ };
40
+ /** FastCDC's 256 numbers: whole, for the hash at a cut, and their low 48 bits in halves of 24, for the hash as the bytes go by. */
41
+ export class CutTable {
42
+ values;
43
+ low = new Int32Array(256);
44
+ high = new Int32Array(256);
45
+ constructor(values) {
46
+ this.values = values;
47
+ if (values.length !== 256)
48
+ throw new RangeError(`A cutting table has 256 numbers, not ${values.length}.`);
49
+ for (let i = 0; i < 256; i++) {
50
+ this.low[i] = Number(values[i] & BigInt(HALF));
51
+ this.high[i] = Number((values[i] >> 24n) & BigInt(HALF));
52
+ }
53
+ }
54
+ }
55
+ let crateTable = null;
56
+ /**
57
+ * The fastcdc crate's own table, which a plain file is cut with: for each
58
+ * value, the first eight bytes, big-endian, of the MD5 of 64 bytes of it
59
+ * (the crate's examples/table64.rs, after the C implementation it follows).
60
+ * Made here rather than copied in.
61
+ */
62
+ export function publicTable() {
63
+ if (!crateTable) {
64
+ const values = new BigUint64Array(256);
65
+ for (let i = 0; i < 256; i++) {
66
+ const digest = new Md5().update(new Uint8Array(64).fill(i)).digest();
67
+ values[i] = new DataView(digest.buffer, digest.byteOffset, 8).getBigUint64(0, false);
68
+ }
69
+ crateTable = new CutTable(values);
70
+ }
71
+ return crateTable;
72
+ }
73
+ /** A protected file's own table: 2,048 bytes of HKDF-SHA256 from its key, with no salt and the info "filefreak-shard-v2 cut", as 256 big-endian numbers. */
74
+ export async function keyedTable(raw) {
75
+ if (raw.length !== 32)
76
+ throw new RangeError(`A shard key is 32 bytes, not ${raw.length}.`);
77
+ const base = await crypto.subtle.importKey("raw", new Uint8Array(raw), "HKDF", false, ["deriveBits"]);
78
+ const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("filefreak-shard-v2 cut") }, base, 2048 * 8);
79
+ const view = new DataView(bits);
80
+ const values = new BigUint64Array(256);
81
+ for (let i = 0; i < 256; i++)
82
+ values[i] = view.getBigUint64(i * 8, false);
83
+ return new CutTable(values);
84
+ }
85
+ /** A pattern as two halves of 24 bits, refused if it looks above the 48 bits the hash is kept to. */
86
+ function halves(pattern) {
87
+ if (pattern >> 48n !== 0n)
88
+ throw new RangeError(`A cutting pattern looks only at the low 48 bits of the hash, and ${pattern.toString(16)} does not.`);
89
+ return [Number(pattern & BigInt(HALF)), Number(pattern >> 24n)];
90
+ }
91
+ /**
92
+ * The piece `data` begins with, where `data` runs to the end of the largest
93
+ * piece or of the file, whichever comes first. As the crate cuts: the hash
94
+ * starts `least` bytes in; places are tested two at a time, so the last byte
95
+ * of a piece cut short at an odd length is hashed and never tested; and a
96
+ * piece cut at a match ends before the byte that matched.
97
+ */
98
+ export function piece(data, table, rule = RULE) {
99
+ const left = data.length;
100
+ if (left <= rule.least)
101
+ return { length: left, hash: 0n };
102
+ const end = Math.min(left, rule.most);
103
+ const strictTo = 2 * Math.floor(Math.min(rule.average, end) / 2);
104
+ const testedTo = 2 * Math.floor(end / 2);
105
+ const low = table.low;
106
+ const high = table.high;
107
+ let [maskLow, maskHigh] = halves(rule.strict);
108
+ const [looseLow, looseHigh] = halves(rule.loose);
109
+ let lo = 0;
110
+ let hi = 0;
111
+ for (let i = rule.least; i < testedTo; i++) {
112
+ if (i === strictTo) {
113
+ maskLow = looseLow;
114
+ maskHigh = looseHigh;
115
+ }
116
+ const b = data[i];
117
+ const sum = 2 * lo + low[b];
118
+ hi = (2 * hi + high[b] + (sum >>> 24)) & HALF;
119
+ lo = sum & HALF;
120
+ if ((lo & maskLow) === 0 && (hi & maskHigh) === 0)
121
+ return { length: i, hash: hashAt(data, i, table, rule, i % 2 === 0) };
122
+ }
123
+ return { length: end, hash: hashAt(data, end - 1, table, rule, false) };
124
+ }
125
+ /**
126
+ * The whole hash once byte `i` is hashed, from the bytes that still count in
127
+ * it: those from `least` on, and of them only the last 64, since each byte
128
+ * doubles it and 64 doublings leave nothing of an earlier one. Doubled again
129
+ * for a match at an even place, as the crate gives it.
130
+ */
131
+ function hashAt(data, i, table, rule, doubled) {
132
+ let h = 0n;
133
+ for (let j = Math.max(rule.least, i - 63); j <= i; j++)
134
+ h = BigInt.asUintN(64, (h << 1n) + table.values[data[j]]);
135
+ return doubled ? BigInt.asUintN(64, h << 1n) : h;
136
+ }
137
+ /**
138
+ * Whether a piece cut at `hash` ends a run, in a file past groupFrom: one in
139
+ * four, decided by the bytes where it was cut, so that a run ends at the
140
+ * same content wherever it moves to. Mixed first, because a hash at a cut
141
+ * matches a pattern and some of its bits are always zero.
142
+ */
143
+ export function endsRun(hash) {
144
+ return BigInt.asUintN(64, hash * 0x9e3779b97f4a7c15n) >> 62n === 0n;
145
+ }
146
+ /**
147
+ * A file cut into blocks with the table given: a piece to a block, or in a
148
+ * file past groupFrom, runs of pieces. `name` is given each block's bytes in
149
+ * order. Null as soon as there are more blocks than the API takes, without
150
+ * reading the rest. What is read is kept only while a piece or an open run
151
+ * still needs it.
152
+ */
153
+ export async function cut(bytes, table, name, rule = RULE, signal) {
154
+ const reader = bytes instanceof Uint8Array ? null : bytes;
155
+ const size = sizeOf(bytes);
156
+ let buffer = bytes instanceof Uint8Array ? bytes : new Uint8Array(0);
157
+ let bufferAt = 0;
158
+ const grouping = size > rule.groupFrom;
159
+ const blocks = [];
160
+ let runAt = null;
161
+ for (let at = 0; at < size;) {
162
+ signal?.throwIfAborted();
163
+ const want = Math.min(size, at + rule.most);
164
+ if (reader && bufferAt + buffer.length < want) {
165
+ const keep = runAt ?? at;
166
+ const had = bufferAt + buffer.length;
167
+ const upTo = Math.min(size, Math.max(want, had + rule.readBytes));
168
+ const more = await reader.read(had, upTo);
169
+ if (more.length !== upTo - had)
170
+ throw new RangeError(`Reading bytes ${had} to ${upTo} gave ${more.length}: the file has changed since it was opened.`);
171
+ const joined = new Uint8Array(had - keep + more.length);
172
+ joined.set(buffer.subarray(keep - bufferAt));
173
+ joined.set(more, had - keep);
174
+ buffer = joined;
175
+ bufferAt = keep;
176
+ }
177
+ const { length, hash } = piece(buffer.subarray(at - bufferAt, want - bufferAt), table, rule);
178
+ const end = at + length;
179
+ runAt ??= at;
180
+ if (!grouping || end - runAt >= rule.groupMost || endsRun(hash) || end === size) {
181
+ blocks.push({ length: end - runAt, name: await name(buffer.subarray(runAt - bufferAt, end - bufferAt)) });
182
+ if (blocks.length > rule.mostBlocks)
183
+ return null;
184
+ runAt = null;
185
+ }
186
+ at = end;
187
+ }
188
+ return blocks;
189
+ }
190
+ /**
191
+ * A plain file cut for beginning its upload, each block its size and
192
+ * SHA-256. Null for a file too small to be worth cutting, or cut into more
193
+ * blocks than the API takes: either is sent in parts of one size.
194
+ */
195
+ export async function cutPlain(bytes, rule = RULE, signal) {
196
+ if (sizeOf(bytes) < rule.cutFrom)
197
+ return null;
198
+ const blocks = await cut(bytes, publicTable(), async (block) => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", own(block)))), rule, signal);
199
+ return blocks && blocks.map((b) => ({ size: b.length, sha256: b.name }));
200
+ }
201
+ /**
202
+ * A protected file cut for version 2 of the shard format, with its own key's
203
+ * table, each block its id and length, as uploads.blocks takes them and the
204
+ * outline holds them. Null for a file not cut into two to ten thousand
205
+ * blocks, which version 2 does not take: it is written in version 1.
206
+ */
207
+ export async function cutSealed(bytes, raw, keys, rule = RULE, signal) {
208
+ const blocks = await cut(bytes, await keyedTable(raw), (block) => blockId(keys, block), rule, signal);
209
+ return blocks && blocks.length >= 2 ? blocks.map((b) => ({ id: b.name, length: b.length })) : null;
210
+ }
211
+ function sizeOf(bytes) {
212
+ return bytes instanceof Uint8Array ? bytes.length : bytes.size;
213
+ }
214
+ /** The bytes as WebCrypto's types take them, copied only when they are not in an ArrayBuffer of their own (a SharedArrayBuffer's). */
215
+ function own(bytes) {
216
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
217
+ }
218
+ export function hex(bytes) {
219
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
220
+ }
package/dist/uploads.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { FilesFrameAnswer, FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsFragmentsAnswer, UploadsFragmentsBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
1
+ import { type Rule } from "./cut.js";
2
+ import type { FilesFrameAnswer, FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsBlocksAnswer, UploadsBlocksBody, UploadsCancelAnswer, UploadsCompleteAnswer, UploadsCompleteBody, UploadsFragmentsAnswer, UploadsFragmentsBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
2
3
  import { type CallOptions } from "./transport.js";
3
4
  /** What can be uploaded. A File's name and type are used when none are given. */
4
5
  export type Source = Blob | ArrayBuffer | Uint8Array;
@@ -18,7 +19,7 @@ export interface UploadOptions {
18
19
  contentType?: string;
19
20
  /** Smaller copies to make of an image as soon as it is stored. */
20
21
  imageSizes?: number[];
21
- /** Bytes in each part, from 8 MiB to 64 MiB. Left out, 32 MiB, or less for a small file. */
22
+ /** Bytes in each part, from 8 MiB to 64 MiB, for a file not cut by its content: one under 16 MiB, or one of more blocks than the API takes. Left out, 32 MiB, or less for a small file. */
22
23
  partBytes?: number;
23
24
  /** Parts sent at once. */
24
25
  concurrency?: number;
@@ -52,11 +53,13 @@ export interface UploadCalls {
52
53
  begin(body: UploadsBeginBody, options?: CallOptions): Promise<UploadsBeginAnswer>;
53
54
  get(id: string, options?: CallOptions): Promise<UploadsGetAnswer>;
54
55
  parts(id: string, query?: UploadsPartsQuery, options?: CallOptions): Promise<UploadsPartsAnswer>;
56
+ blocks(id: string, body: UploadsBlocksBody, options?: CallOptions): Promise<UploadsBlocksAnswer>;
55
57
  fragments(id: string, body: UploadsFragmentsBody, options?: CallOptions): Promise<UploadsFragmentsAnswer>;
56
58
  complete(id: string, body?: UploadsCompleteBody, options?: CallOptions): Promise<UploadsCompleteAnswer>;
59
+ cancel(id: string, options?: CallOptions): Promise<UploadsCancelAnswer>;
57
60
  file(id: string, options?: CallOptions): Promise<FilesGetAnswer>;
58
61
  frame(id: string, options?: CallOptions): Promise<FilesFrameAnswer>;
59
62
  }
60
63
  /** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
61
64
  export declare function partSizeFor(fileBytes: number, atOnce: number): number;
62
- export declare function upload(calls: UploadCalls, fetcher: typeof fetch, source: Source, options?: UploadOptions): Promise<Item>;
65
+ export declare function upload(calls: UploadCalls, fetcher: typeof fetch, source: Source, options?: UploadOptions, rule?: Rule): Promise<Item>;
package/dist/uploads.js CHANGED
@@ -16,7 +16,17 @@
16
16
  // answers 202 until it has, and is asked again when it says.
17
17
  //
18
18
  // A part is read into memory whole to be hashed and sent, so the memory an
19
- // upload needs is about partBytes times the parts sent at once.
19
+ // upload needs is about the size of a part times the parts sent at once.
20
+ //
21
+ // CUT BY CONTENT, as the CLI cuts a file (cut.ts). A file of 16 MiB or more
22
+ // is read through once before it is begun, cut where its content says, and
23
+ // begun with its blocks, each its size and SHA-256. Each part is then a
24
+ // block, and a block the file it replaces has already, or one an earlier
25
+ // part repeats, is not sent: the API lists those in reused. So a new version
26
+ // of a large file sends only the blocks that changed. Each block is hashed
27
+ // again as it is sent and refused if it is not the block that was declared,
28
+ // since the file has changed. A block is 16 MiB at most, or 64 MiB in a file
29
+ // past 32 GiB.
20
30
  //
21
31
  // SEALED AS IT IS SENT (plan 04, F6 step 4, answer 46). Every begin says the
22
32
  // SDK can seal (protectable), as the website and the CLI do. Into a place
@@ -30,9 +40,21 @@
30
40
  // Carrying on, every fragment not recorded is confirmed before it is sent
31
41
  // again, since storage may hold it already. A fragment is up to 64 MiB and
32
42
  // sealed in memory, so the memory a sealed upload needs is the same.
43
+ //
44
+ // Every begin also says the SDK writes version 2 of the shard format, in
45
+ // which a protected file of more than 16 MiB is answered. Its fragments are
46
+ // blocks: the file is cut again with a table made from its own key, each
47
+ // block named by its id under the key (cutSealed), and the blocks declared
48
+ // (uploads.blocks). Those the file's last version has under the same key are
49
+ // kept and never sent, so a new version of a large protected file sends only
50
+ // what changed, sealed here. Each block is checked against the id declared
51
+ // for its place as it is sealed, and completing sends the outline's MAC. A
52
+ // file not cut into two to ten thousand blocks, which version 2 does not
53
+ // take, is begun again in version 1.
54
+ import { cutPlain, cutSealed, hex, RULE } from "./cut.js";
33
55
  import { canDrawFrames, drawFrame, sendFrame, takesFrame } from "./frames.js";
34
56
  import { base64, Md5 } from "./md5.js";
35
- import { sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
57
+ import { BlockLayout, fileKeys, outlineMac, sealBlock, sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
36
58
  import { FileFreakError, sleep } from "./transport.js";
37
59
  const MIB = 1024 * 1024;
38
60
  /** The API's bounds on a part. */
@@ -54,6 +76,8 @@ const CONFIRM_MOST = 100;
54
76
  const CONFIRM_PATIENCE_MS = 5 * 60_000;
55
77
  /** How many times fragments storage did not keep are sealed again and sent before the upload stops. */
56
78
  const SEALED_ROUNDS = 4;
79
+ /** The versions of the shard format this writes. */
80
+ const WRITES_FORMATS = [1, 2];
57
81
  /** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
58
82
  export class UploadStopped extends Error {
59
83
  uploadId;
@@ -64,33 +88,106 @@ export class UploadStopped extends Error {
64
88
  this.name = "UploadStopped";
65
89
  }
66
90
  }
67
- /**
68
- * The key and the cut of a sealed upload, from what uploads.get says, with
69
- * every fragment it has not recorded waiting to be confirmed: one sent
70
- * before, by this process or another, may be in storage already.
71
- */
72
- async function sealingOf(state, size) {
91
+ /** A part that is not what was declared for it: the file has changed since it was cut. */
92
+ function changed(part) {
93
+ return new FileFreakError(0, "source_changed", `Part ${part} of the file is not what it was when the upload began: the file has changed since.`);
94
+ }
95
+ /** A sealed upload's key, from what uploads.get says, and what it says of the file. */
96
+ function keyOf(state) {
73
97
  const shards = state.shards;
74
98
  if (!shards)
75
99
  throw new FileFreakError(0, "unreadable", "The API began a sealed upload and then gave no key for it.");
76
- let layout;
77
100
  let raw;
78
101
  try {
79
- layout = new ShardLayout(size, shards.fragmentBytes);
80
102
  raw = Uint8Array.from(atob(shards.key), (c) => c.charCodeAt(0));
81
103
  }
82
104
  catch (e) {
83
- throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose cut or key could not be read."), { cause: e });
105
+ throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose key could not be read."), { cause: e });
106
+ }
107
+ if (raw.length !== 32)
108
+ throw new FileFreakError(0, "unreadable", `The API answered with a key of ${raw.length} bytes, where a file's key is 32.`);
109
+ return { raw, shards };
110
+ }
111
+ /**
112
+ * The key and the cut of an upload sealed in version 1, from what
113
+ * uploads.get says, with every fragment it has not recorded waiting to be
114
+ * confirmed: one sent before, by this process or another, may be in storage
115
+ * already.
116
+ */
117
+ async function sealingOf(state, size) {
118
+ const { raw, shards } = keyOf(state);
119
+ let layout;
120
+ try {
121
+ layout = new ShardLayout(size, shards.fragmentBytes);
122
+ }
123
+ catch (e) {
124
+ throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose cut could not be read."), { cause: e });
84
125
  }
85
- if (layout.count !== shards.count || layout.count !== state.upload.partCount || raw.length !== 32) {
126
+ if (layout.count !== shards.count || layout.count !== state.upload.partCount) {
86
127
  throw new FileFreakError(0, "layout", `The API cut this file into ${shards.count} fragments, where ${size} bytes in fragments of ${shards.fragmentBytes} make ${layout.count}.`);
87
128
  }
129
+ const key = await shardKey(raw);
130
+ const contentId = shards.contentId;
88
131
  const recorded = new Set(state.uploadedParts);
89
132
  const waiting = new Set();
90
133
  for (let n = 0; n < layout.count; n++)
91
134
  if (!recorded.has(n))
92
135
  waiting.add(n);
93
- return { key: await shardKey(raw), contentId: shards.contentId, layout, recorded, waiting };
136
+ return {
137
+ layout,
138
+ seal: async (place, plain) => {
139
+ try {
140
+ return await sealFragment(key, { contentId, layout, place }, plain);
141
+ }
142
+ catch (e) {
143
+ if (e instanceof ShardError)
144
+ throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
145
+ throw e;
146
+ }
147
+ },
148
+ outline: null,
149
+ recorded,
150
+ waiting,
151
+ };
152
+ }
153
+ /** What a file in version 2 is sealed with: each block checked, as it is sealed, against the id declared for its place. */
154
+ async function blockSealing(keys, outline, recorded) {
155
+ let layout;
156
+ try {
157
+ layout = new BlockLayout(outline);
158
+ }
159
+ catch (e) {
160
+ if (e instanceof ShardError)
161
+ throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
162
+ throw e;
163
+ }
164
+ return {
165
+ layout,
166
+ seal: async (place, plain) => {
167
+ const { id, blob } = await sealBlock(keys, plain);
168
+ if (base64(id) !== base64(outline.blocks[place].id))
169
+ throw changed(place);
170
+ return blob;
171
+ },
172
+ outline: base64(await outlineMac(keys, outline)),
173
+ recorded,
174
+ waiting: new Set(),
175
+ };
176
+ }
177
+ /**
178
+ * Version 2: the file cut with its own key's table, and its blocks declared,
179
+ * with what to seal them with. Null for a file not cut into two to ten
180
+ * thousand blocks, which version 2 does not take.
181
+ */
182
+ async function declare(calls, state, source, size, rule, signal) {
183
+ const { raw, shards } = keyOf(state);
184
+ const keys = await fileKeys(raw);
185
+ const blocks = await cutSealed(cuttable(source, size), raw, keys, rule, signal);
186
+ if (!blocks)
187
+ return null;
188
+ const declared = await calls.blocks(state.upload.id, { blocks: blocks.map((b) => ({ size: b.length, id: base64(b.id) })) }, { signal });
189
+ const sealing = await blockSealing(keys, { contentId: shards.contentId, size, blocks }, new Set(declared.reused));
190
+ return { upload: declared.upload, parts: declared.parts, sealing };
94
191
  }
95
192
  /** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
96
193
  export function partSizeFor(fileBytes, atOnce) {
@@ -109,44 +206,64 @@ async function bytesOf(source, from, to) {
109
206
  return new Uint8Array(source.slice(from, to));
110
207
  return source.slice(from, to);
111
208
  }
112
- export async function upload(calls, fetcher, source, options = {}) {
209
+ /** Bytes of the file, or why they could not be read. */
210
+ async function read(source, from, to) {
211
+ try {
212
+ return await bytesOf(source, from, to);
213
+ }
214
+ catch (e) {
215
+ // Browsers and Node both refuse to read a file that changed after it was
216
+ // opened, which is what keeps one from going up half old and half new.
217
+ throw Object.assign(new FileFreakError(0, "unreadable", "The file could not be read. It may have been changed, moved or deleted since it was opened: open it again, unchanged, to carry on."), { cause: e });
218
+ }
219
+ }
220
+ /** The file as the cutter reads it: where it is, when it is in memory, and a window at a time from a Blob. */
221
+ function cuttable(source, size) {
222
+ if (source instanceof Blob)
223
+ return { size, read: (from, to) => read(source, from, to) };
224
+ return source instanceof ArrayBuffer ? new Uint8Array(source) : source;
225
+ }
226
+ /** Parts of one size, the last whatever remains. */
227
+ function evenly(upload, size) {
228
+ return (n) => ({ start: n * upload.partBytes, end: Math.min(size, (n + 1) * upload.partBytes) });
229
+ }
230
+ /** Parts that are blocks of their own sizes. */
231
+ function blocksOf(sizes) {
232
+ const starts = [0];
233
+ for (const size of sizes)
234
+ starts.push(starts[starts.length - 1] + size);
235
+ return (n) => ({ start: starts[n], end: starts[n + 1] });
236
+ }
237
+ /** A job for an upload sealed as it is sent: each part is a fragment, where its layout says. */
238
+ function sealedJob(source, size, upload, sealing, addresses, done) {
239
+ return { source, size, upload, range: (n) => sealing.layout.range(n), hashes: null, addresses: new Map(addresses.map((p) => [p.number, p])), fetching: null, done: new Set(done), sentBytes: 0, sealing };
240
+ }
241
+ export async function upload(calls, fetcher, source, options = {}, rule = RULE) {
113
242
  const signal = options.signal;
114
243
  const atOnce = Math.max(1, Math.min(16, options.concurrency ?? AT_ONCE));
115
244
  const size = sizeOf(source);
116
- const call = { signal };
117
245
  let job;
118
246
  if (options.resume) {
119
- const state = await calls.get(options.resume, call);
120
- if (state.upload.status === "completed" && state.upload.fileId)
121
- return (await calls.file(state.upload.fileId, call)).item;
122
- if (state.upload.status !== "pending") {
123
- throw new FileFreakError(410, `upload_${state.upload.status}`, `That upload is ${state.upload.status}, so it cannot be carried on. Begin it again.`);
124
- }
125
- if (state.upload.sizeBytes !== size) {
126
- throw new FileFreakError(400, "size_mismatch", `That upload is for ${state.upload.sizeBytes} bytes, and this source has ${size}. Carry it on with the same file it began with.`);
127
- }
128
- // Begun by a program that cut the file by its content, as filefreak does: its
129
- // parts are blocks of different sizes, and this reads part n at n times
130
- // partBytes, so every part would be sent from the wrong place and the
131
- // upload could never complete.
132
- if (state.blocks) {
133
- throw new FileFreakError(400, "cut_by_content", "That upload was begun with its file cut by content, which this SDK does not do. Carry it on with the program that began it, or cancel it and begin again.");
134
- }
135
- const sealing = state.shards ? await sealingOf(state, size) : null;
136
- job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0, sealing };
247
+ const carried = await carryOn(calls, source, size, options.resume, rule, signal);
248
+ if (!("upload" in carried))
249
+ return carried;
250
+ job = carried;
137
251
  }
138
252
  else {
139
253
  const name = options.name ?? (typeof File !== "undefined" && source instanceof File ? source.name : undefined);
140
254
  if (!name)
141
255
  throw new TypeError("Give the upload a name: { name: \"photo.jpg\" }.");
142
256
  const type = options.contentType ?? (source instanceof Blob && source.type ? source.type : undefined);
257
+ // Cut by its content first, as the CLI cuts a file, so that the blocks its next version still has are not sent again.
258
+ const blocks = await cutPlain(cuttable(source, size), rule, signal);
143
259
  const body = {
144
260
  name,
145
261
  sizeBytes: size,
146
262
  parentId: options.parentId ?? null,
147
- partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))),
263
+ ...(blocks ? { blocks } : { partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))) }),
148
264
  // Into a protected place, the parts are fragments this sends sealed.
149
265
  protectable: true,
266
+ shardFormats: WRITES_FORMATS,
150
267
  };
151
268
  if (options.onConflict)
152
269
  body.onConflict = options.onConflict;
@@ -156,19 +273,7 @@ export async function upload(calls, fetcher, source, options = {}) {
156
273
  body.contentType = type;
157
274
  if (options.imageSizes)
158
275
  body.imageSizes = options.imageSizes;
159
- const begun = await calls.begin(body, { signal, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) });
160
- job = { source, size, upload: begun.upload, addresses: new Map(begun.parts.map((p) => [p.number, p])), fetching: null, done: new Set(), sentBytes: 0, sealing: null };
161
- if (begun.shards) {
162
- // Begin's answer is kept by the API under its Idempotency-Key, so the key is asked for here.
163
- try {
164
- job.sealing = await sealingOf(await calls.get(begun.upload.id, call), size);
165
- }
166
- catch (e) {
167
- throw new UploadStopped(begun.upload.id, e);
168
- }
169
- // Nothing of this upload can have been sent yet.
170
- job.sealing.waiting.clear();
171
- }
276
+ job = await begin(calls, source, size, body, blocks, options.idempotencyKey, rule, signal);
172
277
  }
173
278
  options.onBegun?.(job.upload);
174
279
  let item;
@@ -191,10 +296,93 @@ export async function upload(calls, fetcher, source, options = {}) {
191
296
  }
192
297
  return item;
193
298
  }
299
+ /**
300
+ * Begin it. Sent as it is, its parts are its blocks when it was cut, and
301
+ * those the API has already are not sent. Sealed, its key is asked for, and
302
+ * in version 2 its blocks are cut and declared, or if it cannot be written
303
+ * in version 2 it is begun again in version 1.
304
+ */
305
+ async function begin(calls, source, size, body, blocks, idempotencyKey, rule, signal) {
306
+ const begun = await calls.begin(body, { signal, ...(idempotencyKey ? { idempotencyKey } : {}) });
307
+ const addresses = new Map(begun.parts.map((p) => [p.number, p]));
308
+ if (!begun.shards) {
309
+ const range = blocks ? blocksOf(blocks.map((b) => b.size)) : evenly(begun.upload, size);
310
+ return { source, size, upload: begun.upload, range, hashes: blocks && blocks.map((b) => b.sha256), addresses, fetching: null, done: new Set(begun.reused), sentBytes: 0, sealing: null };
311
+ }
312
+ let job;
313
+ try {
314
+ // Begin's answer is kept by the API under its Idempotency-Key, so the key is asked for here.
315
+ const state = await calls.get(begun.upload.id, { signal });
316
+ if (state.shards?.format === 2) {
317
+ // Begun again in version 1 below, and answered in version 2 all the same: going on would begin again for ever.
318
+ if (!body.shardFormats?.includes(2))
319
+ throw new FileFreakError(0, "format_unsupported", "The API began this upload in version 2 of the shard format, which it was not offered.");
320
+ const declared = await declare(calls, state, source, size, rule, signal);
321
+ job = declared && sealedJob(source, size, declared.upload, declared.sealing, declared.parts, declared.sealing.recorded);
322
+ if (!job)
323
+ await calls.cancel(begun.upload.id, { signal });
324
+ }
325
+ else {
326
+ const sealing = await sealingOf(state, size);
327
+ // Nothing of this upload can have been sent yet.
328
+ sealing.waiting.clear();
329
+ job = sealedJob(source, size, begun.upload, sealing, begun.parts, []);
330
+ }
331
+ }
332
+ catch (e) {
333
+ throw new UploadStopped(begun.upload.id, e);
334
+ }
335
+ if (job)
336
+ return job;
337
+ // Not two to ten thousand blocks, which version 2 does not take. The API
338
+ // refuses one Idempotency-Key for two bodies, so this begin goes with the
339
+ // transport's own.
340
+ return await begin(calls, source, size, { ...body, shardFormats: [1] }, blocks, undefined, rule, signal);
341
+ }
342
+ /** An upload begun before, ready to send what storage does not have: or its file, if it was finished. */
343
+ async function carryOn(calls, source, size, id, rule, signal) {
344
+ const state = await calls.get(id, { signal });
345
+ if (state.upload.status === "completed" && state.upload.fileId)
346
+ return (await calls.file(state.upload.fileId, { signal })).item;
347
+ if (state.upload.status !== "pending") {
348
+ throw new FileFreakError(410, `upload_${state.upload.status}`, `That upload is ${state.upload.status}, so it cannot be carried on. Begin it again.`);
349
+ }
350
+ if (state.upload.sizeBytes !== size) {
351
+ throw new FileFreakError(400, "size_mismatch", `That upload is for ${state.upload.sizeBytes} bytes, and this source has ${size}. Carry it on with the same file it began with.`);
352
+ }
353
+ if (state.shards?.format === 2) {
354
+ if (state.shards.blocks) {
355
+ // Declared before. What the file keeps from its last version, and what was
356
+ // recorded, are filled; anything else may be in storage, and is asked about first.
357
+ const blocks = state.shards.blocks.map((b) => ({ id: Uint8Array.from(atob(b.id), (c) => c.charCodeAt(0)), length: b.size }));
358
+ const sealing = await blockSealing(await fileKeys(keyOf(state).raw), { contentId: state.shards.contentId, size, blocks }, new Set(state.uploadedParts));
359
+ for (let n = 0; n < blocks.length; n++)
360
+ if (!sealing.recorded.has(n))
361
+ sealing.waiting.add(n);
362
+ return sealedJob(source, size, state.upload, sealing, [], state.uploadedParts);
363
+ }
364
+ // Begun and never declared: nothing of it can have been sent.
365
+ const declared = await declare(calls, state, source, size, rule, signal);
366
+ if (declared)
367
+ return sealedJob(source, size, declared.upload, declared.sealing, declared.parts, declared.sealing.recorded);
368
+ await calls.cancel(id, { signal });
369
+ throw new FileFreakError(410, "upload_aborted", "That upload cannot be carried on: the file is not cut into 2 to 10,000 blocks, which version 2 of the shard format needs, so it has been cancelled. Begin it again, and it is written in version 1.");
370
+ }
371
+ if (state.shards)
372
+ return sealedJob(source, size, state.upload, await sealingOf(state, size), [], state.uploadedParts);
373
+ // Begun cut by content: each part is its block, checked against its SHA-256 as it is sent.
374
+ const range = state.blocks ? blocksOf(state.blocks.map((b) => b.size)) : evenly(state.upload, size);
375
+ const hashes = state.blocks && state.blocks.map((b) => b.sha256.toLowerCase());
376
+ return { source, size, upload: state.upload, range, hashes, addresses: new Map(), fetching: null, done: new Set([...state.uploadedParts, ...state.reused]), sentBytes: 0, sealing: null };
377
+ }
194
378
  function tally(job, options) {
195
379
  let sent = 0;
196
- for (const n of job.done)
197
- sent += Math.min(job.size, (n + 1) * job.upload.partBytes) - n * job.upload.partBytes;
380
+ for (const n of job.done) {
381
+ if (n < 0 || n >= job.upload.partCount)
382
+ continue;
383
+ const { start, end } = job.range(n);
384
+ sent += end - start;
385
+ }
198
386
  job.sentBytes = sent;
199
387
  options.onProgress?.(sent, job.size);
200
388
  }
@@ -300,27 +488,12 @@ async function sendParts(calls, fetcher, job, atOnce, options) {
300
488
  }
301
489
  }
302
490
  async function sendPart(calls, fetcher, job, n, signal) {
303
- const sealing = job.sealing;
304
- const { start: from, end: to } = sealing ? sealing.layout.range(n) : { start: n * job.upload.partBytes, end: Math.min(job.size, (n + 1) * job.upload.partBytes) };
305
- let bytes;
306
- try {
307
- bytes = await bytesOf(job.source, from, to);
308
- }
309
- catch (e) {
310
- // Browsers and Node both refuse to read a file that changed after it was
311
- // opened, which is what keeps one from going up half old and half new.
312
- throw Object.assign(new FileFreakError(0, "unreadable", "The file could not be read. It may have been changed, moved or deleted since it was opened: open it again, unchanged, to carry on."), { cause: e });
313
- }
314
- if (sealing) {
315
- try {
316
- bytes = await sealFragment(sealing.key, { contentId: sealing.contentId, layout: sealing.layout, place: n }, bytes);
317
- }
318
- catch (e) {
319
- if (e instanceof ShardError)
320
- throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
321
- throw e;
322
- }
323
- }
491
+ const { start: from, end: to } = job.range(n);
492
+ let bytes = await read(job.source, from, to);
493
+ if (job.hashes && hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))) !== job.hashes[n])
494
+ throw changed(n);
495
+ if (job.sealing)
496
+ bytes = await job.sealing.seal(n, bytes);
324
497
  const md5 = base64(new Md5().update(bytes).digest());
325
498
  for (let attempt = 1;; attempt++) {
326
499
  const part = await address(calls, job, n, signal);
@@ -383,7 +556,11 @@ async function address(calls, job, n, signal) {
383
556
  }
384
557
  async function finish(calls, fetcher, job, atOnce, options) {
385
558
  const started = Date.now();
386
- const body = options.sha256 ? { sha256: options.sha256 } : {};
559
+ const body = {};
560
+ if (options.sha256)
561
+ body.sha256 = options.sha256;
562
+ if (job.sealing?.outline)
563
+ body.outline = job.sealing.outline;
387
564
  for (let round = 0;;) {
388
565
  try {
389
566
  const result = await calls.complete(job.upload.id, body, { signal: options.signal });
@@ -396,7 +573,8 @@ async function finish(calls, fetcher, job, atOnce, options) {
396
573
  await sleep(Math.max(1, result.retryAfterSeconds) * 1000, options.signal);
397
574
  }
398
575
  catch (e) {
399
- // Storage does not have every part after all. Send what is missing.
576
+ // Storage does not have every part after all, or in version 2 a block the
577
+ // file kept went with the last version that had it. Send what is missing.
400
578
  if (e instanceof FileFreakError && e.code === "upload_incomplete" && round++ < 2) {
401
579
  const have = Array.isArray(e.body?.uploadedParts) ? e.body.uploadedParts.filter((n) => typeof n === "number") : [];
402
580
  job.done = new Set(have);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@filefreak.io/sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",