@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,724 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validates .vbundle archive files for migration import/export.
|
|
3
|
-
*
|
|
4
|
-
* A .vbundle is a gzip-compressed tar archive containing:
|
|
5
|
-
* - manifest.json: metadata with schema_version, checksums, and bundle info
|
|
6
|
-
* - workspace/: the entire workspace directory tree (new format), OR
|
|
7
|
-
* data/db/assistant.db + config/settings.json (old format)
|
|
8
|
-
*
|
|
9
|
-
* Validation steps:
|
|
10
|
-
* 1. Archive structure: valid gzip tar with required entries
|
|
11
|
-
* 2. Manifest schema: required fields and correct types
|
|
12
|
-
* 3. Manifest checksum: SHA-256 of canonicalized JSON matches declared digest
|
|
13
|
-
* 4. Per-file content integrity: SHA-256 of each file matches manifest checksums
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
-
import { posix } from "node:path";
|
|
18
|
-
import { gunzipSync } from "node:zlib";
|
|
19
|
-
|
|
20
|
-
import { z } from "zod";
|
|
21
|
-
|
|
22
|
-
import { parseUstarSizeField } from "../../archive/ustar-size.js";
|
|
23
|
-
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
// Manifest schema (v1)
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
const ManifestFileEntry = z.object({
|
|
29
|
-
path: z.string().min(1),
|
|
30
|
-
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
31
|
-
size_bytes: z.number().int().nonnegative(),
|
|
32
|
-
link_target: z.string().min(1).optional(),
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const AssistantInfo = z.object({
|
|
36
|
-
id: z.string().min(1),
|
|
37
|
-
name: z.string().min(1),
|
|
38
|
-
runtime_version: z.string().min(1),
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const Origin = z.object({
|
|
42
|
-
mode: z.enum(["managed", "self-hosted-remote", "self-hosted-local"]),
|
|
43
|
-
platform_version: z.string().optional(),
|
|
44
|
-
hostname: z.string().optional(),
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const Compatibility = z.object({
|
|
48
|
-
min_runtime_version: z.string().min(1),
|
|
49
|
-
max_runtime_version: z.string().nullable(),
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const ExportOptions = z.object({
|
|
53
|
-
include_logs: z.boolean(),
|
|
54
|
-
include_browser_state: z.boolean(),
|
|
55
|
-
include_memory_vectors: z.boolean(),
|
|
56
|
-
include_gateway: z.boolean().optional(),
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
export const ManifestSchema = z
|
|
60
|
-
.object({
|
|
61
|
-
schema_version: z.literal(1),
|
|
62
|
-
bundle_id: z.string().uuid(),
|
|
63
|
-
created_at: z.string().datetime({ offset: true }),
|
|
64
|
-
assistant: AssistantInfo,
|
|
65
|
-
origin: Origin,
|
|
66
|
-
compatibility: Compatibility,
|
|
67
|
-
contents: z.array(ManifestFileEntry),
|
|
68
|
-
checksum: z.string().regex(/^[0-9a-f]{64}$/),
|
|
69
|
-
secrets_redacted: z.boolean(),
|
|
70
|
-
export_options: ExportOptions,
|
|
71
|
-
})
|
|
72
|
-
.refine((m) => m.origin.mode !== "managed" || m.secrets_redacted === true, {
|
|
73
|
-
message: "secrets_redacted must be true when origin.mode is 'managed'",
|
|
74
|
-
path: ["secrets_redacted"],
|
|
75
|
-
})
|
|
76
|
-
.refine(
|
|
77
|
-
(m) =>
|
|
78
|
-
m.contents.some(
|
|
79
|
-
(f) =>
|
|
80
|
-
f.path === "data/db/assistant.db" ||
|
|
81
|
-
f.path === "workspace/data/db/assistant.db",
|
|
82
|
-
),
|
|
83
|
-
{
|
|
84
|
-
message:
|
|
85
|
-
"contents must include an entry for data/db/assistant.db (legacy format) or workspace/data/db/assistant.db (current format)",
|
|
86
|
-
path: ["contents"],
|
|
87
|
-
},
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
export type ManifestFileEntryType = z.infer<typeof ManifestFileEntry>;
|
|
91
|
-
export type ManifestType = z.infer<typeof ManifestSchema>;
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// Legacy manifest schema (pre-v1, six-field shape)
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
//
|
|
97
|
-
// Older runtime versions wrote a six-field manifest with `schema_version: "1.0"`,
|
|
98
|
-
// `files`, `size` (per-entry), and a self-referencing `manifest_sha256` field.
|
|
99
|
-
// Existing on-disk artifacts produced by those versions — backup snapshots,
|
|
100
|
-
// cross-version migration bundles — must keep validating after upgrade,
|
|
101
|
-
// per AGENTS.md "no silent breaks of persisted state".
|
|
102
|
-
//
|
|
103
|
-
// We accept legacy bundles via a fallback parse + translator so the rest of
|
|
104
|
-
// the validation pipeline (per-file hash + size verification) only ever sees
|
|
105
|
-
// the v1 shape.
|
|
106
|
-
|
|
107
|
-
const LegacyManifestFileEntry = z.object({
|
|
108
|
-
path: z.string(),
|
|
109
|
-
sha256: z.string(),
|
|
110
|
-
size: z.number().int().nonnegative(),
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
export const LegacyManifestSchema = z.object({
|
|
114
|
-
schema_version: z.string(),
|
|
115
|
-
created_at: z.string(),
|
|
116
|
-
source: z.string().optional(),
|
|
117
|
-
description: z.string().optional(),
|
|
118
|
-
files: z.array(LegacyManifestFileEntry),
|
|
119
|
-
manifest_sha256: z.string(),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
export type LegacyManifestType = z.infer<typeof LegacyManifestSchema>;
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Recompute the legacy `manifest_sha256` field — strips the field entirely
|
|
126
|
-
* (rather than emptying it) before canonicalizing, matching the pre-v1
|
|
127
|
-
* producer behavior. Required so legacy bundles whose checksum was computed
|
|
128
|
-
* the old way still verify after upgrade.
|
|
129
|
-
*/
|
|
130
|
-
export function computeLegacyManifestSha256(manifest: unknown): string {
|
|
131
|
-
const copy = { ...(manifest as Record<string, unknown>) };
|
|
132
|
-
delete copy.manifest_sha256;
|
|
133
|
-
return sha256Hex(canonicalizeJson(copy));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Coerce a legacy ISO-ish `created_at` into the v1 datetime regex shape.
|
|
138
|
-
* Pre-v1 producers always wrote `new Date().toISOString()`, which already
|
|
139
|
-
* has the `Z` suffix the v1 regex requires; this helper is defensive against
|
|
140
|
-
* any historical producer that omitted the offset/`Z`.
|
|
141
|
-
*/
|
|
142
|
-
function coerceLegacyCreatedAt(raw: string): string {
|
|
143
|
-
// If the string already parses as a Date, keep it as the canonical ISO form.
|
|
144
|
-
const parsed = new Date(raw);
|
|
145
|
-
if (!Number.isNaN(parsed.getTime())) {
|
|
146
|
-
// `toISOString()` always emits the `...Z` form the v1 regex accepts.
|
|
147
|
-
return parsed.toISOString();
|
|
148
|
-
}
|
|
149
|
-
return raw;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Translate a parsed legacy manifest into a v1 `ManifestType` so the rest of
|
|
154
|
-
* the validator pipeline can operate on a uniform shape.
|
|
155
|
-
*
|
|
156
|
-
* Legacy bundles never carried assistant identity, origin, compatibility, or
|
|
157
|
-
* export-option signals; we substitute conservative placeholders that satisfy
|
|
158
|
-
* the v1 schema's `.refine()` rules without misrepresenting the source bundle.
|
|
159
|
-
*/
|
|
160
|
-
export function translateLegacyManifest(
|
|
161
|
-
legacy: LegacyManifestType,
|
|
162
|
-
): ManifestType {
|
|
163
|
-
return {
|
|
164
|
-
schema_version: 1,
|
|
165
|
-
bundle_id: randomUUID(),
|
|
166
|
-
created_at: coerceLegacyCreatedAt(legacy.created_at),
|
|
167
|
-
assistant: {
|
|
168
|
-
id: "self",
|
|
169
|
-
name: "Assistant",
|
|
170
|
-
runtime_version: "0.0.0-legacy",
|
|
171
|
-
},
|
|
172
|
-
// Legacy bundles came from the local self-hosted exporter; the
|
|
173
|
-
// conservative default is "self-hosted-local" so the v1 managed/secrets
|
|
174
|
-
// refine never trips on a translated legacy bundle.
|
|
175
|
-
origin: { mode: "self-hosted-local" },
|
|
176
|
-
compatibility: {
|
|
177
|
-
min_runtime_version: "0.0.0-legacy",
|
|
178
|
-
max_runtime_version: null,
|
|
179
|
-
},
|
|
180
|
-
contents: legacy.files.map((f) => ({
|
|
181
|
-
path: f.path,
|
|
182
|
-
sha256: f.sha256,
|
|
183
|
-
size_bytes: f.size,
|
|
184
|
-
})),
|
|
185
|
-
checksum: legacy.manifest_sha256,
|
|
186
|
-
secrets_redacted: false,
|
|
187
|
-
export_options: {
|
|
188
|
-
include_logs: false,
|
|
189
|
-
include_browser_state: false,
|
|
190
|
-
include_memory_vectors: false,
|
|
191
|
-
},
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
// Validation result types
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
export interface ValidationError {
|
|
200
|
-
code: string;
|
|
201
|
-
message: string;
|
|
202
|
-
path?: string;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export interface VBundleTarEntry {
|
|
206
|
-
name: string;
|
|
207
|
-
data: Uint8Array;
|
|
208
|
-
size: number;
|
|
209
|
-
/** Set when the tar entry is typeflag-2 (symlink); carries the link target
|
|
210
|
-
* decoded from the ustar linkname field. */
|
|
211
|
-
linkname?: string;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export interface VBundleValidationResult {
|
|
215
|
-
is_valid: boolean;
|
|
216
|
-
errors: ValidationError[];
|
|
217
|
-
manifest?: ManifestType;
|
|
218
|
-
/** Parsed tar entries — only present when validation succeeds, so callers
|
|
219
|
-
* can reuse them without decompressing the archive a second time. */
|
|
220
|
-
entries?: Map<string, VBundleTarEntry>;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
// Tar parsing (minimal, spec-compliant for ustar/GNU tar)
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
|
|
227
|
-
interface TarEntry {
|
|
228
|
-
name: string;
|
|
229
|
-
data: Uint8Array;
|
|
230
|
-
size: number;
|
|
231
|
-
/** Set when the tar entry is typeflag-2 (symlink); carries the link target
|
|
232
|
-
* decoded from the ustar linkname field. */
|
|
233
|
-
linkname?: string;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Parse a raw tar archive (uncompressed) into its entries.
|
|
238
|
-
* Handles ustar and GNU tar long name extensions.
|
|
239
|
-
*/
|
|
240
|
-
function parseTar(buffer: Uint8Array): TarEntry[] {
|
|
241
|
-
const entries: TarEntry[] = [];
|
|
242
|
-
let offset = 0;
|
|
243
|
-
const BLOCK_SIZE = 512;
|
|
244
|
-
let longName: string | null = null;
|
|
245
|
-
|
|
246
|
-
while (offset + BLOCK_SIZE <= buffer.length) {
|
|
247
|
-
const header = buffer.subarray(offset, offset + BLOCK_SIZE);
|
|
248
|
-
|
|
249
|
-
// Check for end-of-archive (two consecutive zero blocks)
|
|
250
|
-
if (header.every((b) => b === 0)) {
|
|
251
|
-
break;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Extract file name
|
|
255
|
-
let name: string;
|
|
256
|
-
if (longName) {
|
|
257
|
-
name = longName;
|
|
258
|
-
longName = null;
|
|
259
|
-
} else {
|
|
260
|
-
// POSIX ustar: prefix (345 bytes at 345) + name (100 bytes at 0)
|
|
261
|
-
const rawName = decodeNullTerminated(header, 0, 100);
|
|
262
|
-
const prefix = decodeNullTerminated(header, 345, 155);
|
|
263
|
-
name = prefix ? `${prefix}/${rawName}` : rawName;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// File type (byte 156)
|
|
267
|
-
const typeFlag = String.fromCharCode(header[156]);
|
|
268
|
-
|
|
269
|
-
const size = parseUstarSizeField(header);
|
|
270
|
-
|
|
271
|
-
// Calculate data blocks
|
|
272
|
-
const dataBlocks = Math.ceil(size / BLOCK_SIZE);
|
|
273
|
-
const dataStart = offset + BLOCK_SIZE;
|
|
274
|
-
const data = buffer.subarray(dataStart, dataStart + size);
|
|
275
|
-
|
|
276
|
-
// GNU tar long name extension (type 'L')
|
|
277
|
-
if (typeFlag === "L") {
|
|
278
|
-
longName = new TextDecoder().decode(data).replace(/\0+$/, "");
|
|
279
|
-
offset = dataStart + dataBlocks * BLOCK_SIZE;
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// PAX extended header (type 'x') — extract path= attribute for next entry
|
|
284
|
-
if (typeFlag === "x") {
|
|
285
|
-
const paxText = new TextDecoder().decode(data);
|
|
286
|
-
const pathMatch = paxText.match(/\d+ path=([^\n]+)\n/);
|
|
287
|
-
if (pathMatch) {
|
|
288
|
-
longName = pathMatch[1];
|
|
289
|
-
}
|
|
290
|
-
offset = dataStart + dataBlocks * BLOCK_SIZE;
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Symlink (type '2') — empty body regardless of declared size; the link
|
|
295
|
-
// target lives in the ustar linkname field (157..256). We preserve the
|
|
296
|
-
// tar-declared `size` here (rather than forcing it to 0) so the
|
|
297
|
-
// downstream `archiveEntry.size !== 0` check can surface
|
|
298
|
-
// `FILE_SIZE_MISMATCH` on malformed symlink headers. The body itself is
|
|
299
|
-
// always an empty buffer — symlinks have no data body even if the
|
|
300
|
-
// header lies about it.
|
|
301
|
-
if (typeFlag === "2") {
|
|
302
|
-
const linkname = decodeNullTerminated(header, 157, 100);
|
|
303
|
-
entries.push({
|
|
304
|
-
name: normalizePath(name),
|
|
305
|
-
data: new Uint8Array(0),
|
|
306
|
-
size,
|
|
307
|
-
linkname,
|
|
308
|
-
});
|
|
309
|
-
offset = dataStart + dataBlocks * BLOCK_SIZE;
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Regular file or hard link
|
|
314
|
-
if (typeFlag === "0" || typeFlag === "\0" || typeFlag === "") {
|
|
315
|
-
entries.push({ name: normalizePath(name), data, size });
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
offset = dataStart + dataBlocks * BLOCK_SIZE;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return entries;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function decodeNullTerminated(
|
|
325
|
-
buf: Uint8Array,
|
|
326
|
-
start: number,
|
|
327
|
-
maxLen: number,
|
|
328
|
-
): string {
|
|
329
|
-
let end = start;
|
|
330
|
-
while (end < start + maxLen && buf[end] !== 0) {
|
|
331
|
-
end++;
|
|
332
|
-
}
|
|
333
|
-
return new TextDecoder().decode(buf.subarray(start, end));
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function normalizePath(p: string): string {
|
|
337
|
-
// Remove leading ./ and trailing /
|
|
338
|
-
return p.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// ---------------------------------------------------------------------------
|
|
342
|
-
// Hash helpers
|
|
343
|
-
// ---------------------------------------------------------------------------
|
|
344
|
-
|
|
345
|
-
function sha256Hex(data: Uint8Array | string): string {
|
|
346
|
-
return createHash("sha256").update(data).digest("hex");
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Canonicalize a JSON object by sorting keys recursively, then SHA-256 hash it.
|
|
351
|
-
* This matches the cloud's canonicalization approach.
|
|
352
|
-
*/
|
|
353
|
-
export function canonicalizeJson(obj: unknown): string {
|
|
354
|
-
return JSON.stringify(obj, (_key, value) => {
|
|
355
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
356
|
-
const sorted: Record<string, unknown> = {};
|
|
357
|
-
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
|
358
|
-
sorted[k] = (value as Record<string, unknown>)[k];
|
|
359
|
-
}
|
|
360
|
-
return sorted;
|
|
361
|
-
}
|
|
362
|
-
return value;
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Recompute the `checksum` field for a manifest object.
|
|
368
|
-
*
|
|
369
|
-
* The v1 schema spec says the checksum is computed over the canonicalized
|
|
370
|
-
* manifest with the `checksum` field set to the empty string (not absent),
|
|
371
|
-
* so we replace it before canonicalizing — both producers and validators
|
|
372
|
-
* must agree on this exact wire shape. Centralized here so the streaming
|
|
373
|
-
* validator and the in-memory validator agree on the exact canonicalization.
|
|
374
|
-
*/
|
|
375
|
-
export function computeManifestChecksum(manifest: unknown): string {
|
|
376
|
-
const copy = { ...(manifest as Record<string, unknown>), checksum: "" };
|
|
377
|
-
return sha256Hex(canonicalizeJson(copy));
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
// Core validation
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Hard caps on `manifest.json`, shared by the buffered and streaming
|
|
386
|
-
* validators so preflight and import agree on what is acceptable. The two
|
|
387
|
-
* are sized together: the manifest carries one `contents` entry (path,
|
|
388
|
-
* sha256, size) per bundled file at roughly 150 bytes each, so 200,000
|
|
389
|
-
* entries serialize to about 32 MiB.
|
|
390
|
-
*
|
|
391
|
-
* That absorbs a workspace with a stray dependency install or checked-out
|
|
392
|
-
* repo while still bounding the transient memory a hostile "manifest" can
|
|
393
|
-
* force the importer to hold (buffer, decoded string, parsed object,
|
|
394
|
-
* canonical re-serialization for the self-checksum) and the per-entry work
|
|
395
|
-
* it commits to, on a memory-constrained pod.
|
|
396
|
-
*/
|
|
397
|
-
export const MANIFEST_MAX_BYTES = 32 * 1024 * 1024;
|
|
398
|
-
export const MANIFEST_MAX_ENTRIES = 200_000;
|
|
399
|
-
|
|
400
|
-
export interface ValidateVBundleOptions {
|
|
401
|
-
/**
|
|
402
|
-
* Reject manifests over `MANIFEST_MAX_BYTES` or `MANIFEST_MAX_ENTRIES`.
|
|
403
|
-
* Preflights that precede a streaming import set this so they never
|
|
404
|
-
* approve a bundle the import will refuse. The buffered import path
|
|
405
|
-
* (`commitImport`) has no such ceilings, so it stays off there to keep
|
|
406
|
-
* existing large backups restorable.
|
|
407
|
-
*/
|
|
408
|
-
enforceStreamingLimits?: boolean;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Only manifest.json is structurally required. The DB and config live under
|
|
412
|
-
// workspace/ (new format) or data/db/ + config/ (old format) — both are valid.
|
|
413
|
-
const REQUIRED_ENTRIES = ["manifest.json"];
|
|
414
|
-
|
|
415
|
-
// 2 GB — must accommodate large but valid migrations from buildExportVBundle()
|
|
416
|
-
const MAX_DECOMPRESSED_SIZE = 2 * 1024 * 1024 * 1024;
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Validate a .vbundle archive from raw bytes.
|
|
420
|
-
*
|
|
421
|
-
* Performs four validation passes:
|
|
422
|
-
* 1. Archive structure (gzip decompression, tar parsing, required entries)
|
|
423
|
-
* 2. Manifest schema (Zod validation of manifest.json)
|
|
424
|
-
* 3. Manifest checksum (SHA-256 of canonicalized JSON with the `checksum` field set to empty string)
|
|
425
|
-
* 4. Per-file content integrity (SHA-256 of each file vs manifest declaration)
|
|
426
|
-
*/
|
|
427
|
-
export function validateVBundle(
|
|
428
|
-
data: Uint8Array,
|
|
429
|
-
{ enforceStreamingLimits = false }: ValidateVBundleOptions = {},
|
|
430
|
-
): VBundleValidationResult {
|
|
431
|
-
const errors: ValidationError[] = [];
|
|
432
|
-
|
|
433
|
-
// Step 1: Decompress gzip with size cap to prevent zip-bomb DoS
|
|
434
|
-
let tarData: Uint8Array;
|
|
435
|
-
try {
|
|
436
|
-
tarData = gunzipSync(data, { maxOutputLength: MAX_DECOMPRESSED_SIZE });
|
|
437
|
-
} catch (err) {
|
|
438
|
-
const message =
|
|
439
|
-
err instanceof RangeError
|
|
440
|
-
? `Decompressed archive exceeds ${MAX_DECOMPRESSED_SIZE} byte limit`
|
|
441
|
-
: `Archive is not a valid gzip file: ${
|
|
442
|
-
err instanceof Error ? err.message : String(err)
|
|
443
|
-
}`;
|
|
444
|
-
const code =
|
|
445
|
-
err instanceof RangeError ? "DECOMPRESSED_SIZE_EXCEEDED" : "INVALID_GZIP";
|
|
446
|
-
errors.push({ code, message });
|
|
447
|
-
return { is_valid: false, errors };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Step 2: Parse tar
|
|
451
|
-
let entries: TarEntry[];
|
|
452
|
-
try {
|
|
453
|
-
entries = parseTar(tarData);
|
|
454
|
-
} catch (err) {
|
|
455
|
-
errors.push({
|
|
456
|
-
code: "INVALID_TAR",
|
|
457
|
-
message: `Archive is not a valid tar file: ${
|
|
458
|
-
err instanceof Error ? err.message : String(err)
|
|
459
|
-
}`,
|
|
460
|
-
});
|
|
461
|
-
return { is_valid: false, errors };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Build a lookup map for entries
|
|
465
|
-
const entryMap = new Map<string, TarEntry>();
|
|
466
|
-
for (const entry of entries) {
|
|
467
|
-
entryMap.set(entry.name, entry);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Step 3: Check required entries
|
|
471
|
-
for (const required of REQUIRED_ENTRIES) {
|
|
472
|
-
if (!entryMap.has(required)) {
|
|
473
|
-
errors.push({
|
|
474
|
-
code: "MISSING_ENTRY",
|
|
475
|
-
message: `Required archive entry not found: ${required}`,
|
|
476
|
-
path: required,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// If manifest.json is missing, we cannot proceed with further validation
|
|
482
|
-
const manifestEntry = entryMap.get("manifest.json");
|
|
483
|
-
if (!manifestEntry) {
|
|
484
|
-
return { is_valid: false, errors };
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Step 4: Parse and validate manifest schema
|
|
488
|
-
if (
|
|
489
|
-
enforceStreamingLimits &&
|
|
490
|
-
manifestEntry.data.length > MANIFEST_MAX_BYTES
|
|
491
|
-
) {
|
|
492
|
-
errors.push({
|
|
493
|
-
code: "MANIFEST_TOO_LARGE",
|
|
494
|
-
message: `manifest.json exceeds ${MANIFEST_MAX_BYTES} byte limit (${manifestEntry.data.length} bytes)`,
|
|
495
|
-
path: "manifest.json",
|
|
496
|
-
});
|
|
497
|
-
return { is_valid: false, errors };
|
|
498
|
-
}
|
|
499
|
-
let manifestRaw: unknown;
|
|
500
|
-
try {
|
|
501
|
-
manifestRaw = JSON.parse(new TextDecoder().decode(manifestEntry.data));
|
|
502
|
-
} catch (err) {
|
|
503
|
-
errors.push({
|
|
504
|
-
code: "INVALID_MANIFEST_JSON",
|
|
505
|
-
message: `manifest.json is not valid JSON: ${
|
|
506
|
-
err instanceof Error ? err.message : String(err)
|
|
507
|
-
}`,
|
|
508
|
-
path: "manifest.json",
|
|
509
|
-
});
|
|
510
|
-
return { is_valid: false, errors };
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// Try the v1 schema first. If that fails, fall back to the legacy six-field
|
|
514
|
-
// shape so existing on-disk bundles (backup snapshots, cross-version
|
|
515
|
-
// migrations) keep validating after upgrade. AGENTS.md prohibits silent
|
|
516
|
-
// breaks of persisted state.
|
|
517
|
-
const parseResult = ManifestSchema.safeParse(manifestRaw);
|
|
518
|
-
let manifest: ManifestType;
|
|
519
|
-
|
|
520
|
-
if (parseResult.success) {
|
|
521
|
-
manifest = parseResult.data;
|
|
522
|
-
|
|
523
|
-
// Step 5 (v1): Verify manifest checksum — SHA-256 of canonicalized JSON
|
|
524
|
-
// with the `checksum` field replaced by an empty string.
|
|
525
|
-
const computedChecksum = computeManifestChecksum(manifestRaw);
|
|
526
|
-
if (computedChecksum !== manifest.checksum) {
|
|
527
|
-
errors.push({
|
|
528
|
-
code: "MANIFEST_CHECKSUM_MISMATCH",
|
|
529
|
-
message: `Manifest checksum mismatch: expected ${manifest.checksum}, computed ${computedChecksum}`,
|
|
530
|
-
path: "manifest.json",
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
} else {
|
|
534
|
-
const legacyParse = LegacyManifestSchema.safeParse(manifestRaw);
|
|
535
|
-
if (!legacyParse.success) {
|
|
536
|
-
// Truly malformed — surface the v1 error for the clearer error trail.
|
|
537
|
-
for (const issue of parseResult.error.issues) {
|
|
538
|
-
errors.push({
|
|
539
|
-
code: "MANIFEST_SCHEMA_ERROR",
|
|
540
|
-
message: `Manifest validation error at ${issue.path.join(".")}: ${
|
|
541
|
-
issue.message
|
|
542
|
-
}`,
|
|
543
|
-
path: `manifest.json/${issue.path.join(".")}`,
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
return { is_valid: false, errors };
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Step 5 (legacy): Verify the legacy `manifest_sha256` using the OLD
|
|
550
|
-
// canonicalization (strip the field entirely; do NOT replace with "").
|
|
551
|
-
const legacy = legacyParse.data;
|
|
552
|
-
const computedLegacyChecksum = computeLegacyManifestSha256(manifestRaw);
|
|
553
|
-
if (computedLegacyChecksum !== legacy.manifest_sha256) {
|
|
554
|
-
errors.push({
|
|
555
|
-
code: "MANIFEST_CHECKSUM_MISMATCH",
|
|
556
|
-
message: `Manifest checksum mismatch: expected ${legacy.manifest_sha256}, computed ${computedLegacyChecksum}`,
|
|
557
|
-
path: "manifest.json",
|
|
558
|
-
});
|
|
559
|
-
return { is_valid: false, errors };
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Translate to v1 so the rest of the pipeline (per-file hash + size
|
|
563
|
-
// verification, refine rules) sees a uniform shape.
|
|
564
|
-
manifest = translateLegacyManifest(legacy);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
if (
|
|
568
|
-
enforceStreamingLimits &&
|
|
569
|
-
manifest.contents.length > MANIFEST_MAX_ENTRIES
|
|
570
|
-
) {
|
|
571
|
-
errors.push({
|
|
572
|
-
code: "MANIFEST_TOO_MANY_ENTRIES",
|
|
573
|
-
message: `manifest.json declares more than ${MANIFEST_MAX_ENTRIES} entries (${manifest.contents.length})`,
|
|
574
|
-
path: "manifest.json",
|
|
575
|
-
});
|
|
576
|
-
return { is_valid: false, errors };
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Step 6: Verify per-file content integrity
|
|
580
|
-
const manifestFilePaths = new Set(manifest.contents.map((f) => f.path));
|
|
581
|
-
|
|
582
|
-
for (const fileEntry of manifest.contents) {
|
|
583
|
-
const archiveEntry = entryMap.get(fileEntry.path);
|
|
584
|
-
if (!archiveEntry) {
|
|
585
|
-
errors.push({
|
|
586
|
-
code: "MISSING_DECLARED_FILE",
|
|
587
|
-
message: `File declared in manifest not found in archive: ${fileEntry.path}`,
|
|
588
|
-
path: fileEntry.path,
|
|
589
|
-
});
|
|
590
|
-
continue;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
if (fileEntry.link_target !== undefined) {
|
|
594
|
-
// Symlink branch: typeflag agreement, linkname agreement, sha over the
|
|
595
|
-
// link target string, size==0 on both sides, and path-traversal rejection.
|
|
596
|
-
if (archiveEntry.linkname === undefined) {
|
|
597
|
-
errors.push({
|
|
598
|
-
code: "SYMLINK_TYPEFLAG_MISMATCH",
|
|
599
|
-
message: `Manifest declares symlink for ${fileEntry.path} but tar entry is not typeflag-2`,
|
|
600
|
-
path: fileEntry.path,
|
|
601
|
-
});
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
if (archiveEntry.linkname !== fileEntry.link_target) {
|
|
606
|
-
errors.push({
|
|
607
|
-
code: "LINK_TARGET_MISMATCH",
|
|
608
|
-
message: `Symlink linkname mismatch for ${fileEntry.path}: manifest declares "${fileEntry.link_target}", tar carries "${archiveEntry.linkname}"`,
|
|
609
|
-
path: fileEntry.path,
|
|
610
|
-
});
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (archiveEntry.size !== 0) {
|
|
615
|
-
errors.push({
|
|
616
|
-
code: "FILE_SIZE_MISMATCH",
|
|
617
|
-
message: `Size mismatch for ${fileEntry.path}: manifest declares ${fileEntry.size_bytes} bytes, archive has ${archiveEntry.size} bytes`,
|
|
618
|
-
path: fileEntry.path,
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
if (fileEntry.size_bytes !== 0) {
|
|
623
|
-
errors.push({
|
|
624
|
-
code: "FILE_SIZE_MISMATCH",
|
|
625
|
-
message: `Size mismatch for ${fileEntry.path}: manifest declares ${fileEntry.size_bytes} bytes, archive has ${archiveEntry.size} bytes`,
|
|
626
|
-
path: fileEntry.path,
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
const expected = sha256Hex(fileEntry.link_target);
|
|
631
|
-
if (expected !== fileEntry.sha256) {
|
|
632
|
-
errors.push({
|
|
633
|
-
code: "FILE_CHECKSUM_MISMATCH",
|
|
634
|
-
message: `Checksum mismatch for ${fileEntry.path}: expected ${fileEntry.sha256}, computed ${expected}`,
|
|
635
|
-
path: fileEntry.path,
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// Absolute POSIX targets are unconstrained by the bundle root — reject
|
|
640
|
-
// them up front. The `posix.normalize` guard below only catches
|
|
641
|
-
// `..`-based escapes; an absolute path like `/etc/passwd` survives
|
|
642
|
-
// normalization unchanged and would otherwise pass.
|
|
643
|
-
if (fileEntry.link_target.startsWith("/")) {
|
|
644
|
-
errors.push({
|
|
645
|
-
code: "SYMLINK_TARGET_ESCAPES_ARCHIVE",
|
|
646
|
-
message: `Symlink target is absolute, which escapes the archive root for ${fileEntry.path}: target=${fileEntry.link_target}`,
|
|
647
|
-
path: fileEntry.path,
|
|
648
|
-
});
|
|
649
|
-
} else {
|
|
650
|
-
const normalized = posix.normalize(
|
|
651
|
-
posix.join(posix.dirname(fileEntry.path), fileEntry.link_target),
|
|
652
|
-
);
|
|
653
|
-
// Defense-in-depth: also reject if the joined+normalized path is
|
|
654
|
-
// absolute, in case `dirname` ever resolves to an absolute root.
|
|
655
|
-
if (
|
|
656
|
-
normalized.startsWith("../") ||
|
|
657
|
-
normalized === ".." ||
|
|
658
|
-
normalized.startsWith("/")
|
|
659
|
-
) {
|
|
660
|
-
errors.push({
|
|
661
|
-
code: "SYMLINK_TARGET_ESCAPES_ARCHIVE",
|
|
662
|
-
message: `Symlink target escapes archive root for ${fileEntry.path}: target=${fileEntry.link_target}, normalized=${normalized}`,
|
|
663
|
-
path: fileEntry.path,
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
if (archiveEntry.linkname !== undefined) {
|
|
671
|
-
// Tar carries a typeflag-2 entry but manifest declares a regular file.
|
|
672
|
-
errors.push({
|
|
673
|
-
code: "SYMLINK_NOT_DECLARED",
|
|
674
|
-
message: `Tar entry ${fileEntry.path} is typeflag-2 but manifest does not declare link_target`,
|
|
675
|
-
path: fileEntry.path,
|
|
676
|
-
});
|
|
677
|
-
continue;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Verify size
|
|
681
|
-
if (archiveEntry.size !== fileEntry.size_bytes) {
|
|
682
|
-
errors.push({
|
|
683
|
-
code: "FILE_SIZE_MISMATCH",
|
|
684
|
-
message: `Size mismatch for ${fileEntry.path}: manifest declares ${fileEntry.size_bytes} bytes, archive has ${archiveEntry.size} bytes`,
|
|
685
|
-
path: fileEntry.path,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Verify SHA-256
|
|
690
|
-
const computedSha256 = sha256Hex(archiveEntry.data);
|
|
691
|
-
if (computedSha256 !== fileEntry.sha256) {
|
|
692
|
-
errors.push({
|
|
693
|
-
code: "FILE_CHECKSUM_MISMATCH",
|
|
694
|
-
message: `Checksum mismatch for ${fileEntry.path}: expected ${fileEntry.sha256}, computed ${computedSha256}`,
|
|
695
|
-
path: fileEntry.path,
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Step 7: Ensure every required entry (except manifest.json itself) has a
|
|
701
|
-
// checksum in the manifest — presence in the archive alone is not enough.
|
|
702
|
-
for (const required of REQUIRED_ENTRIES) {
|
|
703
|
-
if (required === "manifest.json") {
|
|
704
|
-
continue;
|
|
705
|
-
}
|
|
706
|
-
if (!entryMap.has(required)) {
|
|
707
|
-
continue;
|
|
708
|
-
}
|
|
709
|
-
if (!manifestFilePaths.has(required)) {
|
|
710
|
-
errors.push({
|
|
711
|
-
code: "REQUIRED_FILE_NOT_IN_MANIFEST",
|
|
712
|
-
message: `Required file ${required} exists in archive but has no checksum entry in manifest.contents`,
|
|
713
|
-
path: required,
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
return {
|
|
719
|
-
is_valid: errors.length === 0,
|
|
720
|
-
errors,
|
|
721
|
-
manifest: errors.length === 0 ? manifest : undefined,
|
|
722
|
-
entries: errors.length === 0 ? entryMap : undefined,
|
|
723
|
-
};
|
|
724
|
-
}
|