@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100
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/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +101 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
lstat,
|
|
5
|
+
link,
|
|
6
|
+
mkdir,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
open,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { classifyHelloLynkLocalDataEnvelope } from "./hellolynk-local-data-protection.mjs";
|
|
13
|
+
import {
|
|
14
|
+
assertHelloLynkLocalMessageArchiveEnvelope,
|
|
15
|
+
assertHelloLynkLocalMessageStore,
|
|
16
|
+
} from "./hellolynk-local-messaging.mjs";
|
|
17
|
+
import {
|
|
18
|
+
assertHelloLynkLocalContactStore,
|
|
19
|
+
} from "./hellolynk-local-contacts.mjs";
|
|
20
|
+
import {
|
|
21
|
+
HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT,
|
|
22
|
+
convertEncryptedIndexedMessageDatabase,
|
|
23
|
+
} from "./hellolynk-indexed-message-store.mjs";
|
|
24
|
+
|
|
25
|
+
// Local business records are plaintext. An installation that ran the former
|
|
26
|
+
// encryption migration still has its journal, which keeps every store closed.
|
|
27
|
+
// prepare() converts those records forward once and removes the journal last,
|
|
28
|
+
// so an interrupted run resumes on the next start. The key slot and every
|
|
29
|
+
// credential file are left untouched.
|
|
30
|
+
export const HELLOLYNK_LOCAL_DATA_MIGRATION_CONTRACT = "hellolynk-local-data-migration-v1";
|
|
31
|
+
|
|
32
|
+
const STATE_FILE = "local-data-migration-v1.json";
|
|
33
|
+
const STAGE_DIRECTORY = ".local-data-migration-v1.staged";
|
|
34
|
+
const MAX_ASSET_BYTES = 256 * 1024 * 1024;
|
|
35
|
+
const MAX_STORE_LOCK_BYTES = 16 * 1024;
|
|
36
|
+
const STORE_LOCK_STALE_MS = 5_000;
|
|
37
|
+
const STORE_LOCK_STABILITY_WAIT_MS = 20;
|
|
38
|
+
const PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$/;
|
|
39
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,199}$/;
|
|
40
|
+
const KEY_ID = /^ldk_[A-Za-z0-9_-]{22}$/;
|
|
41
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
42
|
+
const STATE_FIELDS = Object.freeze([
|
|
43
|
+
"assets",
|
|
44
|
+
"bindingDigest",
|
|
45
|
+
"contract",
|
|
46
|
+
"keyId",
|
|
47
|
+
"phase",
|
|
48
|
+
"schemaVersion",
|
|
49
|
+
]);
|
|
50
|
+
const ASSET_STATE_FIELDS = Object.freeze([
|
|
51
|
+
"assetId",
|
|
52
|
+
"canonicalDigest",
|
|
53
|
+
"format",
|
|
54
|
+
"recordCount",
|
|
55
|
+
"sourceBytesDigest",
|
|
56
|
+
"stableIdsDigest",
|
|
57
|
+
"stageFile",
|
|
58
|
+
"targetRelativePath",
|
|
59
|
+
]);
|
|
60
|
+
export function createLocalDataMigrationCoordinator(options = {}) {
|
|
61
|
+
const root = absoluteRoot(options.root);
|
|
62
|
+
const region = safeId(options.region, "local_data_migration_region_invalid");
|
|
63
|
+
const principalId = safeId(
|
|
64
|
+
options.principalId,
|
|
65
|
+
"local_data_migration_principal_id_invalid",
|
|
66
|
+
);
|
|
67
|
+
const hostInstallationId = safeId(
|
|
68
|
+
options.hostInstallationId,
|
|
69
|
+
"local_data_migration_host_installation_id_invalid",
|
|
70
|
+
);
|
|
71
|
+
const profileIds = normalizeProfileIds(options.profileIds);
|
|
72
|
+
const canonicalProfileId = normalizeProfileId(options.canonicalProfileId);
|
|
73
|
+
if (!profileIds.includes(canonicalProfileId)) {
|
|
74
|
+
throw failure("local_data_migration_canonical_profile_untrusted");
|
|
75
|
+
}
|
|
76
|
+
if (options.createLegacyDataProtector != null && typeof options.createLegacyDataProtector !== "function") {
|
|
77
|
+
throw failure("local_data_migration_protector_invalid");
|
|
78
|
+
}
|
|
79
|
+
const faultInjector = typeof options.faultInjector === "function"
|
|
80
|
+
? options.faultInjector
|
|
81
|
+
: async () => undefined;
|
|
82
|
+
const statePath = path.join(root, STATE_FILE);
|
|
83
|
+
const stageRoot = path.join(root, STAGE_DIRECTORY);
|
|
84
|
+
const descriptors = assetDescriptors(root, profileIds, canonicalProfileId, principalId);
|
|
85
|
+
// The former journal canonicalized object keys and sorted the profile set.
|
|
86
|
+
// Before commit, its plaintext backups have no envelope to prove ownership.
|
|
87
|
+
const bindingDigest = digestBytes(JSON.stringify({
|
|
88
|
+
canonicalProfileId, hostInstallationId, principalId, profileIds, region,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
async function prepare(lockContext = {}) {
|
|
92
|
+
assertLockContext(lockContext, { region, principalId, hostInstallationId });
|
|
93
|
+
await assertSafeMigrationLayout(root, stageRoot);
|
|
94
|
+
await reclaimExpectedStaleStoreLocks(root, descriptors, faultInjector);
|
|
95
|
+
const state = await readMigrationState(statePath, descriptors, bindingDigest);
|
|
96
|
+
if (!state) {
|
|
97
|
+
await removeStageDirectory(root, stageRoot);
|
|
98
|
+
return Object.freeze({ phase: "plaintext", converted: false });
|
|
99
|
+
}
|
|
100
|
+
// An interrupted encryption never published its journal as committed; the
|
|
101
|
+
// immutable source backups are the plaintext originals.
|
|
102
|
+
if (state.phase === "staged") await restoreSourceBackups({ root, stageRoot, state });
|
|
103
|
+
const protector = requireLegacyProtector(await options.createLegacyDataProtector?.());
|
|
104
|
+
const result = await protector.withKeyBatch(() => convertProtectedAssets({
|
|
105
|
+
root,
|
|
106
|
+
descriptors,
|
|
107
|
+
profileIds,
|
|
108
|
+
keyId: state.keyId,
|
|
109
|
+
protector,
|
|
110
|
+
faultInjector,
|
|
111
|
+
}));
|
|
112
|
+
await faultInjector("journal", result);
|
|
113
|
+
await rm(statePath);
|
|
114
|
+
await syncDirectory(root);
|
|
115
|
+
await removeStageDirectory(root, stageRoot);
|
|
116
|
+
return Object.freeze({ phase: "plaintext", converted: true, ...result });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
contract: HELLOLYNK_LOCAL_DATA_MIGRATION_CONTRACT,
|
|
121
|
+
statePath,
|
|
122
|
+
prepare,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function convertProtectedAssets({ root, descriptors, profileIds, keyId, protector, faultInjector }) {
|
|
127
|
+
const decrypt = async (envelope, context) => {
|
|
128
|
+
assertEnvelopeKey(envelope, keyId);
|
|
129
|
+
try { return await protector.openJson(envelope, context); }
|
|
130
|
+
catch (error) { throw failure("local_data_migration_protected_asset_invalid", error); }
|
|
131
|
+
};
|
|
132
|
+
// Decrypt and validate every file before rewriting any of them, so a damaged
|
|
133
|
+
// asset leaves the installation as it was. Each database converts in one
|
|
134
|
+
// transaction, and every step skips what an earlier run already converted.
|
|
135
|
+
const pending = [];
|
|
136
|
+
for (const item of conversionItems(descriptors)) {
|
|
137
|
+
const text = await plaintextAsset(item, decrypt);
|
|
138
|
+
if (text != null) pending.push({ item, text });
|
|
139
|
+
}
|
|
140
|
+
let rows = 0;
|
|
141
|
+
for (const profileId of profileIds) {
|
|
142
|
+
const result = await convertEncryptedIndexedMessageDatabase({
|
|
143
|
+
storePath: path.join(root, "messages", `${profileId}.json`),
|
|
144
|
+
profileId,
|
|
145
|
+
isEncrypted: (value) => envelopeProtection(value) !== "plaintext",
|
|
146
|
+
decrypt,
|
|
147
|
+
deriveIndexKey: async (context) => {
|
|
148
|
+
try { return await protector.deriveIndexKey(keyId, context); }
|
|
149
|
+
catch (error) { throw failure("local_data_migration_protected_asset_invalid", error); }
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
if (!result.converted) continue;
|
|
153
|
+
rows += result.rows;
|
|
154
|
+
await faultInjector("database", Object.freeze({ profileId, rows: result.rows }));
|
|
155
|
+
}
|
|
156
|
+
for (const { item, text } of pending) {
|
|
157
|
+
await atomicWriteText(item.target, text);
|
|
158
|
+
if (item.source !== item.target) {
|
|
159
|
+
await rm(item.source);
|
|
160
|
+
await syncDirectory(path.dirname(item.source));
|
|
161
|
+
}
|
|
162
|
+
await faultInjector("asset", Object.freeze({ assetId: item.descriptor.assetId }));
|
|
163
|
+
}
|
|
164
|
+
return Object.freeze({ assetCount: pending.length, rows });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function conversionItems(descriptors) {
|
|
168
|
+
const items = [];
|
|
169
|
+
for (const descriptor of descriptors) {
|
|
170
|
+
items.push({ descriptor, source: descriptor.targetPath, target: descriptor.targetPath });
|
|
171
|
+
// The indexed store kept its pre-import copy of the legacy main file.
|
|
172
|
+
if (descriptor.assetId === `messages-main__${descriptor.profileId}`) {
|
|
173
|
+
items.push({
|
|
174
|
+
descriptor,
|
|
175
|
+
source: `${descriptor.targetPath}.pre-indexed.encrypted`,
|
|
176
|
+
target: `${descriptor.targetPath}.pre-indexed.json`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return items;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function plaintextAsset(item, decrypt) {
|
|
184
|
+
if (!await optionalRegularFile(item.source)) return null;
|
|
185
|
+
const { descriptor } = item;
|
|
186
|
+
const { bytes } = await readRegularFile(item.source, MAX_ASSET_BYTES);
|
|
187
|
+
const reveal = (value) => (envelopeProtection(value) === "plaintext"
|
|
188
|
+
? value
|
|
189
|
+
: decrypt(value, descriptor.context));
|
|
190
|
+
if (descriptor.format === "json") {
|
|
191
|
+
const stored = parseJson(bytes);
|
|
192
|
+
if (item.source === item.target && envelopeProtection(stored) === "plaintext") return null;
|
|
193
|
+
const value = await reveal(stored);
|
|
194
|
+
descriptor.validate(value);
|
|
195
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
196
|
+
}
|
|
197
|
+
const stored = parseArchiveLines(bytes);
|
|
198
|
+
if (stored.every((value) => envelopeProtection(value) === "plaintext")) return null;
|
|
199
|
+
const lines = [];
|
|
200
|
+
for (const line of stored) {
|
|
201
|
+
const value = await reveal(line);
|
|
202
|
+
descriptor.validate(value);
|
|
203
|
+
lines.push(JSON.stringify(value));
|
|
204
|
+
}
|
|
205
|
+
return lines.length ? `${lines.join("\n")}\n` : "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function restoreSourceBackups(input) {
|
|
209
|
+
const backups = [];
|
|
210
|
+
for (const asset of input.state.assets) {
|
|
211
|
+
const backupPath = safeChild(input.stageRoot, sourceBackupFile(asset));
|
|
212
|
+
const backup = await readRegularFile(backupPath, MAX_ASSET_BYTES);
|
|
213
|
+
if (digestBytes(backup.bytes) !== asset.sourceBytesDigest) {
|
|
214
|
+
throw failure("local_data_migration_source_backup_invalid");
|
|
215
|
+
}
|
|
216
|
+
backups.push({ target: safeChild(input.root, asset.targetRelativePath), bytes: backup.bytes });
|
|
217
|
+
}
|
|
218
|
+
// A damaged later backup must be detected before any target is restored.
|
|
219
|
+
for (const backup of backups) await atomicWriteBytes(backup.target, backup.bytes);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sourceBackupFile(asset) {
|
|
223
|
+
return `${asset.assetId}.source.bak`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function assetDescriptors(root, profileIds, canonicalProfileId, principalId) {
|
|
227
|
+
const descriptors = [];
|
|
228
|
+
for (const profileId of profileIds) {
|
|
229
|
+
descriptors.push(jsonDescriptor({
|
|
230
|
+
root,
|
|
231
|
+
profileId,
|
|
232
|
+
assetId: `messages-main__${profileId}`,
|
|
233
|
+
assetKind: "messages",
|
|
234
|
+
targetRelativePath: `messages/${profileId}.json`,
|
|
235
|
+
validate(value) {
|
|
236
|
+
if (value?.storage === "sqlite") {
|
|
237
|
+
assertExactFields(value, ["contract", "profileId", "storage"], "local_data_migration_indexed_fence_invalid");
|
|
238
|
+
if (value.contract !== HELLOLYNK_INDEXED_MESSAGE_STORE_CONTRACT || value.profileId !== profileId) {
|
|
239
|
+
throw failure("local_data_migration_indexed_fence_invalid");
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
try { assertHelloLynkLocalMessageStore(value, profileId); }
|
|
244
|
+
catch (error) { throw failure("local_data_migration_source_invalid", error); }
|
|
245
|
+
},
|
|
246
|
+
}));
|
|
247
|
+
descriptors.push(archiveDescriptor({
|
|
248
|
+
root,
|
|
249
|
+
profileId,
|
|
250
|
+
assetId: `messages-archive__${profileId}`,
|
|
251
|
+
assetKind: "messages-archive-line",
|
|
252
|
+
targetRelativePath: `messages/${profileId}.json.archive.ndjson`,
|
|
253
|
+
validate(value) {
|
|
254
|
+
try { assertHelloLynkLocalMessageArchiveEnvelope(value, profileId); }
|
|
255
|
+
catch (error) { throw failure("local_data_migration_source_invalid", error); }
|
|
256
|
+
},
|
|
257
|
+
}));
|
|
258
|
+
descriptors.push(jsonDescriptor({
|
|
259
|
+
root,
|
|
260
|
+
profileId,
|
|
261
|
+
assetId: `messages-history__${profileId}`,
|
|
262
|
+
assetKind: "messages",
|
|
263
|
+
targetRelativePath: `messages/${profileId}.json.local-history.json`,
|
|
264
|
+
validate(value) {
|
|
265
|
+
assertExactFields(value, ["contractId", "schemaVersion", "profileId", "cleanupPending", "deleted", "hidden"],
|
|
266
|
+
"local_data_migration_source_invalid");
|
|
267
|
+
if (value.contractId !== "hellolynk-local-history-v1" || value.schemaVersion !== 1
|
|
268
|
+
|| value.profileId !== profileId || typeof value.cleanupPending !== "boolean"
|
|
269
|
+
|| !Array.isArray(value.deleted) || !Array.isArray(value.hidden)) {
|
|
270
|
+
throw failure("local_data_migration_source_invalid");
|
|
271
|
+
}
|
|
272
|
+
for (const item of value.deleted) {
|
|
273
|
+
if (item?.messageType !== "local-deleted" || typeof item.localMessageId !== "string"
|
|
274
|
+
|| !SAFE_ID.test(item.localMessageId)) throw failure("local_data_migration_source_invalid");
|
|
275
|
+
}
|
|
276
|
+
for (const item of value.hidden) {
|
|
277
|
+
assertExactFields(item, ["delegateId", "knownMessageIds", "principalId", "region", "threadId"],
|
|
278
|
+
"local_data_migration_source_invalid");
|
|
279
|
+
if (!["intl", "cn"].includes(item.region) || !SAFE_ID.test(item.principalId)
|
|
280
|
+
|| !SAFE_ID.test(item.delegateId) || !SAFE_ID.test(item.threadId)
|
|
281
|
+
|| !Array.isArray(item.knownMessageIds) || item.knownMessageIds.some((id) => !SAFE_ID.test(id))) {
|
|
282
|
+
throw failure("local_data_migration_source_invalid");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
}));
|
|
287
|
+
descriptors.push(jsonDescriptor({
|
|
288
|
+
root,
|
|
289
|
+
profileId,
|
|
290
|
+
assetId: `contacts__${profileId}`,
|
|
291
|
+
assetKind: "contacts",
|
|
292
|
+
targetRelativePath: `contacts/${profileId}.json`,
|
|
293
|
+
validate(value) {
|
|
294
|
+
try { assertHelloLynkLocalContactStore(value, profileId); }
|
|
295
|
+
catch (error) { throw failure("local_data_migration_source_invalid", error); }
|
|
296
|
+
},
|
|
297
|
+
}));
|
|
298
|
+
descriptors.push(jsonDescriptor({
|
|
299
|
+
root,
|
|
300
|
+
profileId,
|
|
301
|
+
assetId: `contact-requests__${profileId}`,
|
|
302
|
+
assetKind: "contact-requests",
|
|
303
|
+
targetRelativePath: `contact-requests/${profileId}.json`,
|
|
304
|
+
validate(value) {
|
|
305
|
+
if (![1, 2].includes(value?.schemaVersion) || value?.contractId !== "hellolynk-contact-request-local-store-v2"
|
|
306
|
+
|| value?.profileId !== profileId || !Array.isArray(value.records)) {
|
|
307
|
+
throw failure("local_data_migration_source_invalid");
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
descriptors.push(jsonDescriptor({
|
|
313
|
+
root,
|
|
314
|
+
profileId: canonicalProfileId,
|
|
315
|
+
assetId: "neutral-host-recovery",
|
|
316
|
+
assetKind: "neutral-host-recovery",
|
|
317
|
+
targetRelativePath: "neutral-host-recovery-v1.json",
|
|
318
|
+
validate(value) {
|
|
319
|
+
if (value?.contract !== "hellolynk-neutral-host-recovery-v1"
|
|
320
|
+
|| value?.principalId !== principalId || !Array.isArray(value.operations)) {
|
|
321
|
+
throw failure("local_data_migration_source_invalid");
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
}));
|
|
325
|
+
return Object.freeze(descriptors.sort((left, right) => left.assetId.localeCompare(right.assetId)));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function jsonDescriptor(input) {
|
|
329
|
+
return Object.freeze({
|
|
330
|
+
...input,
|
|
331
|
+
format: "json",
|
|
332
|
+
targetPath: safeChild(input.root, input.targetRelativePath),
|
|
333
|
+
context: Object.freeze({
|
|
334
|
+
profileId: input.profileId,
|
|
335
|
+
assetKind: input.assetKind,
|
|
336
|
+
logicalPath: input.targetRelativePath,
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function archiveDescriptor(input) {
|
|
342
|
+
return Object.freeze({
|
|
343
|
+
...input,
|
|
344
|
+
format: "ndjson",
|
|
345
|
+
targetPath: safeChild(input.root, input.targetRelativePath),
|
|
346
|
+
context: Object.freeze({
|
|
347
|
+
profileId: input.profileId,
|
|
348
|
+
assetKind: input.assetKind,
|
|
349
|
+
logicalPath: input.targetRelativePath,
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function reclaimExpectedStaleStoreLocks(root, descriptors, faultInjector) {
|
|
355
|
+
const tokenFieldByAssetKind = Object.freeze({
|
|
356
|
+
messages: "ownershipToken",
|
|
357
|
+
contacts: "token",
|
|
358
|
+
"contact-requests": "ownershipToken",
|
|
359
|
+
});
|
|
360
|
+
for (const descriptor of descriptors) {
|
|
361
|
+
const tokenField = tokenFieldByAssetKind[descriptor.assetKind];
|
|
362
|
+
if (!tokenField) continue;
|
|
363
|
+
await reclaimExpectedStaleStoreLock(
|
|
364
|
+
path.join(root, descriptor.targetRelativePath.replaceAll("/", path.sep)),
|
|
365
|
+
tokenField,
|
|
366
|
+
faultInjector,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function reclaimExpectedStaleStoreLock(storePath, tokenField, faultInjector) {
|
|
372
|
+
const lockPath = `${storePath}.lock`;
|
|
373
|
+
const first = await readStoreLockSnapshot(lockPath);
|
|
374
|
+
if (!isStaleDeadStoreLock(first, tokenField)) return false;
|
|
375
|
+
await faultInjector("lock_recovery_stability_wait");
|
|
376
|
+
await wait(STORE_LOCK_STABILITY_WAIT_MS);
|
|
377
|
+
const second = await readStoreLockSnapshot(lockPath);
|
|
378
|
+
if (!isStaleDeadStoreLock(second, tokenField) || !sameStoreLockSnapshot(first, second)) return false;
|
|
379
|
+
const quarantinedPath = `${lockPath}.stale.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
|
|
380
|
+
try {
|
|
381
|
+
await rename(lockPath, quarantinedPath);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (["ENOENT", "EACCES", "EPERM", "EBUSY"].includes(error?.code)) return false;
|
|
384
|
+
throw failure("local_data_migration_lock_recovery_failed", error);
|
|
385
|
+
}
|
|
386
|
+
const quarantined = await readStoreLockSnapshot(quarantinedPath);
|
|
387
|
+
if (!quarantined || !sameStoreLockSnapshot(second, quarantined)) {
|
|
388
|
+
// Do not overwrite a final path that another writer might have acquired
|
|
389
|
+
// after the quarantine rename. A hard link only restores the original
|
|
390
|
+
// owner when that final path is still absent.
|
|
391
|
+
try {
|
|
392
|
+
await link(quarantinedPath, lockPath);
|
|
393
|
+
await rm(quarantinedPath);
|
|
394
|
+
} catch {}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
await rm(quarantinedPath);
|
|
399
|
+
await syncDirectory(path.dirname(lockPath));
|
|
400
|
+
return true;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw failure("local_data_migration_lock_recovery_failed", error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function readStoreLockSnapshot(lockPath) {
|
|
407
|
+
let metadata;
|
|
408
|
+
try { metadata = await lstat(lockPath); }
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (error?.code === "ENOENT") return null;
|
|
411
|
+
throw failure("local_data_migration_lock_recovery_failed", error);
|
|
412
|
+
}
|
|
413
|
+
if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_STORE_LOCK_BYTES) return null;
|
|
414
|
+
let body;
|
|
415
|
+
try {
|
|
416
|
+
const opened = await readRegularFile(lockPath, MAX_STORE_LOCK_BYTES);
|
|
417
|
+
body = opened.bytes;
|
|
418
|
+
return body.length === metadata.size
|
|
419
|
+
? Object.freeze({ metadata, identity: opened.identity, body })
|
|
420
|
+
: null;
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (error?.code === "ENOENT") return null;
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isStaleDeadStoreLock(snapshot, tokenField) {
|
|
428
|
+
if (!snapshot || Date.now() - snapshot.metadata.mtimeMs < STORE_LOCK_STALE_MS) return false;
|
|
429
|
+
let record;
|
|
430
|
+
try { record = JSON.parse(snapshot.body.toString("utf8")); } catch { return false; }
|
|
431
|
+
const pid = Number(record?.pid);
|
|
432
|
+
const token = record?.[tokenField];
|
|
433
|
+
return Number.isSafeInteger(pid)
|
|
434
|
+
&& pid > 0
|
|
435
|
+
&& typeof token === "string"
|
|
436
|
+
&& token.length >= 16
|
|
437
|
+
&& Number.isFinite(Number(record?.createdAt))
|
|
438
|
+
&& processDefinitelyDead(pid);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function processDefinitelyDead(pid) {
|
|
442
|
+
try {
|
|
443
|
+
process.kill(pid, 0);
|
|
444
|
+
return false;
|
|
445
|
+
} catch (error) {
|
|
446
|
+
return error?.code === "ESRCH";
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sameStoreLockSnapshot(left, right) {
|
|
451
|
+
return left.identity.dev === right.identity.dev && left.identity.ino === right.identity.ino
|
|
452
|
+
&& left.metadata.size === right.metadata.size
|
|
453
|
+
&& left.metadata.mtimeMs === right.metadata.mtimeMs
|
|
454
|
+
&& left.body.length === right.body.length
|
|
455
|
+
&& crypto.timingSafeEqual(left.body, right.body);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function wait(milliseconds) {
|
|
459
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function parseArchiveLines(body) {
|
|
463
|
+
return String(body).split(/\r?\n/u).filter(Boolean).map(parseJson);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function parseJson(body) {
|
|
467
|
+
try { return JSON.parse(String(body)); }
|
|
468
|
+
catch (error) { throw failure("local_data_migration_source_invalid", error); }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function envelopeProtection(value) {
|
|
472
|
+
try {
|
|
473
|
+
return classifyHelloLynkLocalDataEnvelope(value) || "plaintext";
|
|
474
|
+
} catch (error) {
|
|
475
|
+
throw failure("local_data_migration_protected_asset_invalid", error);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function assertEnvelopeKey(value, expectedKeyId) {
|
|
480
|
+
if (envelopeProtection(value) !== "current") {
|
|
481
|
+
throw failure("local_data_migration_protected_asset_version_invalid");
|
|
482
|
+
}
|
|
483
|
+
if (value?.keyId !== expectedKeyId) throw failure("local_data_migration_mixed_keys");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function readMigrationState(statePath, descriptors, bindingDigest) {
|
|
487
|
+
let value;
|
|
488
|
+
try {
|
|
489
|
+
try {
|
|
490
|
+
value = JSON.parse((await readRegularFile(statePath, 1024 * 1024)).bytes.toString("utf8"));
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (error?.code === "ENOENT") throw error;
|
|
493
|
+
throw failure("local_data_migration_state_invalid", error);
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if (error?.code === "ENOENT") return null;
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
assertExactFields(value, STATE_FIELDS, "local_data_migration_state_invalid");
|
|
500
|
+
if (value.contract !== HELLOLYNK_LOCAL_DATA_MIGRATION_CONTRACT || value.schemaVersion !== 1
|
|
501
|
+
|| !["preparing", "staged", "committed"].includes(value.phase) || !DIGEST.test(value.bindingDigest)
|
|
502
|
+
|| !KEY_ID.test(value.keyId) || !Array.isArray(value.assets)
|
|
503
|
+
|| value.assets.length > descriptors.length
|
|
504
|
+
|| (value.phase === "preparing" && value.assets.length !== 0)) {
|
|
505
|
+
throw failure("local_data_migration_state_invalid");
|
|
506
|
+
}
|
|
507
|
+
if (value.phase !== "committed" && value.bindingDigest !== bindingDigest) {
|
|
508
|
+
throw failure("local_data_migration_binding_mismatch");
|
|
509
|
+
}
|
|
510
|
+
// Recorded assets must still belong to this installation's profiles, or
|
|
511
|
+
// their records would be left behind when the journal is removed.
|
|
512
|
+
const seen = new Set();
|
|
513
|
+
for (const asset of value.assets) {
|
|
514
|
+
assertAssetState(asset);
|
|
515
|
+
if (seen.has(asset.assetId)) throw failure("local_data_migration_state_invalid");
|
|
516
|
+
seen.add(asset.assetId);
|
|
517
|
+
descriptorForState(asset, descriptors);
|
|
518
|
+
}
|
|
519
|
+
return Object.freeze({ phase: value.phase, keyId: value.keyId, assets: Object.freeze(value.assets) });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function assertAssetState(asset) {
|
|
523
|
+
assertExactFields(
|
|
524
|
+
asset,
|
|
525
|
+
ASSET_STATE_FIELDS,
|
|
526
|
+
"local_data_migration_state_invalid",
|
|
527
|
+
);
|
|
528
|
+
if (!SAFE_ID.test(asset.assetId) || !["json", "ndjson"].includes(asset.format)
|
|
529
|
+
|| typeof asset.targetRelativePath !== "string" || typeof asset.stageFile !== "string"
|
|
530
|
+
|| !DIGEST.test(asset.canonicalDigest) || !DIGEST.test(asset.stableIdsDigest)
|
|
531
|
+
|| !DIGEST.test(asset.sourceBytesDigest)
|
|
532
|
+
|| !Number.isSafeInteger(asset.recordCount) || asset.recordCount < 0) {
|
|
533
|
+
throw failure("local_data_migration_state_invalid");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function descriptorForState(asset, descriptors) {
|
|
538
|
+
const descriptor = descriptors.find((item) => item.assetId === asset.assetId);
|
|
539
|
+
if (!descriptor || descriptor.format !== asset.format
|
|
540
|
+
|| descriptor.targetRelativePath !== asset.targetRelativePath
|
|
541
|
+
|| asset.stageFile !== `${descriptor.assetId}.${descriptor.format === "json" ? "json" : "ndjson"}`) {
|
|
542
|
+
throw failure("local_data_migration_state_invalid");
|
|
543
|
+
}
|
|
544
|
+
return descriptor;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function atomicWriteText(targetPath, value) {
|
|
548
|
+
await atomicWriteBytes(targetPath, Buffer.from(value, "utf8"));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function atomicWriteBytes(targetPath, value) {
|
|
552
|
+
await ensureSafeDirectory(path.dirname(targetPath));
|
|
553
|
+
const temporary = `${targetPath}.${crypto.randomUUID()}.tmp`;
|
|
554
|
+
let handle;
|
|
555
|
+
try {
|
|
556
|
+
handle = await open(temporary, "wx", 0o600);
|
|
557
|
+
await handle.writeFile(value);
|
|
558
|
+
await handle.sync();
|
|
559
|
+
} catch (error) {
|
|
560
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
561
|
+
throw failure("local_data_migration_write_failed", error);
|
|
562
|
+
} finally {
|
|
563
|
+
await handle?.close().catch(() => {});
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
await rename(temporary, targetPath);
|
|
567
|
+
await syncDirectory(path.dirname(targetPath));
|
|
568
|
+
} catch (error) {
|
|
569
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
570
|
+
throw failure("local_data_migration_write_failed", error);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function syncDirectory(directoryPath) {
|
|
575
|
+
let handle;
|
|
576
|
+
try {
|
|
577
|
+
handle = await open(directoryPath, "r");
|
|
578
|
+
await handle.sync();
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (!["EBADF", "EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(error?.code)) throw error;
|
|
581
|
+
} finally {
|
|
582
|
+
await handle?.close().catch(() => {});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function removeStageDirectory(root, stageRoot) {
|
|
587
|
+
const resolved = path.resolve(stageRoot);
|
|
588
|
+
if (path.dirname(resolved) !== path.resolve(root) || path.basename(resolved) !== STAGE_DIRECTORY) {
|
|
589
|
+
throw failure("local_data_migration_stage_path_invalid");
|
|
590
|
+
}
|
|
591
|
+
let metadata;
|
|
592
|
+
try { metadata = await lstat(resolved); }
|
|
593
|
+
catch (error) {
|
|
594
|
+
if (error?.code === "ENOENT") return;
|
|
595
|
+
throw failure("local_data_migration_stage_path_invalid", error);
|
|
596
|
+
}
|
|
597
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
598
|
+
throw failure("local_data_migration_stage_path_invalid");
|
|
599
|
+
}
|
|
600
|
+
await rm(resolved, { recursive: true, force: true });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function assertSafeMigrationLayout(root, stageRoot) {
|
|
604
|
+
await requireSafeDirectory(root);
|
|
605
|
+
for (const name of ["messages", "contacts", "contact-requests"]) {
|
|
606
|
+
await optionalSafeDirectory(path.join(root, name));
|
|
607
|
+
}
|
|
608
|
+
await optionalSafeDirectory(stageRoot);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function ensureSafeDirectory(directoryPath) {
|
|
612
|
+
const existing = await optionalSafeDirectory(directoryPath);
|
|
613
|
+
if (existing) return existing;
|
|
614
|
+
await mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
615
|
+
return requireSafeDirectory(directoryPath);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function optionalSafeDirectory(directoryPath) {
|
|
619
|
+
try { return await requireSafeDirectory(directoryPath); }
|
|
620
|
+
catch (error) {
|
|
621
|
+
if (error?.code === "ENOENT") return null;
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function requireSafeDirectory(directoryPath) {
|
|
627
|
+
let metadata;
|
|
628
|
+
try { metadata = await lstat(directoryPath); }
|
|
629
|
+
catch (error) {
|
|
630
|
+
if (error?.code === "ENOENT") throw error;
|
|
631
|
+
throw failure("local_data_migration_directory_invalid", error);
|
|
632
|
+
}
|
|
633
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
634
|
+
throw failure("local_data_migration_directory_invalid");
|
|
635
|
+
}
|
|
636
|
+
return metadata;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function optionalRegularFile(filePath) {
|
|
640
|
+
try { return await requiredRegularFile(filePath); }
|
|
641
|
+
catch (error) {
|
|
642
|
+
if (error?.code === "ENOENT") return null;
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function requiredRegularFile(filePath) {
|
|
648
|
+
let metadata;
|
|
649
|
+
try { metadata = await lstat(filePath); }
|
|
650
|
+
catch (error) {
|
|
651
|
+
if (error?.code === "ENOENT") throw error;
|
|
652
|
+
throw failure("local_data_migration_asset_invalid", error);
|
|
653
|
+
}
|
|
654
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
655
|
+
throw failure("local_data_migration_asset_invalid");
|
|
656
|
+
}
|
|
657
|
+
return metadata;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function readRegularFile(filePath, maximumBytes) {
|
|
661
|
+
const before = await requiredRegularFile(filePath);
|
|
662
|
+
if (before.size > maximumBytes) throw failure("local_data_migration_asset_too_large");
|
|
663
|
+
let handle;
|
|
664
|
+
try {
|
|
665
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
666
|
+
handle = await open(filePath, fsConstants.O_RDONLY | noFollow);
|
|
667
|
+
const opened = await handle.stat();
|
|
668
|
+
if (!opened.isFile() || opened.size > maximumBytes || !sameFileIdentity(before, opened)) {
|
|
669
|
+
throw failure("local_data_migration_asset_invalid");
|
|
670
|
+
}
|
|
671
|
+
return Object.freeze({ metadata: opened, identity: await handle.stat({ bigint: true }), bytes: await handle.readFile() });
|
|
672
|
+
} catch (error) {
|
|
673
|
+
if (error?.code === "local_data_migration_asset_too_large"
|
|
674
|
+
|| error?.code === "local_data_migration_asset_invalid") throw error;
|
|
675
|
+
throw failure("local_data_migration_asset_invalid", error);
|
|
676
|
+
} finally {
|
|
677
|
+
await handle?.close().catch(() => {});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function sameFileIdentity(left, right) {
|
|
682
|
+
if (process.platform === "win32" && (left.dev === 0 || right.dev === 0)
|
|
683
|
+
&& left.ino !== right.ino) return false;
|
|
684
|
+
if (Number.isSafeInteger(left.dev) && Number.isSafeInteger(left.ino)
|
|
685
|
+
&& Number.isSafeInteger(right.dev) && Number.isSafeInteger(right.ino)) {
|
|
686
|
+
if (left.dev === right.dev && left.ino === right.ino) return true;
|
|
687
|
+
// Node 22 on Windows may report dev=0 for lstat(path), while fstat(handle)
|
|
688
|
+
// reports the volume number for the same open file. Keep the file index and
|
|
689
|
+
// immutable snapshot fields bound when accepting that platform difference.
|
|
690
|
+
if (process.platform !== "win32" || (left.dev !== 0 && right.dev !== 0)
|
|
691
|
+
|| left.ino !== right.ino) return false;
|
|
692
|
+
}
|
|
693
|
+
return left.size === right.size && left.mtimeMs === right.mtimeMs
|
|
694
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function normalizeProfileIds(value) {
|
|
698
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 64) {
|
|
699
|
+
throw failure("local_data_migration_profile_ids_invalid");
|
|
700
|
+
}
|
|
701
|
+
return Object.freeze([...new Set(value.map(normalizeProfileId))].sort());
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function normalizeProfileId(value) {
|
|
705
|
+
const profileId = String(value || "");
|
|
706
|
+
if (!PROFILE_ID.test(profileId)) throw failure("local_data_migration_profile_id_invalid");
|
|
707
|
+
return profileId;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function requireLegacyProtector(value) {
|
|
711
|
+
if (value == null) throw failure("local_data_migration_protector_required");
|
|
712
|
+
if (["openJson", "deriveIndexKey", "withKeyBatch"].some((method) => typeof value[method] !== "function")) {
|
|
713
|
+
throw failure("local_data_migration_protector_invalid");
|
|
714
|
+
}
|
|
715
|
+
return value;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function assertLockContext(value, expected) {
|
|
719
|
+
if (value?.lockHeld !== true || value.region !== expected.region
|
|
720
|
+
|| value.principalId !== expected.principalId
|
|
721
|
+
|| value.hostInstallationId !== expected.hostInstallationId) {
|
|
722
|
+
throw failure("local_data_migration_installation_lock_required");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function assertExactFields(value, expected, code) {
|
|
727
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw failure(code);
|
|
728
|
+
const actual = Object.keys(value).sort();
|
|
729
|
+
const wanted = [...expected].sort();
|
|
730
|
+
if (actual.length !== wanted.length || actual.some((field, index) => field !== wanted[index])) {
|
|
731
|
+
throw failure(code);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function digestBytes(value) {
|
|
736
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function absoluteRoot(value) {
|
|
740
|
+
if (typeof value !== "string" || !value || value.includes("\0") || !path.isAbsolute(value)) {
|
|
741
|
+
throw failure("local_data_migration_root_invalid");
|
|
742
|
+
}
|
|
743
|
+
return path.resolve(value);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function safeChild(root, relative) {
|
|
747
|
+
if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.includes("\0")) {
|
|
748
|
+
throw failure("local_data_migration_path_invalid");
|
|
749
|
+
}
|
|
750
|
+
const target = path.resolve(root, relative);
|
|
751
|
+
const prefix = path.resolve(root) + path.sep;
|
|
752
|
+
if (!target.startsWith(prefix)) throw failure("local_data_migration_path_invalid");
|
|
753
|
+
return target;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function safeId(value, code) {
|
|
757
|
+
const id = String(value || "");
|
|
758
|
+
if (!SAFE_ID.test(id)) throw failure(code);
|
|
759
|
+
return id;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function failure(code, cause = null) {
|
|
763
|
+
const error = new Error(code, cause ? { cause } : undefined);
|
|
764
|
+
error.code = code;
|
|
765
|
+
return error;
|
|
766
|
+
}
|