@mycadet/assistant 0.12.11 → 0.13.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/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/openapi.yaml +2 -708
- package/package.json +2 -3
- package/scripts/sync-web-search-catalog.ts +1 -2
- package/src/__tests__/cloud-profile-guard.test.ts +2 -2
- package/src/__tests__/config-schema.test.ts +6 -38
- package/src/__tests__/conversation-queue.test.ts +45 -6
- package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
- package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
- package/src/__tests__/persona-resolver.test.ts +0 -51
- package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
- package/src/__tests__/run-due-schedules.test.ts +0 -4
- package/src/__tests__/schedule-store.test.ts +0 -12
- package/src/__tests__/stt-stream-session.test.ts +4 -4
- package/src/__tests__/telephony-tts-guard.test.ts +2 -50
- package/src/__tests__/voice-config-update.test.ts +12 -167
- package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
- package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
- package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
- package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
- package/src/calls/resolve-call-tts-provider.ts +6 -25
- package/src/calls/telephony-credential-preflight.ts +0 -10
- package/src/calls/telephony-tts-capability.ts +1 -17
- package/src/cli/commands/tts.ts +1 -2
- package/src/cli/index.help.ts +0 -2
- package/src/cli/program.ts +0 -2
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
- package/src/config/schemas/__tests__/stt.test.ts +10 -14
- package/src/config/schemas/services.ts +5 -6
- package/src/config/schemas/stt.ts +1 -3
- package/src/config/schemas/tts.ts +1 -30
- package/src/daemon/daemon-readiness.ts +4 -29
- package/src/daemon/lifecycle.ts +0 -31
- package/src/daemon/providers-setup.ts +0 -8
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
- package/src/heartbeat/heartbeat-service.ts +1 -66
- package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
- package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
- package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
- package/src/live-voice/live-voice-credential-preflight.ts +9 -33
- package/src/live-voice/live-voice-session.ts +10 -6
- package/src/live-voice/live-voice-tts.ts +1 -8
- package/src/plugins/defaults/memory/worker.ts +3 -3
- package/src/prompts/persona-resolver.ts +0 -35
- package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
- package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
- package/src/providers/search-provider-catalog.ts +7 -39
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
- package/src/providers/speech-to-text/provider-catalog.ts +2 -73
- package/src/providers/speech-to-text/resolve.ts +3 -120
- package/src/runtime/http-router-types.ts +1 -3
- package/src/runtime/http-server.ts +1 -1
- package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
- package/src/runtime/routes/index.ts +0 -4
- package/src/runtime/routes/live-voice-routes.test.ts +4 -11
- package/src/runtime/routes/live-voice-routes.ts +8 -11
- package/src/runtime/routes/secret-routes.ts +0 -6
- package/src/runtime/routes/tts-routes.ts +0 -38
- package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
- package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
- package/src/schedule/schedule-store.ts +0 -7
- package/src/schedule/scheduler.ts +0 -5
- package/src/schedule/worker.ts +1 -1
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
- package/src/stt/daemon-batch-transcriber.ts +4 -38
- package/src/stt/types.ts +1 -9
- package/src/tools/network/__tests__/web-search.test.ts +3 -250
- package/src/tools/network/web-search.ts +4 -165
- package/src/tts/__tests__/provider-resolution.test.ts +0 -5
- package/src/tts/provider-catalog.ts +0 -4
- package/src/tts/types.ts +0 -1
- package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
- package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
- package/src/__tests__/migration-export-http.test.ts +0 -557
- package/src/__tests__/migration-export-streaming.test.ts +0 -350
- package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
- package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
- package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
- package/src/__tests__/migration-import-from-path.test.ts +0 -324
- package/src/__tests__/migration-import-from-url.test.ts +0 -588
- package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
- package/src/__tests__/migration-jobs-status.test.ts +0 -139
- package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
- package/src/__tests__/migration-transport.test.ts +0 -1089
- package/src/__tests__/migration-validate-http.test.ts +0 -634
- package/src/__tests__/migration-wizard.test.ts +0 -1395
- package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
- package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
- package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
- package/src/__tests__/validation-results-screen.test.ts +0 -1130
- package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
- package/src/background-wake/background-wake-routes.test.ts +0 -906
- package/src/background-wake/cloud-client.test.ts +0 -328
- package/src/background-wake/cloud-client.ts +0 -196
- package/src/background-wake/next-wake.test.ts +0 -335
- package/src/background-wake/next-wake.ts +0 -188
- package/src/background-wake/publisher.ts +0 -93
- package/src/background-wake/wake-intent-hooks.test.ts +0 -234
- package/src/cli/commands/__tests__/backup.test.ts +0 -363
- package/src/cli/commands/backup.help.ts +0 -180
- package/src/cli/commands/backup.ts +0 -398
- package/src/cloud/cloud-speech.test.ts +0 -335
- package/src/cloud/cloud-speech.ts +0 -300
- package/src/config/cloud-speech-defaults.ts +0 -300
- package/src/config/sanitize-for-transfer.ts +0 -111
- package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
- package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
- package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
- package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
- package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
- package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
- package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
- package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
- package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
- package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
- package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
- package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
- package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
- package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
- package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
- package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
- package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
- package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
- package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
- package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
- package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
- package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
- package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
- package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
- package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
- package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
- package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
- package/src/runtime/migrations/gcs-signed-url.ts +0 -190
- package/src/runtime/migrations/job-registry.ts +0 -297
- package/src/runtime/migrations/migration-transport.ts +0 -612
- package/src/runtime/migrations/migration-wizard.ts +0 -747
- package/src/runtime/migrations/origin-mode.ts +0 -42
- package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
- package/src/runtime/migrations/staged-import-path.ts +0 -116
- package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
- package/src/runtime/migrations/validation-results-screen.ts +0 -469
- package/src/runtime/migrations/vbundle-builder.ts +0 -1382
- package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
- package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
- package/src/runtime/migrations/vbundle-importer.ts +0 -826
- package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
- package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
- package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
- package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
- package/src/runtime/migrations/vbundle-validator.ts +0 -724
- package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
- package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
- package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
- package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
- package/src/runtime/routes/background-wake-routes.ts +0 -387
- package/src/runtime/routes/migration-routes.ts +0 -2584
- package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
- package/src/tools/network/cloud-search-proxy.ts +0 -211
- package/src/tts/__tests__/cadet-provider.test.ts +0 -253
- package/src/tts/providers/cadet-provider.ts +0 -244
- package/src/types/tar-stream.d.ts +0 -66
|
@@ -1,426 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Streaming validation primitives for `.vbundle` archives.
|
|
3
|
-
*
|
|
4
|
-
* The non-streaming `validateVBundle` decompresses the entire archive into
|
|
5
|
-
* memory and walks the tar buffer to compute per-file SHA-256s. That is fine
|
|
6
|
-
* for small bundles but peaks at 2x the decompressed size in RAM — an 8 GB
|
|
7
|
-
* bundle OOMs a 3 GB pod.
|
|
8
|
-
*
|
|
9
|
-
* This module lets a caller validate a bundle while streaming:
|
|
10
|
-
* - `readAndValidateManifest` consumes the first tar entry (which must be
|
|
11
|
-
* `manifest.json`), validates the schema, and verifies the self-referencing
|
|
12
|
-
* `checksum` against the canonicalized JSON.
|
|
13
|
-
* - `createHashVerifier` returns a passthrough `Transform` that hashes bytes
|
|
14
|
-
* flowing through it and errors the pipeline if the final digest or byte
|
|
15
|
-
* count does not match the expected values from the manifest.
|
|
16
|
-
*
|
|
17
|
-
* Together, these let a consumer pipe every subsequent tar entry through a
|
|
18
|
-
* hash verifier before writing it to disk, without ever buffering the full
|
|
19
|
-
* bundle.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { createHash } from "node:crypto";
|
|
23
|
-
import { posix } from "node:path";
|
|
24
|
-
import { Transform, type TransformCallback } from "node:stream";
|
|
25
|
-
|
|
26
|
-
import type { StreamedTarEntry } from "./vbundle-tar-stream.js";
|
|
27
|
-
import {
|
|
28
|
-
computeLegacyManifestSha256,
|
|
29
|
-
computeManifestChecksum,
|
|
30
|
-
LegacyManifestSchema,
|
|
31
|
-
MANIFEST_MAX_BYTES,
|
|
32
|
-
MANIFEST_MAX_ENTRIES,
|
|
33
|
-
ManifestSchema,
|
|
34
|
-
type ManifestType,
|
|
35
|
-
translateLegacyManifest,
|
|
36
|
-
} from "./vbundle-validator.js";
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Public types
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
export interface ManifestReadResult {
|
|
43
|
-
manifest: ManifestType;
|
|
44
|
-
/**
|
|
45
|
-
* Fast lookup from archive path -> expected sha256 + size + linkTarget
|
|
46
|
-
* (from manifest.contents). `linkTarget` is non-null only for symlink
|
|
47
|
-
* entries — regular file entries carry `null` so callers can branch on
|
|
48
|
-
* type without a separate map.
|
|
49
|
-
*/
|
|
50
|
-
expected: Map<
|
|
51
|
-
string,
|
|
52
|
-
{ sha256: string; size: number; linkTarget: string | null }
|
|
53
|
-
>;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* All failure modes produced by this module. Every throw/error includes a
|
|
58
|
-
* stable `code` string so callers can branch on the failure kind without
|
|
59
|
-
* string-matching the message.
|
|
60
|
-
*/
|
|
61
|
-
export class StreamingValidationError extends Error {
|
|
62
|
-
public readonly code: string;
|
|
63
|
-
public readonly archivePath?: string;
|
|
64
|
-
|
|
65
|
-
constructor(code: string, message: string, archivePath?: string) {
|
|
66
|
-
super(message);
|
|
67
|
-
this.name = "StreamingValidationError";
|
|
68
|
-
this.code = code;
|
|
69
|
-
if (archivePath !== undefined) {
|
|
70
|
-
this.archivePath = archivePath;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// Manifest validation
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Drain the first tar entry — which MUST be `manifest.json` — and run the
|
|
81
|
-
* full manifest-level validation pipeline:
|
|
82
|
-
* 1. Entry name check.
|
|
83
|
-
* 2. Size cap (`MANIFEST_MAX_BYTES`).
|
|
84
|
-
* 3. JSON parse.
|
|
85
|
-
* 4. Zod schema validation.
|
|
86
|
-
* 5. Self-referencing `checksum` verification against the
|
|
87
|
-
* canonicalized JSON (minus that field).
|
|
88
|
-
* 6. Entry-count ceiling on `contents` (`maxEntries`, default
|
|
89
|
-
* `MANIFEST_MAX_ENTRIES`).
|
|
90
|
-
*
|
|
91
|
-
* On success, returns the parsed manifest plus a `Map` keyed by archive
|
|
92
|
-
* path that callers consult as each subsequent entry streams past.
|
|
93
|
-
*
|
|
94
|
-
* On failure, throws a `StreamingValidationError` with a distinct `code`
|
|
95
|
-
* for every failure mode.
|
|
96
|
-
*/
|
|
97
|
-
export async function readAndValidateManifest(
|
|
98
|
-
first: StreamedTarEntry,
|
|
99
|
-
options: { maxEntries?: number } = {},
|
|
100
|
-
): Promise<ManifestReadResult> {
|
|
101
|
-
const maxEntries = options.maxEntries ?? MANIFEST_MAX_ENTRIES;
|
|
102
|
-
if (first.header.name !== "manifest.json") {
|
|
103
|
-
// Drain the body so the underlying tar extractor isn't left dangling
|
|
104
|
-
// on backpressure before the caller reports the error.
|
|
105
|
-
first.body.resume();
|
|
106
|
-
throw new StreamingValidationError(
|
|
107
|
-
"manifest_not_first",
|
|
108
|
-
`Expected manifest.json as the first tar entry, got "${first.header.name}"`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Drain the entry body into a Buffer, enforcing the size cap as we go.
|
|
113
|
-
// The moment we cross the cap we destroy the entry stream — this signals
|
|
114
|
-
// the tar extractor (and therefore gunzip + upstream source) to abort,
|
|
115
|
-
// so a malicious archive whose "manifest" is a multi-GB decompressed
|
|
116
|
-
// stream can't force us to read through all of it before rejecting.
|
|
117
|
-
const chunks: Buffer[] = [];
|
|
118
|
-
let total = 0;
|
|
119
|
-
for await (const chunk of first.body) {
|
|
120
|
-
const buf = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
|
|
121
|
-
total += buf.length;
|
|
122
|
-
if (total > MANIFEST_MAX_BYTES) {
|
|
123
|
-
first.body.destroy();
|
|
124
|
-
throw new StreamingValidationError(
|
|
125
|
-
"manifest_too_large",
|
|
126
|
-
`manifest.json exceeds ${MANIFEST_MAX_BYTES} byte limit (read ${total} bytes before aborting)`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
chunks.push(buf);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const bodyBuf = Buffer.concat(chunks, total);
|
|
133
|
-
|
|
134
|
-
let manifestRaw: unknown;
|
|
135
|
-
try {
|
|
136
|
-
manifestRaw = JSON.parse(bodyBuf.toString("utf8"));
|
|
137
|
-
} catch (err) {
|
|
138
|
-
throw new StreamingValidationError(
|
|
139
|
-
"manifest_malformed",
|
|
140
|
-
`manifest.json is not valid JSON: ${
|
|
141
|
-
err instanceof Error ? err.message : String(err)
|
|
142
|
-
}`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Try the v1 schema first; fall back to the legacy six-field shape so
|
|
147
|
-
// existing on-disk bundles (backup snapshots, cross-version migrations)
|
|
148
|
-
// keep streaming-validating after upgrade. AGENTS.md prohibits silent
|
|
149
|
-
// breaks of persisted state.
|
|
150
|
-
const parseResult = ManifestSchema.safeParse(manifestRaw);
|
|
151
|
-
let manifest: ManifestType;
|
|
152
|
-
|
|
153
|
-
if (parseResult.success) {
|
|
154
|
-
manifest = parseResult.data;
|
|
155
|
-
// Recompute the self-referencing checksum using the exact canonicalization
|
|
156
|
-
// that vbundle-validator.ts uses. Any drift here would silently reject
|
|
157
|
-
// valid bundles produced by buildVBundle.
|
|
158
|
-
const computed = computeManifestChecksum(manifestRaw);
|
|
159
|
-
if (computed !== manifest.checksum) {
|
|
160
|
-
throw new StreamingValidationError(
|
|
161
|
-
"manifest_sha256",
|
|
162
|
-
`Manifest checksum mismatch: expected ${manifest.checksum}, computed ${computed}`,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
} else {
|
|
166
|
-
const legacyParse = LegacyManifestSchema.safeParse(manifestRaw);
|
|
167
|
-
if (!legacyParse.success) {
|
|
168
|
-
const issues = parseResult.error.issues
|
|
169
|
-
.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`)
|
|
170
|
-
.join("; ");
|
|
171
|
-
throw new StreamingValidationError(
|
|
172
|
-
"manifest_schema",
|
|
173
|
-
`manifest.json failed schema validation: ${issues}`,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
const legacy = legacyParse.data;
|
|
177
|
-
// Verify the legacy checksum using the OLD canonicalization (strip the
|
|
178
|
-
// field entirely; do NOT replace with "").
|
|
179
|
-
const computedLegacy = computeLegacyManifestSha256(manifestRaw);
|
|
180
|
-
if (computedLegacy !== legacy.manifest_sha256) {
|
|
181
|
-
throw new StreamingValidationError(
|
|
182
|
-
"manifest_sha256",
|
|
183
|
-
`Manifest checksum mismatch: expected ${legacy.manifest_sha256}, computed ${computedLegacy}`,
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
manifest = translateLegacyManifest(legacy);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (manifest.contents.length > maxEntries) {
|
|
190
|
-
throw new StreamingValidationError(
|
|
191
|
-
"bundle_too_many_entries",
|
|
192
|
-
`bundle contains more than ${maxEntries} entries (declared: ${manifest.contents.length})`,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const expected = new Map<
|
|
197
|
-
string,
|
|
198
|
-
{ sha256: string; size: number; linkTarget: string | null }
|
|
199
|
-
>();
|
|
200
|
-
for (const file of manifest.contents) {
|
|
201
|
-
if (expected.has(file.path)) {
|
|
202
|
-
throw new StreamingValidationError(
|
|
203
|
-
"manifest_duplicate_path",
|
|
204
|
-
`Manifest contains duplicate entry for path: ${file.path}`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
expected.set(file.path, {
|
|
208
|
-
sha256: file.sha256,
|
|
209
|
-
size: file.size_bytes,
|
|
210
|
-
linkTarget: file.link_target ?? null,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return { manifest, expected };
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
// Symlink entry verifier
|
|
219
|
-
// ---------------------------------------------------------------------------
|
|
220
|
-
|
|
221
|
-
/** SHA-256 hex digest of UTF-8 bytes / Uint8Array — local mirror of the helper in vbundle-streaming-importer.ts. */
|
|
222
|
-
function sha256Hex(data: Uint8Array | string): string {
|
|
223
|
-
return createHash("sha256").update(data).digest("hex");
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Verify a streaming tar entry whose manifest declared it as a symlink.
|
|
228
|
-
*
|
|
229
|
-
* Throws `StreamingValidationError` with stable codes for each failure mode:
|
|
230
|
-
* - `entry_size`: tar header declared a non-zero size (symlink bodies must be empty).
|
|
231
|
-
* - `symlink_not_declared`: manifest entry didn't carry a `link_target` so the
|
|
232
|
-
* tar typeflag-2 record is unauthorized.
|
|
233
|
-
* - `link_target_mismatch`: tar header `linkname` doesn't equal the manifest's
|
|
234
|
-
* declared `link_target`. Either the bundle was tampered with or the producer
|
|
235
|
-
* and writer disagreed.
|
|
236
|
-
* - `entry_hash`: manifest's declared sha256 does not match the sha256 of the
|
|
237
|
-
* declared link target string. Catches manifest tampering where the attacker
|
|
238
|
-
* adjusted `link_target` but forgot to recompute the digest.
|
|
239
|
-
* - `symlink_target_escapes_archive`: the resolved link target points outside
|
|
240
|
-
* the archive root (e.g. `../../etc/passwd`). Importer would otherwise
|
|
241
|
-
* follow the symlink to a privileged path on the host.
|
|
242
|
-
*
|
|
243
|
-
* On success, drains the entry body via `entry.body.resume()` so the tar
|
|
244
|
-
* extractor's `next` callback fires and the parser can advance to the next
|
|
245
|
-
* entry — symlink bodies are size=0, but `tar-stream` still emits a Readable
|
|
246
|
-
* that needs to be consumed.
|
|
247
|
-
*/
|
|
248
|
-
export function verifySymlinkEntry(input: {
|
|
249
|
-
entry: StreamedTarEntry;
|
|
250
|
-
expectedEntry: { sha256: string; size: number; linkTarget: string | null };
|
|
251
|
-
}): void {
|
|
252
|
-
const { entry, expectedEntry } = input;
|
|
253
|
-
const archivePath = entry.header.name;
|
|
254
|
-
|
|
255
|
-
if (entry.header.size !== 0) {
|
|
256
|
-
entry.body.resume();
|
|
257
|
-
throw new StreamingValidationError(
|
|
258
|
-
"entry_size",
|
|
259
|
-
`Symlink entry ${archivePath} declared size ${entry.header.size}; symlink bodies must be empty`,
|
|
260
|
-
archivePath,
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Reject manifest-declared size_bytes != 0 for symlink entries. The buffered
|
|
265
|
-
// validator catches this via FILE_SIZE_MISMATCH; without this guard a
|
|
266
|
-
// crafted bundle could declare `link_target` plus `size_bytes: 100` and the
|
|
267
|
-
// streaming side would accept (header.size is independent of the manifest's
|
|
268
|
-
// size_bytes).
|
|
269
|
-
if (expectedEntry.size !== 0) {
|
|
270
|
-
entry.body.resume();
|
|
271
|
-
throw new StreamingValidationError(
|
|
272
|
-
"entry_size",
|
|
273
|
-
`Symlink ${archivePath} has non-zero manifest-declared size ${expectedEntry.size} (expected 0)`,
|
|
274
|
-
archivePath,
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (expectedEntry.linkTarget == null) {
|
|
279
|
-
entry.body.resume();
|
|
280
|
-
throw new StreamingValidationError(
|
|
281
|
-
"symlink_not_declared",
|
|
282
|
-
`Tar entry ${archivePath} is a symlink but the manifest did not declare a link_target`,
|
|
283
|
-
archivePath,
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const tarLinkname = entry.header.linkname;
|
|
288
|
-
if (tarLinkname !== expectedEntry.linkTarget) {
|
|
289
|
-
entry.body.resume();
|
|
290
|
-
throw new StreamingValidationError(
|
|
291
|
-
"link_target_mismatch",
|
|
292
|
-
`Symlink target mismatch for ${archivePath}: tar header linkname=${JSON.stringify(
|
|
293
|
-
tarLinkname,
|
|
294
|
-
)}, manifest link_target=${JSON.stringify(expectedEntry.linkTarget)}`,
|
|
295
|
-
archivePath,
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const computedSha = sha256Hex(expectedEntry.linkTarget);
|
|
300
|
-
if (computedSha !== expectedEntry.sha256) {
|
|
301
|
-
entry.body.resume();
|
|
302
|
-
throw new StreamingValidationError(
|
|
303
|
-
"entry_hash",
|
|
304
|
-
`Symlink hash mismatch for ${archivePath}: manifest sha256=${expectedEntry.sha256}, computed over link_target=${computedSha}`,
|
|
305
|
-
archivePath,
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Absolute targets escape the archive root unconditionally — and would
|
|
310
|
-
// bypass the resolution check below because `posix.join("workspace",
|
|
311
|
-
// "/etc/passwd")` normalizes the leading `/` away and returns
|
|
312
|
-
// `"workspace/etc/passwd"`. Reject these explicitly before resolution so
|
|
313
|
-
// a symlink with target `/etc/passwd` cannot be imported as a real
|
|
314
|
-
// host-filesystem symlink.
|
|
315
|
-
if (expectedEntry.linkTarget.startsWith("/")) {
|
|
316
|
-
entry.body.resume();
|
|
317
|
-
throw new StreamingValidationError(
|
|
318
|
-
"symlink_target_escapes_archive",
|
|
319
|
-
`Symlink ${archivePath} has absolute target ${JSON.stringify(
|
|
320
|
-
expectedEntry.linkTarget,
|
|
321
|
-
)}, which escapes the archive root`,
|
|
322
|
-
archivePath,
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Path traversal: resolve the symlink target relative to the symlink's own
|
|
327
|
-
// directory inside the archive. If the resolved path escapes the archive
|
|
328
|
-
// root (begins with `..` or equals `..`), the target points outside the
|
|
329
|
-
// bundle — refuse to import. Also reject a resolved path that is itself
|
|
330
|
-
// absolute as defense-in-depth (dirname could in theory yield an absolute
|
|
331
|
-
// path; cheap to guard).
|
|
332
|
-
const resolved = posix.normalize(
|
|
333
|
-
posix.join(posix.dirname(archivePath), expectedEntry.linkTarget),
|
|
334
|
-
);
|
|
335
|
-
if (
|
|
336
|
-
resolved === ".." ||
|
|
337
|
-
resolved.startsWith("../") ||
|
|
338
|
-
resolved.startsWith("/")
|
|
339
|
-
) {
|
|
340
|
-
entry.body.resume();
|
|
341
|
-
throw new StreamingValidationError(
|
|
342
|
-
"symlink_target_escapes_archive",
|
|
343
|
-
`Symlink ${archivePath} target ${JSON.stringify(
|
|
344
|
-
expectedEntry.linkTarget,
|
|
345
|
-
)} resolves to ${resolved}, which escapes the archive root`,
|
|
346
|
-
archivePath,
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// All checks pass — drain the (empty) body so the tar extractor advances.
|
|
351
|
-
entry.body.resume();
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ---------------------------------------------------------------------------
|
|
355
|
-
// Per-entry hash + size verifier
|
|
356
|
-
// ---------------------------------------------------------------------------
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Create a passthrough `Transform` that:
|
|
360
|
-
* - forwards every chunk unchanged (identity transform for correct input),
|
|
361
|
-
* - incrementally SHA-256s the byte stream,
|
|
362
|
-
* - on `_flush`, errors the pipeline if the final digest or total byte
|
|
363
|
-
* count differs from `expected`.
|
|
364
|
-
*
|
|
365
|
-
* Errors are emitted as `StreamingValidationError` with `code` set to
|
|
366
|
-
* `"entry_hash"` or `"entry_size"` and `archivePath` populated so callers
|
|
367
|
-
* can surface which file failed.
|
|
368
|
-
*
|
|
369
|
-
* Consumers should pipe the entry body through this transform before
|
|
370
|
-
* writing to disk — that way a bad payload is caught before the byte
|
|
371
|
-
* reaches storage rather than after a whole 8 GB write completes.
|
|
372
|
-
*/
|
|
373
|
-
export function createHashVerifier(expected: {
|
|
374
|
-
sha256: string;
|
|
375
|
-
size: number;
|
|
376
|
-
archivePath: string;
|
|
377
|
-
}): Transform {
|
|
378
|
-
const hash = createHash("sha256");
|
|
379
|
-
let bytes = 0;
|
|
380
|
-
|
|
381
|
-
return new Transform({
|
|
382
|
-
transform(
|
|
383
|
-
chunk: Buffer | string,
|
|
384
|
-
encoding: BufferEncoding,
|
|
385
|
-
callback: TransformCallback,
|
|
386
|
-
) {
|
|
387
|
-
try {
|
|
388
|
-
const buf =
|
|
389
|
-
typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk;
|
|
390
|
-
hash.update(buf);
|
|
391
|
-
bytes += buf.length;
|
|
392
|
-
callback(null, buf);
|
|
393
|
-
} catch (err) {
|
|
394
|
-
callback(err instanceof Error ? err : new Error(String(err)));
|
|
395
|
-
}
|
|
396
|
-
},
|
|
397
|
-
flush(callback: TransformCallback) {
|
|
398
|
-
// Size check first — a wrong size is a sharper signal than a hash
|
|
399
|
-
// collision, and a truncated payload frequently triggers both.
|
|
400
|
-
if (bytes !== expected.size) {
|
|
401
|
-
callback(
|
|
402
|
-
new StreamingValidationError(
|
|
403
|
-
"entry_size",
|
|
404
|
-
`Size mismatch for ${expected.archivePath}: expected ${expected.size} bytes, got ${bytes}`,
|
|
405
|
-
expected.archivePath,
|
|
406
|
-
),
|
|
407
|
-
);
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const digest = hash.digest("hex");
|
|
412
|
-
if (digest !== expected.sha256) {
|
|
413
|
-
callback(
|
|
414
|
-
new StreamingValidationError(
|
|
415
|
-
"entry_hash",
|
|
416
|
-
`Checksum mismatch for ${expected.archivePath}: expected ${expected.sha256}, computed ${digest}`,
|
|
417
|
-
expected.archivePath,
|
|
418
|
-
),
|
|
419
|
-
);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
callback();
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
}
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Streaming tar reader for `.vbundle` archives.
|
|
3
|
-
*
|
|
4
|
-
* A `.vbundle` is a gzip-compressed tar. This module gunzips the incoming
|
|
5
|
-
* `Readable`, pipes it through `tar-stream`'s push-style extractor, and
|
|
6
|
-
* adapts the `(header, stream, next)` event into a consumer-friendly async
|
|
7
|
-
* generator that yields one entry at a time.
|
|
8
|
-
*
|
|
9
|
-
* Memory invariant: each entry's body is surfaced as a `Readable`, never a
|
|
10
|
-
* pre-buffered blob. Callers MUST fully consume (or explicitly `resume()`)
|
|
11
|
-
* each body stream before advancing the outer generator — otherwise the
|
|
12
|
-
* underlying tar extractor will stall waiting on backpressure.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { Readable } from "node:stream";
|
|
16
|
-
import { createGunzip } from "node:zlib";
|
|
17
|
-
|
|
18
|
-
import { extract as tarExtract } from "tar-stream";
|
|
19
|
-
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
// Public types
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
|
|
24
|
-
/** Subset of tar-stream's header surface that vbundle consumers care about. */
|
|
25
|
-
export interface StreamedTarHeader {
|
|
26
|
-
name: string;
|
|
27
|
-
size: number;
|
|
28
|
-
type: "file" | "directory" | "pax-header" | "symlink" | "other";
|
|
29
|
-
/** Populated only when `type === "symlink"`; the symlink's target string from the tar header. */
|
|
30
|
-
linkname?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface StreamedTarEntry {
|
|
34
|
-
header: StreamedTarHeader;
|
|
35
|
-
/**
|
|
36
|
-
* The entry body. Must be fully consumed (drained via for-await, piped,
|
|
37
|
-
* or explicitly `.resume()`'d) before the generator is advanced again.
|
|
38
|
-
*/
|
|
39
|
-
body: Readable;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Implementation
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
interface PendingEntry {
|
|
47
|
-
header: StreamedTarHeader;
|
|
48
|
-
body: Readable;
|
|
49
|
-
next: (err?: Error | null) => void;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function normalizeHeaderType(
|
|
53
|
-
type: string | undefined,
|
|
54
|
-
): StreamedTarHeader["type"] {
|
|
55
|
-
switch (type) {
|
|
56
|
-
case "file":
|
|
57
|
-
return "file";
|
|
58
|
-
case "directory":
|
|
59
|
-
return "directory";
|
|
60
|
-
case "pax-header":
|
|
61
|
-
return "pax-header";
|
|
62
|
-
case "symlink":
|
|
63
|
-
return "symlink";
|
|
64
|
-
default:
|
|
65
|
-
return "other";
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Stream a `.vbundle` archive as an async sequence of tar entries.
|
|
71
|
-
*
|
|
72
|
-
* Errors from the upstream source, gunzip, or tar-stream extractor are
|
|
73
|
-
* surfaced by throwing from the generator.
|
|
74
|
-
*
|
|
75
|
-
* Early termination (caller `break`s or the for-await loop throws) destroys
|
|
76
|
-
* the upstream source and the tar extractor so that sockets and file
|
|
77
|
-
* descriptors are released promptly.
|
|
78
|
-
*/
|
|
79
|
-
export async function* parseVBundleStream(
|
|
80
|
-
source: Readable,
|
|
81
|
-
): AsyncGenerator<StreamedTarEntry, void, void> {
|
|
82
|
-
const gunzip = createGunzip();
|
|
83
|
-
const extractor = tarExtract();
|
|
84
|
-
|
|
85
|
-
// Terminal error captured from any stage in the pipeline. Consumed by the
|
|
86
|
-
// generator's internal pump; re-thrown on the next `yield`.
|
|
87
|
-
let pipelineError: Error | null = null;
|
|
88
|
-
let finished = false;
|
|
89
|
-
|
|
90
|
-
// Single-slot mailbox: the extractor pushes one entry here, and the
|
|
91
|
-
// generator consumes it. We gate the push-style API by holding the tar
|
|
92
|
-
// extractor's `next` callback until the caller is done with the body.
|
|
93
|
-
let waiter: {
|
|
94
|
-
resolve: (entry: PendingEntry | null) => void;
|
|
95
|
-
reject: (err: Error) => void;
|
|
96
|
-
} | null = null;
|
|
97
|
-
let pending: PendingEntry | null = null;
|
|
98
|
-
|
|
99
|
-
function pushEntry(entry: PendingEntry): void {
|
|
100
|
-
if (waiter) {
|
|
101
|
-
const w = waiter;
|
|
102
|
-
waiter = null;
|
|
103
|
-
w.resolve(entry);
|
|
104
|
-
} else {
|
|
105
|
-
pending = entry;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function pushError(err: Error): void {
|
|
110
|
-
if (pipelineError) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
pipelineError = err;
|
|
114
|
-
if (waiter) {
|
|
115
|
-
const w = waiter;
|
|
116
|
-
waiter = null;
|
|
117
|
-
w.reject(err);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function pushFinish(): void {
|
|
122
|
-
finished = true;
|
|
123
|
-
if (waiter) {
|
|
124
|
-
const w = waiter;
|
|
125
|
-
waiter = null;
|
|
126
|
-
w.resolve(null);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
extractor.on("entry", (header, body, next) => {
|
|
131
|
-
// Avoid unhandled "error" on body streams destroyed mid-flight; the
|
|
132
|
-
// extractor itself propagates the real error to its "error" listener.
|
|
133
|
-
body.on("error", () => {});
|
|
134
|
-
const normalizedType = normalizeHeaderType(header.type);
|
|
135
|
-
const surfaced: StreamedTarHeader = {
|
|
136
|
-
name: header.name,
|
|
137
|
-
size: header.size,
|
|
138
|
-
type: normalizedType,
|
|
139
|
-
};
|
|
140
|
-
if (normalizedType === "symlink" && header.linkname) {
|
|
141
|
-
surfaced.linkname = header.linkname;
|
|
142
|
-
}
|
|
143
|
-
pushEntry({
|
|
144
|
-
header: surfaced,
|
|
145
|
-
body,
|
|
146
|
-
next,
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
extractor.on("error", (err: Error) => {
|
|
151
|
-
pushError(err);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
extractor.on("finish", () => {
|
|
155
|
-
pushFinish();
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
gunzip.on("error", (err: Error) => {
|
|
159
|
-
pushError(err);
|
|
160
|
-
extractor.destroy(err);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
source.on("error", (err: Error) => {
|
|
164
|
-
pushError(err);
|
|
165
|
-
gunzip.destroy(err);
|
|
166
|
-
extractor.destroy(err);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// `pipe()` is a no-op on an already-destroyed Readable, so gunzip and
|
|
170
|
-
// extractor would never see `end` and `nextEntry()` would await forever.
|
|
171
|
-
// Synthesize a terminal error instead.
|
|
172
|
-
if (source.destroyed) {
|
|
173
|
-
const err = new Error("vbundle source stream was destroyed before parse");
|
|
174
|
-
pushError(err);
|
|
175
|
-
gunzip.destroy(err);
|
|
176
|
-
extractor.destroy(err);
|
|
177
|
-
} else {
|
|
178
|
-
source.pipe(gunzip).pipe(extractor);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function nextEntry(): Promise<PendingEntry | null> {
|
|
182
|
-
if (pipelineError) {
|
|
183
|
-
return Promise.reject(pipelineError);
|
|
184
|
-
}
|
|
185
|
-
if (pending) {
|
|
186
|
-
const p = pending;
|
|
187
|
-
pending = null;
|
|
188
|
-
return Promise.resolve(p);
|
|
189
|
-
}
|
|
190
|
-
if (finished) {
|
|
191
|
-
return Promise.resolve(null);
|
|
192
|
-
}
|
|
193
|
-
return new Promise<PendingEntry | null>((resolve, reject) => {
|
|
194
|
-
waiter = { resolve, reject };
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
while (true) {
|
|
200
|
-
const entry = await nextEntry();
|
|
201
|
-
if (entry === null) {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
let advanced = false;
|
|
206
|
-
const advance = (err?: Error | null): void => {
|
|
207
|
-
if (advanced) {
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
advanced = true;
|
|
211
|
-
entry.next(err ?? null);
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
// When the consumer finishes (or abandons) the body, release the tar
|
|
215
|
-
// extractor so the next entry can flow.
|
|
216
|
-
entry.body.once("end", () => advance());
|
|
217
|
-
entry.body.once("close", () => advance());
|
|
218
|
-
entry.body.once("error", (err: Error) => advance(err));
|
|
219
|
-
|
|
220
|
-
try {
|
|
221
|
-
yield { header: entry.header, body: entry.body };
|
|
222
|
-
} catch (err) {
|
|
223
|
-
// Caller threw (or re-threw) inside the for-await loop. Propagate
|
|
224
|
-
// after cleanup in the finally below.
|
|
225
|
-
advance(err instanceof Error ? err : new Error(String(err)));
|
|
226
|
-
throw err;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// If the consumer neither consumed nor destroyed the body, drain it
|
|
230
|
-
// for them so the extractor can advance.
|
|
231
|
-
if (!advanced) {
|
|
232
|
-
entry.body.resume();
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
} finally {
|
|
236
|
-
// Early termination (break, throw, or natural completion): tear down the
|
|
237
|
-
// pipeline so we don't leak the socket/file descriptor underneath.
|
|
238
|
-
if (!finished || pipelineError) {
|
|
239
|
-
source.destroy();
|
|
240
|
-
gunzip.destroy();
|
|
241
|
-
extractor.destroy();
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|