sealkeep 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/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { decryptArchive as openEnvelope, encryptArchive as sealEnvelope } from "../packages/vaultline-crypto/src/index.js";
|
|
5
|
+
import { fail } from "./errors.js";
|
|
6
|
+
import { canonicalPhrase } from "./mnemonic.js";
|
|
7
|
+
import { decryptRecord, listArchives, readConfig } from "./vault.js";
|
|
8
|
+
import { openChunkWindow, supportsChunkAccess } from "../packages/vaultline-crypto/src/chunk-access.js";
|
|
9
|
+
import { isV2 } from "./types.js";
|
|
10
|
+
const indexPath = (dataDir) => join(dataDir, "index", "content-index.vlindex");
|
|
11
|
+
const envelopePath = (dataDir) => join(dataDir, "index", "content-index.json");
|
|
12
|
+
const coveragePath = (dataDir) => join(dataDir, "index", "coverage.json");
|
|
13
|
+
const MIN_TOKEN = 3;
|
|
14
|
+
const MAX_TOKENS_PER_ARCHIVE = 4000;
|
|
15
|
+
/**
|
|
16
|
+
* What deserves an index slot. Transcripts are full of hex digests, base64
|
|
17
|
+
* blobs, and minified identifiers; every one of those admitted is a real word
|
|
18
|
+
* crowded out of the cap. Queries still tokenize unfiltered — searching FOR a
|
|
19
|
+
* long id simply misses content and falls to metadata search, which owns ids.
|
|
20
|
+
*/
|
|
21
|
+
export function indexableToken(token) {
|
|
22
|
+
if (token.length > 24)
|
|
23
|
+
return false;
|
|
24
|
+
if (token.length >= 12 && /^[0-9a-f]+$/.test(token))
|
|
25
|
+
return false;
|
|
26
|
+
if (token.length >= 16 && /^[A-Za-z0-9+/=_-]+$/.test(token) && !/[aeiou]{1}/i.test(token.slice(0, 8)))
|
|
27
|
+
return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Round-robin merge of per-chunk token sets: every chunk gets a fair slice of
|
|
32
|
+
* the archive's token budget. First-chunks-win was the live finding — with a
|
|
33
|
+
* 40k budget, chunks 0–7 of a 399-chunk archive consumed everything and a
|
|
34
|
+
* word living in chunk 50 stayed unsearchable.
|
|
35
|
+
*/
|
|
36
|
+
export function mergeChunkTokens(byChunk, cap) {
|
|
37
|
+
const merged = new Map();
|
|
38
|
+
const iterators = [...byChunk.entries()]
|
|
39
|
+
.sort(([a], [b]) => a - b)
|
|
40
|
+
.map(([chunkIndex, tokens]) => ({ chunkIndex, iterator: tokens[Symbol.iterator]() }));
|
|
41
|
+
let admitted = 0;
|
|
42
|
+
let progressed = true;
|
|
43
|
+
while (progressed && iterators.length > 0) {
|
|
44
|
+
progressed = false;
|
|
45
|
+
for (const lane of iterators) {
|
|
46
|
+
let step = lane.iterator.next();
|
|
47
|
+
while (!step.done) {
|
|
48
|
+
const token = step.value;
|
|
49
|
+
const existing = merged.get(token);
|
|
50
|
+
if (existing) {
|
|
51
|
+
if (existing.size < 2)
|
|
52
|
+
existing.add(lane.chunkIndex);
|
|
53
|
+
step = lane.iterator.next();
|
|
54
|
+
continue; // an already-admitted token only collects a tag; keep drawing for a NEW one
|
|
55
|
+
}
|
|
56
|
+
if (admitted < cap) {
|
|
57
|
+
merged.set(token, new Set([lane.chunkIndex]));
|
|
58
|
+
admitted += 1;
|
|
59
|
+
progressed = true;
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (admitted >= cap)
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
// Budget spent or lanes dry: sweep remaining occurrences so admitted tokens
|
|
68
|
+
// still collect their second tag.
|
|
69
|
+
if (admitted >= cap) {
|
|
70
|
+
for (const lane of iterators) {
|
|
71
|
+
let step = lane.iterator.next();
|
|
72
|
+
while (!step.done) {
|
|
73
|
+
const existing = merged.get(step.value);
|
|
74
|
+
if (existing && existing.size < 2)
|
|
75
|
+
existing.add(lane.chunkIndex);
|
|
76
|
+
step = lane.iterator.next();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return merged;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The token budget grows with the archive: a 40 KB session and a 1.7 GB one
|
|
84
|
+
* do not deserve the same 4000 slots — the live drill proved the flat cap
|
|
85
|
+
* starves everything past the first chunk of a big archive ("retention"
|
|
86
|
+
* indexed, "chunk" lost). Bounded so the phonebook stays a phonebook.
|
|
87
|
+
*/
|
|
88
|
+
export function tokenCapFor(sourceBytes) {
|
|
89
|
+
// 72 tokens/MB, capped at 120k: a 1.7 GB transcript's real (junk-filtered)
|
|
90
|
+
// vocabulary measured ~40–100k distinct words, and a 40k budget cut the
|
|
91
|
+
// tail off — "chunk", 141 honest mentions, lost every per-chunk seat. At
|
|
92
|
+
// ~100 bytes per entry the whale archive costs ~2 MB gzipped in the index;
|
|
93
|
+
// ordinary sessions stay in the low thousands.
|
|
94
|
+
return Math.min(120_000, MAX_TOKENS_PER_ARCHIVE + Math.floor(sourceBytes / (1024 * 1024)) * 72);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Own-property-safe append to the tokens map. A transcript containing the
|
|
98
|
+
* word "constructor" made `tokens[token] ??= []` find — and push into —
|
|
99
|
+
* Object.prototype.constructor. Every read and write of a token entry must
|
|
100
|
+
* go through own-property checks; the token space is attacker-adjacent input.
|
|
101
|
+
*/
|
|
102
|
+
function pushTokenTag(tokens, token, tag) {
|
|
103
|
+
if (Object.prototype.hasOwnProperty.call(tokens, token) && Array.isArray(tokens[token]))
|
|
104
|
+
tokens[token].push(tag);
|
|
105
|
+
else
|
|
106
|
+
tokens[token] = [tag];
|
|
107
|
+
}
|
|
108
|
+
/** Own-property-safe read: inherited names ("constructor", "toString") answer as absent. */
|
|
109
|
+
export function tokenEntries(tokens, token) {
|
|
110
|
+
return Object.prototype.hasOwnProperty.call(tokens, token) && Array.isArray(tokens[token]) ? tokens[token] : [];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A query term also matches indexed tokens it prefixes — "chunk" finds
|
|
114
|
+
* "chunks" and "chunkbytes", which is what a person typing it meant. Bounded
|
|
115
|
+
* fan-out; exact entries come first so precise queries stay precise.
|
|
116
|
+
*/
|
|
117
|
+
export function tokenEntriesExpanded(tokens, term) {
|
|
118
|
+
const exact = tokenEntries(tokens, term);
|
|
119
|
+
if (term.length < MIN_TOKEN)
|
|
120
|
+
return exact;
|
|
121
|
+
const expanded = [...exact];
|
|
122
|
+
let fanOut = 0;
|
|
123
|
+
for (const key of Object.keys(tokens)) {
|
|
124
|
+
if (key === term || !key.startsWith(term))
|
|
125
|
+
continue;
|
|
126
|
+
expanded.push(...tokenEntries(tokens, key));
|
|
127
|
+
fanOut += 1;
|
|
128
|
+
if (fanOut >= 40)
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
return expanded;
|
|
132
|
+
}
|
|
133
|
+
export function tokenize(text) {
|
|
134
|
+
const out = new Set();
|
|
135
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9_.@/-]+/)) {
|
|
136
|
+
if (raw.length < MIN_TOKEN)
|
|
137
|
+
continue;
|
|
138
|
+
out.add(raw);
|
|
139
|
+
// Compounds also index by their segments: "chunk-store.ts" answers for
|
|
140
|
+
// "chunk" and "store", and a hyphenated marker longer than the storage
|
|
141
|
+
// cap is still findable through every piece of itself. Queries run the
|
|
142
|
+
// same split, so a compound query becomes an AND of its segments.
|
|
143
|
+
if (/[-._/@]/.test(raw)) {
|
|
144
|
+
for (const segment of raw.split(/[-._/@]+/)) {
|
|
145
|
+
if (segment.length >= MIN_TOKEN)
|
|
146
|
+
out.add(segment);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return [...out];
|
|
151
|
+
}
|
|
152
|
+
function metadataHaystack(record) {
|
|
153
|
+
return `${record.id} ${record.source.agent} ${record.source.path} ${record.createdAt}`.toLowerCase();
|
|
154
|
+
}
|
|
155
|
+
/** Metadata search needs no secret and never touches an archive's contents. */
|
|
156
|
+
export async function searchMetadata(dataDir, query, options = {}) {
|
|
157
|
+
const needle = query.trim().toLowerCase();
|
|
158
|
+
if (!needle)
|
|
159
|
+
fail("invalid_argument", "A search query is required");
|
|
160
|
+
return (await listArchives(dataDir))
|
|
161
|
+
.filter((record) => (!options.agent || record.source.agent === options.agent) && metadataHaystack(record).includes(needle))
|
|
162
|
+
.map((record) => ({ id: record.id, agent: record.source.agent, path: record.source.path, bytes: record.source.bytes, createdAt: record.createdAt, matched: "metadata" }));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Builds the opt-in content index.
|
|
166
|
+
*
|
|
167
|
+
* This decrypts every archive locally, so it requires the recovery phrase. The
|
|
168
|
+
* resulting index is itself encrypted with the same envelope before it touches the
|
|
169
|
+
* disk, and it is never uploaded: `sealkeep upload` only ever sends archive objects.
|
|
170
|
+
*/
|
|
171
|
+
export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
|
|
172
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
173
|
+
await readConfig(dataDir);
|
|
174
|
+
const records = await listArchives(dataDir);
|
|
175
|
+
// Start from what already exists — the remote index when it is newer (a
|
|
176
|
+
// teammate's machine may have indexed archives this one never decrypted),
|
|
177
|
+
// the local one otherwise. Rebuilding from zero used to be the only mode,
|
|
178
|
+
// which on a second machine meant fetching and decrypting the whole
|
|
179
|
+
// history to answer one search.
|
|
180
|
+
const { remoteIndexStoreFor, pullIndexIfNewer, localIndexBuiltAt } = await import("./index-sync.js");
|
|
181
|
+
const store = options.sync === false ? null : await remoteIndexStoreFor(dataDir, phrase);
|
|
182
|
+
let pulled = false;
|
|
183
|
+
if (store) {
|
|
184
|
+
const outcome = await pullIndexIfNewer(dataDir, store, await localIndexBuiltAt(envelopePath(dataDir)), async (envelope, ciphertext) => {
|
|
185
|
+
await mkdir(join(dataDir, "index"), { recursive: true, mode: 0o700 });
|
|
186
|
+
await writeFile(indexPath(dataDir), ciphertext, { mode: 0o600 });
|
|
187
|
+
await writeFile(envelopePath(dataDir), JSON.stringify(envelope, null, 2) + "\n", { mode: 0o600 });
|
|
188
|
+
});
|
|
189
|
+
pulled = outcome.pulled;
|
|
190
|
+
}
|
|
191
|
+
let index;
|
|
192
|
+
try {
|
|
193
|
+
const existing = normalizeContentIndex(await loadContentIndex(dataDir, phrase));
|
|
194
|
+
// Merge-by-union is safe because an archive's tokens are a pure function
|
|
195
|
+
// of its content: whoever indexed it got the same answer.
|
|
196
|
+
index = { version: 2, builtAt: new Date().toISOString(), archives: existing.archives, tokens: existing.tokens };
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
index = { version: 2, builtAt: new Date().toISOString(), archives: {}, tokens: {} };
|
|
200
|
+
}
|
|
201
|
+
// Drop entries for archives that no longer exist, then index only what is
|
|
202
|
+
// NEW — the difference between "re-decrypt 53 archives" and "decrypt the
|
|
203
|
+
// two sealed since the last build".
|
|
204
|
+
const liveIds = new Set(records.map((record) => record.id));
|
|
205
|
+
for (const id of Object.keys(index.archives)) {
|
|
206
|
+
if (liveIds.has(id))
|
|
207
|
+
continue;
|
|
208
|
+
delete index.archives[id];
|
|
209
|
+
for (const token of Object.keys(index.tokens)) {
|
|
210
|
+
index.tokens[token] = index.tokens[token].filter((entry) => entry !== id && !entry.startsWith(`${id}@`));
|
|
211
|
+
if (index.tokens[token].length === 0)
|
|
212
|
+
delete index.tokens[token];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const fresh = records.filter((record) => !index.archives[record.id]);
|
|
216
|
+
let indexedNow = 0;
|
|
217
|
+
let skipped = 0;
|
|
218
|
+
for (const record of fresh) {
|
|
219
|
+
// One flaky download must not kill a 54-archive build: each archive gets
|
|
220
|
+
// two tries, then the build moves on and reports what it left for the
|
|
221
|
+
// next run. The archives entry is only written on success — a listed id
|
|
222
|
+
// with no tokens would be "indexed" yet unfindable forever.
|
|
223
|
+
try {
|
|
224
|
+
try {
|
|
225
|
+
await indexOneRecord(dataDir, index, record, phrase);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
await indexOneRecord(dataDir, index, record, phrase);
|
|
229
|
+
}
|
|
230
|
+
indexedNow += 1;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
skipped += 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const pushed = await persistContentIndex(dataDir, phrase, index, store);
|
|
237
|
+
return { archives: Object.keys(index.archives).length, tokens: Object.keys(index.tokens).length, builtAt: index.builtAt, indexedNow, skipped, pulled, pushed };
|
|
238
|
+
}
|
|
239
|
+
/** Indexes one archive into `index`, chunk-tagged when the format allows, windowed otherwise. Writes the archives entry LAST, so a failure leaves no half-indexed id. */
|
|
240
|
+
async function indexOneRecord(dataDir, index, record, phrase) {
|
|
241
|
+
const cap = tokenCapFor(record.source.bytes);
|
|
242
|
+
if (isV2(record) && supportsChunkAccess(record.envelope)) {
|
|
243
|
+
const { readFile: readBlob } = await import("node:fs/promises");
|
|
244
|
+
const { materialiseArchive } = await import("./offload.js");
|
|
245
|
+
const materialised = await materialiseArchive(dataDir, record);
|
|
246
|
+
try {
|
|
247
|
+
const stored = await readBlob(materialised.path);
|
|
248
|
+
const byChunk = new Map();
|
|
249
|
+
const perChunkCap = Math.max(4000, Math.floor(cap / 4));
|
|
250
|
+
let offset = 0;
|
|
251
|
+
for (const chunk of record.envelope.chunks) {
|
|
252
|
+
const slice = stored.subarray(offset, offset + chunk.storedBytes);
|
|
253
|
+
offset += chunk.storedBytes;
|
|
254
|
+
const window = { firstIndex: chunk.index, lastIndex: chunk.index, storedOffset: 0, storedLength: chunk.storedBytes, rawStartInWindow: 0, rawLengthInWindow: chunk.rawBytes ?? 0 };
|
|
255
|
+
const raw = openChunkWindow(record.envelope, window, slice, { phrase });
|
|
256
|
+
const bucket = new Set();
|
|
257
|
+
for (const token of tokenize(raw.toString("utf8"))) {
|
|
258
|
+
if (bucket.size >= perChunkCap)
|
|
259
|
+
break;
|
|
260
|
+
if (!indexableToken(token))
|
|
261
|
+
continue;
|
|
262
|
+
bucket.add(token);
|
|
263
|
+
}
|
|
264
|
+
byChunk.set(chunk.index, bucket);
|
|
265
|
+
raw.fill(0);
|
|
266
|
+
}
|
|
267
|
+
// Round-robin merge: every chunk gets a fair slice of the budget, so a
|
|
268
|
+
// word living only in chunk 50 is as findable as one from chunk 0.
|
|
269
|
+
for (const [token, chunkSet] of mergeChunkTokens(byChunk, cap)) {
|
|
270
|
+
for (const chunkIndex of [...chunkSet].slice(0, 2))
|
|
271
|
+
pushTokenTag(index.tokens, token, `${record.id}@c${chunkIndex}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
await materialised.release();
|
|
276
|
+
}
|
|
277
|
+
index.archives[record.id] = [record.source.agent, record.source.path];
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const { plaintext } = await decryptRecord(dataDir, record.id, phrase);
|
|
281
|
+
// Windowed, never one giant string: a legacy archive can decrypt to more
|
|
282
|
+
// than Node's ~512 MB string ceiling, and the live index build died on
|
|
283
|
+
// exactly that ("Cannot create a string longer than 0x1fffffe8"). Small
|
|
284
|
+
// overlap keeps tokens that straddle a window boundary.
|
|
285
|
+
const seen = new Set();
|
|
286
|
+
const WINDOW = 8 * 1024 * 1024;
|
|
287
|
+
const OVERLAP = 256;
|
|
288
|
+
for (let at = 0; at < plaintext.length && seen.size < cap; at += WINDOW) {
|
|
289
|
+
const slice = plaintext.subarray(Math.max(0, at - OVERLAP), Math.min(plaintext.length, at + WINDOW));
|
|
290
|
+
for (const token of tokenize(slice.toString("utf8"))) {
|
|
291
|
+
if (!indexableToken(token))
|
|
292
|
+
continue;
|
|
293
|
+
seen.add(token);
|
|
294
|
+
if (seen.size >= cap)
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const token of seen)
|
|
299
|
+
pushTokenTag(index.tokens, token, record.id);
|
|
300
|
+
plaintext.fill(0);
|
|
301
|
+
index.archives[record.id] = [record.source.agent, record.source.path];
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Seals the index to disk atomically and pushes it to the vault's remote slot
|
|
305
|
+
* when one is reachable. Sealed local, so sealed remote: pushing costs two
|
|
306
|
+
* PUTs of ciphertext and buys every other machine of this vault a search that
|
|
307
|
+
* needs no rebuild. A token index is highly repetitive JSON and compresses hard.
|
|
308
|
+
*/
|
|
309
|
+
async function persistContentIndex(dataDir, phrase, index, store) {
|
|
310
|
+
const { envelope, ciphertext } = sealEnvelope(Buffer.from(JSON.stringify(index)), { recipients: [{ kind: "phrase", phrase }], compression: "gzip", adapter: { agent: "vaultline-index", version: "1" } });
|
|
311
|
+
const target = indexPath(dataDir);
|
|
312
|
+
await mkdir(join(dataDir, "index"), { recursive: true, mode: 0o700 });
|
|
313
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
314
|
+
await writeFile(temp, ciphertext, { mode: 0o600 });
|
|
315
|
+
await rename(temp, target);
|
|
316
|
+
await writeFile(envelopePath(dataDir), JSON.stringify(envelope, null, 2) + "\n", { mode: 0o600 });
|
|
317
|
+
// The unsealed coverage sidecar: WHICH archive ids are in the index, and
|
|
318
|
+
// nothing else. Ids are metadata the records directory already shows, so
|
|
319
|
+
// status, doctor, and the panel can answer "what is searchable and what is
|
|
320
|
+
// not" without asking for the phrase. Tokens stay sealed next door.
|
|
321
|
+
await writeFile(coveragePath(dataDir), JSON.stringify({ builtAt: index.builtAt, indexed: Object.keys(index.archives).sort() }, null, 2) + "\n", { mode: 0o600 });
|
|
322
|
+
if (!store)
|
|
323
|
+
return false;
|
|
324
|
+
try {
|
|
325
|
+
await store.push(envelope, ciphertext);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false; /* an offline build is still a build; the next one pushes */
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Seal-time indexing: the seal already held every plaintext slice in its
|
|
334
|
+
* hands, so the tokens arrive here for free — no downloading an archive back
|
|
335
|
+
* just to read what was just read. Merges one archive's chunk-tagged tokens
|
|
336
|
+
* into the index (pulling a newer remote first, exactly like a build) and
|
|
337
|
+
* persists. Failure must never fail a seal; callers treat this as advisory.
|
|
338
|
+
*/
|
|
339
|
+
export async function indexArchiveTokens(dataDir, rawPhrase, record, tokensByChunk, options = {}) {
|
|
340
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
341
|
+
const { remoteIndexStoreFor, pullIndexIfNewer, localIndexBuiltAt } = await import("./index-sync.js");
|
|
342
|
+
const store = options.sync === false ? null : await remoteIndexStoreFor(dataDir, phrase);
|
|
343
|
+
if (store) {
|
|
344
|
+
await pullIndexIfNewer(dataDir, store, await localIndexBuiltAt(envelopePath(dataDir)), async (envelope, ciphertext) => {
|
|
345
|
+
await mkdir(join(dataDir, "index"), { recursive: true, mode: 0o700 });
|
|
346
|
+
await writeFile(indexPath(dataDir), ciphertext, { mode: 0o600 });
|
|
347
|
+
await writeFile(envelopePath(dataDir), JSON.stringify(envelope, null, 2) + "\n", { mode: 0o600 });
|
|
348
|
+
}).catch(() => undefined);
|
|
349
|
+
}
|
|
350
|
+
let index;
|
|
351
|
+
try {
|
|
352
|
+
index = normalizeContentIndex(await loadContentIndex(dataDir, phrase));
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
index = { version: 2, builtAt: new Date().toISOString(), archives: {}, tokens: {} };
|
|
356
|
+
}
|
|
357
|
+
// Replace any previous entries for this archive id, then apply the same
|
|
358
|
+
// caps the build applies: bounded tokens per archive, two tags per token.
|
|
359
|
+
for (const token of Object.keys(index.tokens)) {
|
|
360
|
+
index.tokens[token] = index.tokens[token].filter((entry) => entry !== record.id && !entry.startsWith(`${record.id}@`));
|
|
361
|
+
if (index.tokens[token].length === 0)
|
|
362
|
+
delete index.tokens[token];
|
|
363
|
+
}
|
|
364
|
+
index.archives[record.id] = [record.source.agent, record.source.path];
|
|
365
|
+
const filtered = new Map();
|
|
366
|
+
for (const [chunkIndex, tokens] of tokensByChunk) {
|
|
367
|
+
const keep = new Set();
|
|
368
|
+
for (const token of tokens)
|
|
369
|
+
if (indexableToken(token))
|
|
370
|
+
keep.add(token);
|
|
371
|
+
filtered.set(chunkIndex, keep);
|
|
372
|
+
}
|
|
373
|
+
const merged = mergeChunkTokens(filtered, tokenCapFor(record.source.bytes));
|
|
374
|
+
for (const [token, chunkSet] of merged) {
|
|
375
|
+
for (const chunkIndex of [...chunkSet].slice(0, 2))
|
|
376
|
+
pushTokenTag(index.tokens, token, `${record.id}@c${chunkIndex}`);
|
|
377
|
+
}
|
|
378
|
+
index.builtAt = new Date().toISOString();
|
|
379
|
+
const pushed = await persistContentIndex(dataDir, phrase, index, store);
|
|
380
|
+
return { pushed };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Whatever shape an old index decrypts to, the build merges only what it can
|
|
384
|
+
* use: token entries must be arrays of archive tags, archive entries must
|
|
385
|
+
* exist. The live rebuild found a version-1 index whose token values were not
|
|
386
|
+
* arrays — `.push is not a function`, four archives in. Anything unusable is
|
|
387
|
+
* dropped here and simply re-indexed, which is what "incremental" owes its
|
|
388
|
+
* caller anyway.
|
|
389
|
+
*/
|
|
390
|
+
export function normalizeContentIndex(raw) {
|
|
391
|
+
const source = (typeof raw === "object" && raw !== null ? raw : {});
|
|
392
|
+
const archives = {};
|
|
393
|
+
for (const [id, value] of Object.entries(source.archives ?? {})) {
|
|
394
|
+
archives[id] = Array.isArray(value) ? value.map(String) : [];
|
|
395
|
+
}
|
|
396
|
+
const tokens = {};
|
|
397
|
+
for (const [token, value] of Object.entries(source.tokens ?? {})) {
|
|
398
|
+
if (Array.isArray(value))
|
|
399
|
+
tokens[token] = value.filter((entry) => typeof entry === "string");
|
|
400
|
+
else if (typeof value === "string")
|
|
401
|
+
tokens[token] = [value];
|
|
402
|
+
// anything else (counts, objects from the v1 experiment) is dropped
|
|
403
|
+
}
|
|
404
|
+
// An archive no surviving token points at would be listed as indexed yet
|
|
405
|
+
// unfindable forever. Unlisting it here makes the next build re-index it —
|
|
406
|
+
// the one-time cost that turns a relic index into a working one.
|
|
407
|
+
const referenced = new Set();
|
|
408
|
+
for (const entries of Object.values(tokens))
|
|
409
|
+
for (const entry of entries)
|
|
410
|
+
referenced.add(entry.split("@")[0]);
|
|
411
|
+
for (const id of Object.keys(archives))
|
|
412
|
+
if (!referenced.has(id))
|
|
413
|
+
delete archives[id];
|
|
414
|
+
return { version: 2, builtAt: typeof source.builtAt === "string" ? source.builtAt : new Date().toISOString(), archives, tokens };
|
|
415
|
+
}
|
|
416
|
+
export async function loadContentIndex(dataDir, rawPhrase) {
|
|
417
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
418
|
+
const envelope = await readFile(envelopePath(dataDir), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
419
|
+
const ciphertext = await readFile(indexPath(dataDir)).catch(() => null);
|
|
420
|
+
if (!envelope || !ciphertext)
|
|
421
|
+
fail("invalid_argument", "No local content index. Build one with: sealkeep index build", { dataDir });
|
|
422
|
+
try {
|
|
423
|
+
return JSON.parse(openEnvelope(envelope, ciphertext, { phrase }).toString("utf8"));
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return fail("recovery_phrase_mismatch", "The content index could not be opened with this recovery phrase");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
export async function dropContentIndex(dataDir) {
|
|
430
|
+
await rm(indexPath(dataDir), { force: true });
|
|
431
|
+
await rm(envelopePath(dataDir), { force: true });
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Content search over the opt-in index. Every returned snippet is read from the
|
|
435
|
+
* archive at query time, so nothing readable is kept on disk between searches.
|
|
436
|
+
*/
|
|
437
|
+
export async function searchContent(dataDir, query, rawPhrase, options = {}) {
|
|
438
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
439
|
+
const terms = tokenize(query);
|
|
440
|
+
if (terms.length === 0)
|
|
441
|
+
fail("invalid_argument", "A content query needs a term of at least three characters");
|
|
442
|
+
// Terms the index could never hold (an over-long compound, a hex digest)
|
|
443
|
+
// must not be REQUIRED — their segments carry the query. They still count
|
|
444
|
+
// for snippet line matching, where the literal text exists.
|
|
445
|
+
const requiredTerms = terms.filter((term) => indexableToken(term));
|
|
446
|
+
const effectiveTerms = requiredTerms.length > 0 ? requiredTerms : terms;
|
|
447
|
+
// A teammate's machine may have pushed a newer index since this one last
|
|
448
|
+
// built — one tiny meta GET decides, and search answers from the freshest
|
|
449
|
+
// sealed index either side has produced. Tolerated failure: offline search
|
|
450
|
+
// over yesterday's index beats no search.
|
|
451
|
+
try {
|
|
452
|
+
const { remoteIndexStoreFor, pullIndexIfNewer, localIndexBuiltAt } = await import("./index-sync.js");
|
|
453
|
+
const store = await remoteIndexStoreFor(dataDir, phrase);
|
|
454
|
+
if (store) {
|
|
455
|
+
await pullIndexIfNewer(dataDir, store, await localIndexBuiltAt(envelopePath(dataDir)), async (envelope, ciphertext) => {
|
|
456
|
+
await mkdir(join(dataDir, "index"), { recursive: true, mode: 0o700 });
|
|
457
|
+
await writeFile(indexPath(dataDir), ciphertext, { mode: 0o600 });
|
|
458
|
+
await writeFile(envelopePath(dataDir), JSON.stringify(envelope, null, 2) + "\n", { mode: 0o600 });
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch { /* the local index still answers */ }
|
|
463
|
+
const index = await loadContentIndex(dataDir, phrase);
|
|
464
|
+
const counts = new Map();
|
|
465
|
+
const chunkTags = new Map();
|
|
466
|
+
for (const term of effectiveTerms) {
|
|
467
|
+
// One term may carry several chunk tags for the same archive; a match is
|
|
468
|
+
// still ONE term seen — counting entries instead of archives would let a
|
|
469
|
+
// single-term archive impersonate an every-term one.
|
|
470
|
+
const idsThisTerm = new Set();
|
|
471
|
+
for (const entry of tokenEntriesExpanded(index.tokens, term)) {
|
|
472
|
+
const at = entry.indexOf("@c");
|
|
473
|
+
const id = at === -1 ? entry : entry.slice(0, at);
|
|
474
|
+
idsThisTerm.add(id);
|
|
475
|
+
if (at !== -1) {
|
|
476
|
+
const tags = chunkTags.get(id) ?? new Set();
|
|
477
|
+
tags.add(Number(entry.slice(at + 2)));
|
|
478
|
+
chunkTags.set(id, tags);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const id of idsThisTerm)
|
|
482
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
483
|
+
}
|
|
484
|
+
const matches = [...counts.entries()].filter(([, hitCount]) => hitCount === effectiveTerms.length).map(([id]) => id);
|
|
485
|
+
const records = await listArchives(dataDir);
|
|
486
|
+
const hits = [];
|
|
487
|
+
for (const id of matches) {
|
|
488
|
+
// A matched id with no local record used to be dropped silently — which
|
|
489
|
+
// made a second machine of the vault blind to everything it had not
|
|
490
|
+
// archived itself, defeating the shared index entirely. The archive is
|
|
491
|
+
// self-carrying in the cloud (envelope sidecar + chunk objects), so the
|
|
492
|
+
// hit is reconstructed from there: phrase opens the sidecar, the sealed
|
|
493
|
+
// identity restores agent/path, and snippets fetch like any cold archive.
|
|
494
|
+
const record = records.find((item) => item.id === id) ?? await remoteOnlyRecord(dataDir, id, phrase);
|
|
495
|
+
if (!record || (options.agent && record.source.agent !== options.agent))
|
|
496
|
+
continue;
|
|
497
|
+
let snippets = [];
|
|
498
|
+
if (options.snippets !== false) {
|
|
499
|
+
snippets = await snippetsFor(dataDir, record, phrase, terms, chunkTags.get(id));
|
|
500
|
+
}
|
|
501
|
+
hits.push({ id: record.id, agent: record.source.agent, path: record.source.path, bytes: record.source.bytes, createdAt: record.createdAt, matched: "content", snippets });
|
|
502
|
+
}
|
|
503
|
+
return hits;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Reconstructs enough of an archive to search it, from the cloud alone: the
|
|
507
|
+
* managed chunk layout stores `<id>.envelope.vlmeta` beside the chunks, and
|
|
508
|
+
* that sidecar carries the envelope plus the sealed identity. Nothing is
|
|
509
|
+
* persisted — this is a read-time view for machines that never held the
|
|
510
|
+
* record. Managed accounts only; a vault with no cloud sign-in answers null
|
|
511
|
+
* and the hit stays dropped, exactly as before.
|
|
512
|
+
*/
|
|
513
|
+
async function remoteOnlyRecord(dataDir, id, phrase) {
|
|
514
|
+
try {
|
|
515
|
+
const { pullCiphertext, unframeObject } = await import("./cloud.js");
|
|
516
|
+
const { openIdentity } = await import("./chunk-store.js");
|
|
517
|
+
const { unwrapArchiveKey } = await import("../packages/vaultline-crypto/src/recipients.js");
|
|
518
|
+
const { ciphertext: sidecar } = await pullCiphertext(dataDir, `${id}.envelope.vlmeta`);
|
|
519
|
+
const { envelope, ciphertext: identityBlob } = unframeObject(sidecar);
|
|
520
|
+
if (!envelope)
|
|
521
|
+
return null;
|
|
522
|
+
const archiveKey = unwrapArchiveKey(envelope.wrappedKeys, envelope.suite, envelope.archiveId, { phrase });
|
|
523
|
+
let identity;
|
|
524
|
+
try {
|
|
525
|
+
identity = openIdentity(archiveKey, id, identityBlob);
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
archiveKey.fill(0);
|
|
529
|
+
}
|
|
530
|
+
const storedBytes = envelope.chunks.reduce((sum, chunk) => sum + chunk.storedBytes, 0);
|
|
531
|
+
return {
|
|
532
|
+
version: 2, id, createdAt: identity.createdAt,
|
|
533
|
+
source: { path: identity.sourcePath, agent: identity.agent, bytes: envelope.manifest.originalBytes ?? 0, sha256: envelope.manifest.originalSha256 ?? envelope.manifest.plaintextSha256 },
|
|
534
|
+
cipher: { algorithm: envelope.suite, ciphertextSha256: "", storedBytes, chunks: envelope.chunks.length },
|
|
535
|
+
envelope,
|
|
536
|
+
objectPath: join(dataDir, "never-materialised", `${id}.vlarchive`),
|
|
537
|
+
remote: {
|
|
538
|
+
provider: "vaultline", bucket: "vaultline-managed", objectKey: id,
|
|
539
|
+
bytes: storedBytes, checksum: "", verifiedAt: identity.createdAt,
|
|
540
|
+
layout: { kind: "chunks", chunkCount: envelope.chunks.length }
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Snippets, fetched as narrowly as the archive allows. A chunk-tagged hit on a
|
|
550
|
+
* chunk-independent archive reads ONLY the tagged chunks (padded one chunk
|
|
551
|
+
* each side, because a matching line can straddle a boundary): from the local
|
|
552
|
+
* blob when present, and through a ranged managed download when the local copy
|
|
553
|
+
* was offloaded — kilobytes over the wire against a cold 1.6 GB object.
|
|
554
|
+
* Anything else falls back to the whole-archive read it always was.
|
|
555
|
+
*/
|
|
556
|
+
async function snippetsFor(dataDir, record, phrase, terms, tagged) {
|
|
557
|
+
const lineMatches = (text) => text.split("\n").filter((line) => terms.every((term) => line.toLowerCase().includes(term))).slice(0, 3).map((line) => line.slice(0, 200));
|
|
558
|
+
if (tagged && tagged.size > 0 && isV2(record) && supportsChunkAccess(record.envelope)) {
|
|
559
|
+
try {
|
|
560
|
+
// Up to two SEPARATE small windows, one per tagged chunk, each padded a
|
|
561
|
+
// chunk either side. The old code built ONE window spanning from the
|
|
562
|
+
// lowest tag to the highest — tags at c3 and c350 fetched 349 chunks of
|
|
563
|
+
// a cold archive to show two snippet lines. Bounded now: ≤ 6 chunks,
|
|
564
|
+
// and a multi-term query costs the same as a single-term one.
|
|
565
|
+
const anchors = [...tagged].sort((a, b) => a - b).slice(0, 2);
|
|
566
|
+
const windows = [];
|
|
567
|
+
for (const anchor of anchors) {
|
|
568
|
+
const first = Math.max(0, anchor - 1);
|
|
569
|
+
const last = Math.min(record.envelope.chunks.length - 1, anchor + 1);
|
|
570
|
+
const previous = windows[windows.length - 1];
|
|
571
|
+
if (previous && first <= previous.last + 1)
|
|
572
|
+
previous.last = Math.max(previous.last, last);
|
|
573
|
+
else
|
|
574
|
+
windows.push({ first, last });
|
|
575
|
+
}
|
|
576
|
+
const { stat: statFile, readFile: readBlob } = await import("node:fs/promises");
|
|
577
|
+
const local = await statFile(record.objectPath).then((entry) => entry.isFile()).catch(() => false);
|
|
578
|
+
const found = [];
|
|
579
|
+
for (const { first, last } of windows) {
|
|
580
|
+
const window = {
|
|
581
|
+
firstIndex: first, lastIndex: last,
|
|
582
|
+
storedOffset: record.envelope.chunks.slice(0, first).reduce((sum, chunk) => sum + chunk.storedBytes, 0),
|
|
583
|
+
storedLength: record.envelope.chunks.slice(first, last + 1).reduce((sum, chunk) => sum + chunk.storedBytes, 0),
|
|
584
|
+
rawStartInWindow: 0,
|
|
585
|
+
rawLengthInWindow: record.envelope.chunks.slice(first, last + 1).reduce((sum, chunk) => sum + (chunk.rawBytes ?? 0), 0)
|
|
586
|
+
};
|
|
587
|
+
let slice;
|
|
588
|
+
if (local) {
|
|
589
|
+
const blob = await readBlob(record.objectPath);
|
|
590
|
+
slice = blob.subarray(window.storedOffset, window.storedOffset + window.storedLength);
|
|
591
|
+
}
|
|
592
|
+
else if (record.remote?.provider === "vaultline" && record.remote.layout?.kind === "chunks") {
|
|
593
|
+
// A chunk-folder copy IS the ranged read — and the window's chunks
|
|
594
|
+
// fetch in PARALLEL, each read hash-checked by the account.
|
|
595
|
+
const { managedChunkClient } = await import("./managed-chunks.js");
|
|
596
|
+
const { chunkObjectName } = await import("./chunk-store.js");
|
|
597
|
+
const managed = managedChunkClient(dataDir);
|
|
598
|
+
const parts = await Promise.all(Array.from({ length: last - first + 1 }, (_, offsetIndex) => managed.getObject(`${record.remote.objectKey}/${chunkObjectName(first + offsetIndex)}`)));
|
|
599
|
+
slice = Buffer.concat(parts);
|
|
600
|
+
}
|
|
601
|
+
else if (record.offloaded?.provider === "vaultline" || record.remote?.provider === "vaultline") {
|
|
602
|
+
const { pullCiphertextRange, frameObject } = await import("./cloud.js");
|
|
603
|
+
// A managed object is envelope-framed: the ciphertext starts after a
|
|
604
|
+
// header whose size is knowable from the envelope in hand — frame it
|
|
605
|
+
// over nothing and measure. Offsets in the window address the
|
|
606
|
+
// ciphertext; the wire range must address the object.
|
|
607
|
+
const frameHeaderBytes = frameObject(record.envelope, Buffer.alloc(0)).length;
|
|
608
|
+
slice = await pullCiphertextRange(dataDir, record.id, frameHeaderBytes + window.storedOffset, window.storedLength);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
throw new Error("no ranged path for this provider yet");
|
|
612
|
+
}
|
|
613
|
+
const raw = openChunkWindow(record.envelope, window, slice, { phrase });
|
|
614
|
+
found.push(...lineMatches(raw.toString("utf8")));
|
|
615
|
+
raw.fill(0);
|
|
616
|
+
if (found.length >= 3)
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
if (found.length > 0)
|
|
620
|
+
return found.slice(0, 3);
|
|
621
|
+
// A boundary case the padding did not cover — fall through to whole.
|
|
622
|
+
}
|
|
623
|
+
catch { /* ranged read unavailable — the whole path below still answers */ }
|
|
624
|
+
}
|
|
625
|
+
const { plaintext } = await decryptRecord(dataDir, record.id, phrase);
|
|
626
|
+
// Same string-ceiling discipline as the index build: scan line by line in
|
|
627
|
+
// windows, carrying the partial last line across the boundary.
|
|
628
|
+
const found = [];
|
|
629
|
+
const WINDOW = 8 * 1024 * 1024;
|
|
630
|
+
let carry = "";
|
|
631
|
+
for (let at = 0; at < plaintext.length && found.length < 3; at += WINDOW) {
|
|
632
|
+
const text = carry + plaintext.subarray(at, Math.min(plaintext.length, at + WINDOW)).toString("utf8");
|
|
633
|
+
const lines = text.split("\n");
|
|
634
|
+
carry = at + WINDOW < plaintext.length ? lines.pop() ?? "" : "";
|
|
635
|
+
for (const line of lines) {
|
|
636
|
+
if (terms.every((term) => line.toLowerCase().includes(term))) {
|
|
637
|
+
found.push(line.slice(0, 200));
|
|
638
|
+
if (found.length >= 3)
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (found.length < 3 && carry && terms.every((term) => carry.toLowerCase().includes(term)))
|
|
644
|
+
found.push(carry.slice(0, 200));
|
|
645
|
+
plaintext.fill(0);
|
|
646
|
+
return found;
|
|
647
|
+
}
|
|
648
|
+
export async function search(dataDir, query, options = {}) {
|
|
649
|
+
if ((options.mode ?? "metadata") === "metadata")
|
|
650
|
+
return searchMetadata(dataDir, query, options);
|
|
651
|
+
if (!options.phrase)
|
|
652
|
+
fail("recovery_phrase_missing", "Content search decrypts archives locally and needs the recovery phrase");
|
|
653
|
+
return searchContent(dataDir, query, options.phrase, options);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* What the index covers, from the unsealed sidecar — no phrase required.
|
|
657
|
+
* "Missing" means sealed and durable yet not yet searchable, which is exactly
|
|
658
|
+
* the list `sealkeep index build` would work through.
|
|
659
|
+
*/
|
|
660
|
+
export async function indexCoverage(dataDir) {
|
|
661
|
+
const records = await listArchives(dataDir);
|
|
662
|
+
const coverage = await readFile(coveragePath(dataDir), "utf8")
|
|
663
|
+
.then((raw) => JSON.parse(raw))
|
|
664
|
+
.catch(() => null);
|
|
665
|
+
const indexedIds = new Set(coverage?.indexed ?? []);
|
|
666
|
+
const missing = records
|
|
667
|
+
.filter((record) => !indexedIds.has(record.id))
|
|
668
|
+
.map((record) => ({ id: record.id, path: record.source.path, agent: record.source.agent }));
|
|
669
|
+
const indexBytes = await import("node:fs/promises").then(({ stat }) => stat(indexPath(dataDir))).then((info) => info.size).catch(() => 0);
|
|
670
|
+
return {
|
|
671
|
+
total: records.length,
|
|
672
|
+
indexed: records.length - missing.length,
|
|
673
|
+
builtAt: coverage?.builtAt ?? null,
|
|
674
|
+
indexBytes,
|
|
675
|
+
missing
|
|
676
|
+
};
|
|
677
|
+
}
|