@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.9",
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": {
@@ -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 { decryptStream, decodeHeader, cipherRangeFor, cipherSize, HEADER_BYTES } from './encryption/envelope.js';
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, body, { contentType: ct, signal });
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
- const node = await this.#upsertItem({ collectionId, name, storageKey, size: info.size, contentType: ct, etag: info.etag });
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));