@3sln/trove 0.0.9 → 0.0.10
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/package.json +1 -1
- package/packages/core/src/vfs.js +78 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@3sln/trove",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
|
|
6
6
|
"repository": {
|
package/packages/core/src/vfs.js
CHANGED
|
@@ -12,8 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
import { TroveError, isOutOfSpace } from './errors.js';
|
|
14
14
|
import { UploadManager } from './uploads.js';
|
|
15
|
-
import { toHex } from './encryption/keys.js';
|
|
16
|
-
import {
|
|
15
|
+
import { fromHex, toHex } from './encryption/keys.js';
|
|
16
|
+
import {
|
|
17
|
+
encrypt, decryptStream, decodeHeader, cipherRangeFor, cipherSize, HEADER_BYTES,
|
|
18
|
+
DEFAULT_CHUNK_SIZE,
|
|
19
|
+
} from './encryption/envelope.js';
|
|
20
|
+
import { shouldEncrypt } from './encryption/policy.js';
|
|
17
21
|
import { IndexerRegistry } from './indexers/registry.js';
|
|
18
22
|
import { ParsingSearchTransformer, matchTagFilters } from './search/transformer.js';
|
|
19
23
|
import { extname } from './util.js';
|
|
@@ -56,6 +60,24 @@ async function bytesOf(stream) {
|
|
|
56
60
|
return out;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Whatever `writeFile` was handed, as bytes.
|
|
65
|
+
*
|
|
66
|
+
* Sealing needs the plaintext size before it can write the envelope header, and this path
|
|
67
|
+
* is the convenience write for things already resident, so buffering costs nothing that was
|
|
68
|
+
* not already paid.
|
|
69
|
+
*/
|
|
70
|
+
async function bytesOfBody(body) {
|
|
71
|
+
if (body == null) return new Uint8Array(0);
|
|
72
|
+
if (typeof body === 'string') return new TextEncoder().encode(body);
|
|
73
|
+
if (body instanceof Uint8Array) return body;
|
|
74
|
+
if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
75
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
|
76
|
+
if (typeof body.arrayBuffer === 'function') return new Uint8Array(await body.arrayBuffer());
|
|
77
|
+
if (typeof body.getReader === 'function') return bytesOf(body);
|
|
78
|
+
return new Uint8Array(body);
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
/**
|
|
60
82
|
* Emit only the bytes between `start` and `end` of a stream.
|
|
61
83
|
*
|
|
@@ -132,6 +154,32 @@ export class Vfs {
|
|
|
132
154
|
if (this.collections) await this.collections.init();
|
|
133
155
|
}
|
|
134
156
|
|
|
157
|
+
/**
|
|
158
|
+
* What this collection wants sealed, and the key for it — or null.
|
|
159
|
+
*
|
|
160
|
+
* The same question `UploadManager` asks through its injected `encryptionFor`, including
|
|
161
|
+
* `shouldEncrypt`, so a collection with per-item rules answers identically whichever way
|
|
162
|
+
* the bytes arrive.
|
|
163
|
+
*/
|
|
164
|
+
async #sealingFor(collectionId, name, contentType) {
|
|
165
|
+
if (!this.collections?.encryptionFor) return null;
|
|
166
|
+
// A collection record that is not there cannot ask for anything. `writeFile` defaults
|
|
167
|
+
// to 'default', which on a zero-config drive is a storage backend rather than a
|
|
168
|
+
// collection anyone created, and asking about it throws rather than answering "no".
|
|
169
|
+
let encryption;
|
|
170
|
+
try {
|
|
171
|
+
encryption = await this.collections.encryptionFor(collectionId);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err?.code === 'not_found') return null;
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
if (!encryption?.enabled) return null;
|
|
177
|
+
if (!shouldEncrypt(encryption, { name, contentType })) return null;
|
|
178
|
+
const key = await this.collections.dataKeyFor(collectionId);
|
|
179
|
+
if (!key) throw TroveError.internal('This collection is encrypted but its key is unavailable');
|
|
180
|
+
return { key, fingerprint: fromHex(encryption.fingerprint), chunkSize: encryption.chunkSize || DEFAULT_CHUNK_SIZE };
|
|
181
|
+
}
|
|
182
|
+
|
|
135
183
|
/** Resolve the storage backend for a collection. */
|
|
136
184
|
async storageFor(collectionId = 'default') {
|
|
137
185
|
if (this.collections) return this.collections.storageFor(collectionId);
|
|
@@ -256,9 +304,31 @@ export class Vfs {
|
|
|
256
304
|
const storageKey = `obj_${cryptoId()}`;
|
|
257
305
|
const ct = contentType || this.guessContentType(name);
|
|
258
306
|
const storage = await this.storageFor(collectionId);
|
|
307
|
+
// Sealed here too, if the collection says so.
|
|
308
|
+
//
|
|
309
|
+
// This path wrote straight to the bucket and recorded the item with no `encryption`,
|
|
310
|
+
// never asking — so a server-side write put a READABLE file in a collection someone had
|
|
311
|
+
// set up to be encrypted, and stamped it as unencrypted so the read path served it back
|
|
312
|
+
// happily and nothing ever said otherwise. Once the drive started sealing uploads, this
|
|
313
|
+
// was the only remaining way to get plaintext into an encrypted bucket.
|
|
314
|
+
const sealing = await this.#sealingFor(collectionId, name, ct);
|
|
315
|
+
let toStore = body;
|
|
316
|
+
let encryption = null;
|
|
317
|
+
let plaintextSize = null;
|
|
318
|
+
if (sealing) {
|
|
319
|
+
// Buffered, unlike the upload path, which streams. This is the convenience write for
|
|
320
|
+
// things already resident — a sidecar, a test fixture, an in-process import — and the
|
|
321
|
+
// envelope needs the plaintext size before it can write its header.
|
|
322
|
+
const plain = await bytesOfBody(body);
|
|
323
|
+
plaintextSize = plain.length;
|
|
324
|
+
toStore = await encrypt(sealing.key, plain, {
|
|
325
|
+
fingerprint: sealing.fingerprint, chunkSize: sealing.chunkSize,
|
|
326
|
+
});
|
|
327
|
+
encryption = { fingerprint: toHex(sealing.fingerprint), chunkSize: sealing.chunkSize };
|
|
328
|
+
}
|
|
259
329
|
let info;
|
|
260
330
|
try {
|
|
261
|
-
info = await storage.put(storageKey,
|
|
331
|
+
info = await storage.put(storageKey, toStore, { contentType: ct, signal });
|
|
262
332
|
} catch (err) {
|
|
263
333
|
// A write that failed for lack of room is a standing condition, not one bad
|
|
264
334
|
// request: the next upload will fail the same way. Record it so it is visible
|
|
@@ -266,7 +336,11 @@ export class Vfs {
|
|
|
266
336
|
if (isOutOfSpace(err)) await this.storageUsage(collectionId).catch(() => {});
|
|
267
337
|
throw err;
|
|
268
338
|
}
|
|
269
|
-
|
|
339
|
+
// The size the user sees is the FILE's, not the envelope's.
|
|
340
|
+
const node = await this.#upsertItem({
|
|
341
|
+
collectionId, name, storageKey, size: plaintextSize ?? info.size, contentType: ct,
|
|
342
|
+
etag: info.etag, encryption,
|
|
343
|
+
});
|
|
270
344
|
// Small server-side writes index synchronously (search is ready on return);
|
|
271
345
|
// large client uploads (completeUpload) index in the background instead.
|
|
272
346
|
await this.indexing.indexNode(node).catch((e) => console.error('index error', e));
|