@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,473 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the v1-tightened `validateVBundle` schema.
|
|
3
|
-
*
|
|
4
|
-
* Covers:
|
|
5
|
-
* - Accepts a v1 ten-field manifest produced by `buildVBundle`.
|
|
6
|
-
* - Rejects every legacy six-field manifest shape.
|
|
7
|
-
* - Rejects when `schema_version` is anything other than `1`.
|
|
8
|
-
* - Enforces the cloud-mode redaction `.refine()` (`origin.mode === "managed" ⇒ secrets_redacted === true`).
|
|
9
|
-
* - Enforces the `data/db/assistant.db`-in-contents `.refine()`.
|
|
10
|
-
* - Rejects when the declared `checksum` doesn't match the recomputed self-checksum.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { createHash } from "node:crypto";
|
|
14
|
-
import { gzipSync } from "node:zlib";
|
|
15
|
-
import { describe, expect, test } from "bun:test";
|
|
16
|
-
|
|
17
|
-
import { buildVBundle } from "../vbundle-builder.js";
|
|
18
|
-
import {
|
|
19
|
-
canonicalizeJson,
|
|
20
|
-
computeLegacyManifestSha256,
|
|
21
|
-
computeManifestChecksum,
|
|
22
|
-
MANIFEST_MAX_BYTES,
|
|
23
|
-
MANIFEST_MAX_ENTRIES,
|
|
24
|
-
ManifestSchema,
|
|
25
|
-
validateVBundle,
|
|
26
|
-
} from "../vbundle-validator.js";
|
|
27
|
-
import { buildTestManifest, defaultV1Options } from "./v1-test-helpers.js";
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Tar helpers — minimum-viable to wrap a manifest object into a vbundle.
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
const BLOCK = 512;
|
|
34
|
-
|
|
35
|
-
function padToBlock(data: Uint8Array): Uint8Array {
|
|
36
|
-
const r = data.length % BLOCK;
|
|
37
|
-
if (r === 0) {
|
|
38
|
-
return data;
|
|
39
|
-
}
|
|
40
|
-
const out = new Uint8Array(data.length + (BLOCK - r));
|
|
41
|
-
out.set(data);
|
|
42
|
-
return out;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function writeOctal(
|
|
46
|
-
buf: Uint8Array,
|
|
47
|
-
offset: number,
|
|
48
|
-
length: number,
|
|
49
|
-
value: number,
|
|
50
|
-
): void {
|
|
51
|
-
const s = value.toString(8).padStart(length - 1, "0");
|
|
52
|
-
for (let i = 0; i < s.length; i++) {
|
|
53
|
-
buf[offset + i] = s.charCodeAt(i);
|
|
54
|
-
}
|
|
55
|
-
buf[offset + length - 1] = 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function tarEntry(name: string, data: Uint8Array): Uint8Array {
|
|
59
|
-
const enc = new TextEncoder();
|
|
60
|
-
const header = new Uint8Array(BLOCK);
|
|
61
|
-
header.set(enc.encode(name).subarray(0, 100), 0);
|
|
62
|
-
writeOctal(header, 100, 8, 0o644);
|
|
63
|
-
writeOctal(header, 108, 8, 0);
|
|
64
|
-
writeOctal(header, 116, 8, 0);
|
|
65
|
-
writeOctal(header, 124, 12, data.length);
|
|
66
|
-
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
|
|
67
|
-
header[156] = "0".charCodeAt(0);
|
|
68
|
-
header.set(enc.encode("ustar\0"), 257);
|
|
69
|
-
header[263] = "0".charCodeAt(0);
|
|
70
|
-
header[264] = "0".charCodeAt(0);
|
|
71
|
-
let sum = 0;
|
|
72
|
-
for (let i = 0; i < BLOCK; i++) {
|
|
73
|
-
sum += i >= 148 && i < 156 ? 0x20 : header[i];
|
|
74
|
-
}
|
|
75
|
-
writeOctal(header, 148, 7, sum);
|
|
76
|
-
header[155] = 0x20;
|
|
77
|
-
const padded = padToBlock(data);
|
|
78
|
-
const out = new Uint8Array(header.length + padded.length);
|
|
79
|
-
out.set(header, 0);
|
|
80
|
-
out.set(padded, header.length);
|
|
81
|
-
return out;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function tarArchive(
|
|
85
|
-
entries: Array<{ name: string; data: Uint8Array }>,
|
|
86
|
-
): Uint8Array {
|
|
87
|
-
const parts: Uint8Array[] = entries.map((e) => tarEntry(e.name, e.data));
|
|
88
|
-
parts.push(new Uint8Array(BLOCK * 2));
|
|
89
|
-
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
90
|
-
const out = new Uint8Array(total);
|
|
91
|
-
let off = 0;
|
|
92
|
-
for (const p of parts) {
|
|
93
|
-
out.set(p, off);
|
|
94
|
-
off += p.length;
|
|
95
|
-
}
|
|
96
|
-
return out;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function gzipTarOf(
|
|
100
|
-
manifestObj: Record<string, unknown>,
|
|
101
|
-
files: Array<{ name: string; data: Uint8Array }> = [],
|
|
102
|
-
): Uint8Array {
|
|
103
|
-
const manifestData = new TextEncoder().encode(JSON.stringify(manifestObj));
|
|
104
|
-
return gzipSync(
|
|
105
|
-
tarArchive([{ name: "manifest.json", data: manifestData }, ...files]),
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function sha256Hex(data: Uint8Array | string): string {
|
|
110
|
-
return createHash("sha256").update(data).digest("hex");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const DB_BYTES = new TextEncoder().encode("db-bytes");
|
|
114
|
-
|
|
115
|
-
function v1Skeleton(): Record<string, unknown> {
|
|
116
|
-
return {
|
|
117
|
-
schema_version: 1,
|
|
118
|
-
bundle_id: "00000000-0000-4000-8000-000000000000",
|
|
119
|
-
created_at: "2026-04-01T00:00:00Z",
|
|
120
|
-
assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
|
|
121
|
-
origin: { mode: "self-hosted-local" },
|
|
122
|
-
compatibility: {
|
|
123
|
-
min_runtime_version: "0.0.0-test",
|
|
124
|
-
max_runtime_version: null,
|
|
125
|
-
},
|
|
126
|
-
contents: [
|
|
127
|
-
{
|
|
128
|
-
path: "data/db/assistant.db",
|
|
129
|
-
sha256: sha256Hex(DB_BYTES),
|
|
130
|
-
size_bytes: DB_BYTES.length,
|
|
131
|
-
},
|
|
132
|
-
],
|
|
133
|
-
checksum: "",
|
|
134
|
-
secrets_redacted: false,
|
|
135
|
-
export_options: {
|
|
136
|
-
include_logs: false,
|
|
137
|
-
include_browser_state: false,
|
|
138
|
-
include_memory_vectors: false,
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function withChecksum(
|
|
144
|
-
manifest: Record<string, unknown>,
|
|
145
|
-
): Record<string, unknown> {
|
|
146
|
-
const checksum = computeManifestChecksum(manifest);
|
|
147
|
-
return { ...manifest, checksum };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
// Tests
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
describe("ManifestSchema — v1 acceptance", () => {
|
|
155
|
-
test("accepts a freshly-built v1 manifest from buildVBundle", () => {
|
|
156
|
-
const { archive } = buildVBundle({
|
|
157
|
-
files: [{ path: "data/db/assistant.db", data: DB_BYTES }],
|
|
158
|
-
...defaultV1Options(),
|
|
159
|
-
});
|
|
160
|
-
const result = validateVBundle(archive);
|
|
161
|
-
expect(result.is_valid).toBe(true);
|
|
162
|
-
expect(result.manifest?.schema_version).toBe(1);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("accepts when origin.mode === 'managed' and secrets_redacted === true", () => {
|
|
166
|
-
const manifest = withChecksum({
|
|
167
|
-
...v1Skeleton(),
|
|
168
|
-
origin: { mode: "managed" },
|
|
169
|
-
secrets_redacted: true,
|
|
170
|
-
});
|
|
171
|
-
const archive = gzipTarOf(manifest, [
|
|
172
|
-
{ name: "data/db/assistant.db", data: DB_BYTES },
|
|
173
|
-
]);
|
|
174
|
-
const result = validateVBundle(archive);
|
|
175
|
-
expect(result.is_valid).toBe(true);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe("validateVBundle: streaming manifest limits", () => {
|
|
180
|
-
test("rejects a manifest over MANIFEST_MAX_BYTES with MANIFEST_TOO_LARGE", () => {
|
|
181
|
-
const oversized = new Uint8Array(MANIFEST_MAX_BYTES + 1).fill(0x20);
|
|
182
|
-
const archive = gzipSync(
|
|
183
|
-
tarArchive([{ name: "manifest.json", data: oversized }]),
|
|
184
|
-
);
|
|
185
|
-
const result = validateVBundle(archive, { enforceStreamingLimits: true });
|
|
186
|
-
expect(result.is_valid).toBe(false);
|
|
187
|
-
expect(result.errors.map((e) => e.code)).toEqual(["MANIFEST_TOO_LARGE"]);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("rejects a manifest declaring more than MANIFEST_MAX_ENTRIES with MANIFEST_TOO_MANY_ENTRIES", () => {
|
|
191
|
-
const skeleton = v1Skeleton();
|
|
192
|
-
const dbEntry = (skeleton.contents as Array<Record<string, unknown>>)[0]!;
|
|
193
|
-
const extra = Array.from({ length: MANIFEST_MAX_ENTRIES }, (_, i) => ({
|
|
194
|
-
...dbEntry,
|
|
195
|
-
path: `workspace/${i}`,
|
|
196
|
-
}));
|
|
197
|
-
const archive = gzipTarOf(
|
|
198
|
-
withChecksum({ ...skeleton, contents: [dbEntry, ...extra] }),
|
|
199
|
-
);
|
|
200
|
-
const result = validateVBundle(archive, { enforceStreamingLimits: true });
|
|
201
|
-
expect(result.is_valid).toBe(false);
|
|
202
|
-
expect(result.errors.map((e) => e.code)).toEqual([
|
|
203
|
-
"MANIFEST_TOO_MANY_ENTRIES",
|
|
204
|
-
]);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("does not apply the limits unless enforceStreamingLimits is set", () => {
|
|
208
|
-
const skeleton = v1Skeleton();
|
|
209
|
-
const dbEntry = (skeleton.contents as Array<Record<string, unknown>>)[0]!;
|
|
210
|
-
const extra = Array.from({ length: MANIFEST_MAX_ENTRIES }, (_, i) => ({
|
|
211
|
-
...dbEntry,
|
|
212
|
-
path: `workspace/${i}`,
|
|
213
|
-
}));
|
|
214
|
-
const archive = gzipTarOf(
|
|
215
|
-
withChecksum({ ...skeleton, contents: [dbEntry, ...extra] }),
|
|
216
|
-
[{ name: "data/db/assistant.db", data: DB_BYTES }],
|
|
217
|
-
);
|
|
218
|
-
const result = validateVBundle(archive);
|
|
219
|
-
expect(result.errors.map((e) => e.code)).not.toContain(
|
|
220
|
-
"MANIFEST_TOO_MANY_ENTRIES",
|
|
221
|
-
);
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
describe("ManifestSchema — v1 rejection", () => {
|
|
226
|
-
test("rejects schema_version !== 1", () => {
|
|
227
|
-
const manifest = withChecksum({ ...v1Skeleton(), schema_version: 2 });
|
|
228
|
-
const archive = gzipTarOf(manifest, [
|
|
229
|
-
{ name: "data/db/assistant.db", data: DB_BYTES },
|
|
230
|
-
]);
|
|
231
|
-
const result = validateVBundle(archive);
|
|
232
|
-
expect(result.is_valid).toBe(false);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test("rejects managed mode with secrets_redacted=false", () => {
|
|
236
|
-
const manifest = withChecksum({
|
|
237
|
-
...v1Skeleton(),
|
|
238
|
-
origin: { mode: "managed" },
|
|
239
|
-
secrets_redacted: false,
|
|
240
|
-
});
|
|
241
|
-
const archive = gzipTarOf(manifest, [
|
|
242
|
-
{ name: "data/db/assistant.db", data: DB_BYTES },
|
|
243
|
-
]);
|
|
244
|
-
const result = validateVBundle(archive);
|
|
245
|
-
expect(result.is_valid).toBe(false);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test("rejects manifest whose contents lacks data/db/assistant.db", () => {
|
|
249
|
-
const manifest = withChecksum({
|
|
250
|
-
...v1Skeleton(),
|
|
251
|
-
contents: [
|
|
252
|
-
{
|
|
253
|
-
path: "config/settings.json",
|
|
254
|
-
sha256: sha256Hex(new TextEncoder().encode("{}")),
|
|
255
|
-
size_bytes: 2,
|
|
256
|
-
},
|
|
257
|
-
],
|
|
258
|
-
});
|
|
259
|
-
const archive = gzipTarOf(manifest);
|
|
260
|
-
const result = validateVBundle(archive);
|
|
261
|
-
expect(result.is_valid).toBe(false);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test("rejects a checksum that does not match the recomputed self-checksum", () => {
|
|
265
|
-
// Build a structurally valid manifest but tamper with `checksum` after
|
|
266
|
-
// computing it so the validator's recomputation fails.
|
|
267
|
-
const valid = withChecksum(v1Skeleton());
|
|
268
|
-
const tampered = {
|
|
269
|
-
...valid,
|
|
270
|
-
// Twiddle one byte; still 64 hex chars so the schema accepts it,
|
|
271
|
-
// but the recomputation rejects it.
|
|
272
|
-
checksum:
|
|
273
|
-
(valid.checksum as string).slice(0, -1) +
|
|
274
|
-
((valid.checksum as string).slice(-1) === "0" ? "1" : "0"),
|
|
275
|
-
};
|
|
276
|
-
const archive = gzipTarOf(tampered, [
|
|
277
|
-
{ name: "data/db/assistant.db", data: DB_BYTES },
|
|
278
|
-
]);
|
|
279
|
-
const result = validateVBundle(archive);
|
|
280
|
-
expect(result.is_valid).toBe(false);
|
|
281
|
-
expect(
|
|
282
|
-
result.errors.some((e) => e.code === "MANIFEST_CHECKSUM_MISMATCH"),
|
|
283
|
-
).toBe(true);
|
|
284
|
-
});
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
// Legacy six-field manifest fallback — required for backwards compatibility
|
|
289
|
-
// with on-disk bundles produced by pre-v1 runtimes (backup snapshots,
|
|
290
|
-
// cross-version migration artifacts). AGENTS.md prohibits silent breaks of
|
|
291
|
-
// persisted state.
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
293
|
-
|
|
294
|
-
function legacySkeleton(
|
|
295
|
-
files: Array<{ path: string; sha256: string; size: number }>,
|
|
296
|
-
): Record<string, unknown> {
|
|
297
|
-
return {
|
|
298
|
-
schema_version: "1.0",
|
|
299
|
-
created_at: new Date().toISOString(),
|
|
300
|
-
source: "runtime-export",
|
|
301
|
-
description: "legacy fixture",
|
|
302
|
-
files,
|
|
303
|
-
manifest_sha256: "",
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function withLegacyChecksum(
|
|
308
|
-
manifest: Record<string, unknown>,
|
|
309
|
-
): Record<string, unknown> {
|
|
310
|
-
// `computeLegacyManifestSha256` strips the field internally before
|
|
311
|
-
// canonicalizing, so passing the manifest in unmodified is correct.
|
|
312
|
-
const manifest_sha256 = computeLegacyManifestSha256(manifest);
|
|
313
|
-
return { ...manifest, manifest_sha256 };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
describe("ManifestSchema — legacy fallback (backwards compatibility)", () => {
|
|
317
|
-
test("accepts a valid legacy six-field manifest", () => {
|
|
318
|
-
const legacy = withLegacyChecksum(
|
|
319
|
-
legacySkeleton([
|
|
320
|
-
{
|
|
321
|
-
path: "data/db/assistant.db",
|
|
322
|
-
sha256: sha256Hex(DB_BYTES),
|
|
323
|
-
size: DB_BYTES.length,
|
|
324
|
-
},
|
|
325
|
-
]),
|
|
326
|
-
);
|
|
327
|
-
const archive = gzipTarOf(legacy, [
|
|
328
|
-
{ name: "data/db/assistant.db", data: DB_BYTES },
|
|
329
|
-
]);
|
|
330
|
-
const result = validateVBundle(archive);
|
|
331
|
-
expect(result.is_valid).toBe(true);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
test("rejects a legacy manifest with a wrong manifest_sha256", () => {
|
|
335
|
-
const legacy = {
|
|
336
|
-
...legacySkeleton([
|
|
337
|
-
{
|
|
338
|
-
path: "data/db/assistant.db",
|
|
339
|
-
sha256: sha256Hex(DB_BYTES),
|
|
340
|
-
size: DB_BYTES.length,
|
|
341
|
-
},
|
|
342
|
-
]),
|
|
343
|
-
// Deliberately wrong checksum.
|
|
344
|
-
manifest_sha256:
|
|
345
|
-
"0000000000000000000000000000000000000000000000000000000000000000",
|
|
346
|
-
};
|
|
347
|
-
const archive = gzipTarOf(legacy, [
|
|
348
|
-
{ name: "data/db/assistant.db", data: DB_BYTES },
|
|
349
|
-
]);
|
|
350
|
-
const result = validateVBundle(archive);
|
|
351
|
-
expect(result.is_valid).toBe(false);
|
|
352
|
-
expect(
|
|
353
|
-
result.errors.some((e) => e.code === "MANIFEST_CHECKSUM_MISMATCH"),
|
|
354
|
-
).toBe(true);
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
test("translator surfaces a v1 ManifestType to downstream consumers", () => {
|
|
358
|
-
const legacy = withLegacyChecksum(
|
|
359
|
-
legacySkeleton([
|
|
360
|
-
{
|
|
361
|
-
path: "data/db/assistant.db",
|
|
362
|
-
sha256: sha256Hex(DB_BYTES),
|
|
363
|
-
size: DB_BYTES.length,
|
|
364
|
-
},
|
|
365
|
-
]),
|
|
366
|
-
);
|
|
367
|
-
const archive = gzipTarOf(legacy, [
|
|
368
|
-
{ name: "data/db/assistant.db", data: DB_BYTES },
|
|
369
|
-
]);
|
|
370
|
-
const result = validateVBundle(archive);
|
|
371
|
-
expect(result.is_valid).toBe(true);
|
|
372
|
-
expect(result.manifest).toBeDefined();
|
|
373
|
-
// Translation fills in v1 fields and renames legacy fields.
|
|
374
|
-
expect(result.manifest?.schema_version).toBe(1);
|
|
375
|
-
expect(result.manifest?.contents).toHaveLength(1);
|
|
376
|
-
expect(result.manifest?.contents[0]?.path).toBe("data/db/assistant.db");
|
|
377
|
-
expect(result.manifest?.contents[0]?.size_bytes).toBe(DB_BYTES.length);
|
|
378
|
-
expect(result.manifest?.contents[0]?.sha256).toBe(sha256Hex(DB_BYTES));
|
|
379
|
-
// Conservative defaults so the v1 refines never trip.
|
|
380
|
-
expect(result.manifest?.origin.mode).toBe("self-hosted-local");
|
|
381
|
-
expect(result.manifest?.secrets_redacted).toBe(false);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("legacy manifest with corrupted file payload still flags FILE_CHECKSUM_MISMATCH", () => {
|
|
385
|
-
// Verifies the per-file integrity pipeline runs on translated legacy
|
|
386
|
-
// bundles too — translation must preserve `files` → `contents` /
|
|
387
|
-
// `size` → `size_bytes` so verification works uniformly.
|
|
388
|
-
const legacy = withLegacyChecksum(
|
|
389
|
-
legacySkeleton([
|
|
390
|
-
{
|
|
391
|
-
path: "data/db/assistant.db",
|
|
392
|
-
sha256: sha256Hex(DB_BYTES),
|
|
393
|
-
size: DB_BYTES.length,
|
|
394
|
-
},
|
|
395
|
-
]),
|
|
396
|
-
);
|
|
397
|
-
const corruptedDb = new TextEncoder().encode("not-the-real-db-bytes");
|
|
398
|
-
const archive = gzipTarOf(legacy, [
|
|
399
|
-
{ name: "data/db/assistant.db", data: corruptedDb },
|
|
400
|
-
]);
|
|
401
|
-
const result = validateVBundle(archive);
|
|
402
|
-
expect(result.is_valid).toBe(false);
|
|
403
|
-
expect(result.errors.some((e) => e.code === "FILE_CHECKSUM_MISMATCH")).toBe(
|
|
404
|
-
true,
|
|
405
|
-
);
|
|
406
|
-
});
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
describe("ManifestFileEntry — link_target field", () => {
|
|
410
|
-
test("accepts manifest entry with link_target", () => {
|
|
411
|
-
const manifest = buildTestManifest({
|
|
412
|
-
contents: [
|
|
413
|
-
{
|
|
414
|
-
path: "workspace/data/db/assistant.db",
|
|
415
|
-
sha256: sha256Hex(DB_BYTES),
|
|
416
|
-
size_bytes: 1,
|
|
417
|
-
link_target: "bar.md",
|
|
418
|
-
},
|
|
419
|
-
],
|
|
420
|
-
});
|
|
421
|
-
const result = ManifestSchema.safeParse(manifest);
|
|
422
|
-
expect(result.success).toBe(true);
|
|
423
|
-
if (result.success) {
|
|
424
|
-
expect(result.data.contents[0]?.link_target).toBe("bar.md");
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
test("accepts manifest entry without link_target (backwards-compat)", () => {
|
|
429
|
-
const manifest = buildTestManifest({
|
|
430
|
-
contents: [
|
|
431
|
-
{
|
|
432
|
-
path: "workspace/data/db/assistant.db",
|
|
433
|
-
sha256: sha256Hex(DB_BYTES),
|
|
434
|
-
size_bytes: 1,
|
|
435
|
-
},
|
|
436
|
-
],
|
|
437
|
-
});
|
|
438
|
-
const result = ManifestSchema.safeParse(manifest);
|
|
439
|
-
expect(result.success).toBe(true);
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
test("rejects manifest entry with empty link_target", () => {
|
|
443
|
-
const manifest = buildTestManifest({
|
|
444
|
-
contents: [
|
|
445
|
-
{
|
|
446
|
-
path: "workspace/data/db/assistant.db",
|
|
447
|
-
sha256: sha256Hex(DB_BYTES),
|
|
448
|
-
size_bytes: 1,
|
|
449
|
-
link_target: "",
|
|
450
|
-
},
|
|
451
|
-
],
|
|
452
|
-
});
|
|
453
|
-
const result = ManifestSchema.safeParse(manifest);
|
|
454
|
-
expect(result.success).toBe(false);
|
|
455
|
-
});
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
// Exercise canonicalizeJson + computeManifestChecksum themselves to keep the
|
|
459
|
-
// helpers from rotting if signatures change.
|
|
460
|
-
describe("computeManifestChecksum helper", () => {
|
|
461
|
-
test("ignores the existing checksum value when computing the canonical form", () => {
|
|
462
|
-
const a = withChecksum(v1Skeleton());
|
|
463
|
-
const b = withChecksum({ ...v1Skeleton(), checksum: "deadbeef" });
|
|
464
|
-
// Both inputs canonicalize to the same form (checksum field replaced by
|
|
465
|
-
// empty string), so both produce the same checksum.
|
|
466
|
-
expect(a.checksum).toBe(b.checksum);
|
|
467
|
-
// Sanity: the same canonicalization explanation as in the validator.
|
|
468
|
-
const expected = sha256Hex(
|
|
469
|
-
canonicalizeJson({ ...v1Skeleton(), checksum: "" }),
|
|
470
|
-
);
|
|
471
|
-
expect(a.checksum).toBe(expected);
|
|
472
|
-
});
|
|
473
|
-
});
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validator for Google Cloud Storage signed URLs.
|
|
3
|
-
*
|
|
4
|
-
* Used by the migrations import path to confirm that a URL the daemon is
|
|
5
|
-
* about to `fetch()` is, in fact, a signed GCS object URL (and not an
|
|
6
|
-
* attacker-chosen host, scheme, or path).
|
|
7
|
-
*
|
|
8
|
-
* This is a pure function — no I/O, no side effects. The caller decides
|
|
9
|
-
* what to do with the validation result.
|
|
10
|
-
*
|
|
11
|
-
* What we check:
|
|
12
|
-
* - The string parses as a URL.
|
|
13
|
-
* - The scheme is `https:` (no `http:`, `file:`, `data:`, etc.).
|
|
14
|
-
* - The hostname is exactly `storage.googleapis.com`.
|
|
15
|
-
* - No explicit port is present (the default HTTPS port is required;
|
|
16
|
-
* WHATWG URL normalizes `:443` to an empty port string for HTTPS).
|
|
17
|
-
* - The URL carries a signature query param — `X-Goog-Signature` (GCS
|
|
18
|
-
* V4) or `Signature` (GCS V2); for hosts on a non-default allowlist
|
|
19
|
-
* (local/minikube SeaweedFS) SigV4's `X-Amz-Signature` is also
|
|
20
|
-
* accepted. A URL with no signature param is not a presigned URL and
|
|
21
|
-
* is refused.
|
|
22
|
-
* - The pathname does not contain `..` segments (traversal guard).
|
|
23
|
-
*
|
|
24
|
-
* On success we return the hostname and pathname for logging/telemetry.
|
|
25
|
-
* We deliberately do NOT return the full URL or the query string,
|
|
26
|
-
* because the signature is sensitive and should not end up in logs.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
export type GcsUrlValidation =
|
|
30
|
-
| { ok: true; host: string; path: string }
|
|
31
|
-
| { ok: false; reason: string };
|
|
32
|
-
|
|
33
|
-
const EXPECTED_HOST = "storage.googleapis.com";
|
|
34
|
-
const DEFAULT_ALLOWED_HOSTS: readonly string[] = [EXPECTED_HOST];
|
|
35
|
-
|
|
36
|
-
export interface ValidateGcsSignedUrlOptions {
|
|
37
|
-
/**
|
|
38
|
-
* Allowlisted hosts. Defaults to `["storage.googleapis.com"]` — the only
|
|
39
|
-
* production value. A non-default list (local/minikube via
|
|
40
|
-
* `CADET_MIGRATION_IMPORT_ALLOWED_HOSTS`, or tests pointing at a local
|
|
41
|
-
* HTTP fixture) relaxes the `scheme` check to accept `http:`, skips the
|
|
42
|
-
* explicit-port check, and skips the signature-param requirement for the
|
|
43
|
-
* listed hosts. Production code MUST NOT pass a wider list.
|
|
44
|
-
*/
|
|
45
|
-
allowedHosts?: readonly string[];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function validateGcsSignedUrl(
|
|
49
|
-
raw: string,
|
|
50
|
-
options?: ValidateGcsSignedUrlOptions,
|
|
51
|
-
): GcsUrlValidation {
|
|
52
|
-
let parsed: URL;
|
|
53
|
-
try {
|
|
54
|
-
parsed = new URL(raw);
|
|
55
|
-
} catch {
|
|
56
|
-
return { ok: false, reason: "invalid_url" };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const allowedHosts = options?.allowedHosts ?? DEFAULT_ALLOWED_HOSTS;
|
|
60
|
-
const isDefaultHostList =
|
|
61
|
-
allowedHosts.length === 1 && allowedHosts[0] === EXPECTED_HOST;
|
|
62
|
-
|
|
63
|
-
// When the allowlist is the production default (GCS only), require HTTPS.
|
|
64
|
-
// When a caller passes a non-default allowlist (tests pointing at a local
|
|
65
|
-
// HTTP server), allow http too — same-process tests cannot reasonably
|
|
66
|
-
// stand up HTTPS. We still refuse non-http(s) schemes (file:, data:, etc).
|
|
67
|
-
if (isDefaultHostList) {
|
|
68
|
-
if (parsed.protocol !== "https:") {
|
|
69
|
-
return { ok: false, reason: "scheme" };
|
|
70
|
-
}
|
|
71
|
-
} else {
|
|
72
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
73
|
-
return { ok: false, reason: "scheme" };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!allowedHosts.includes(parsed.hostname)) {
|
|
78
|
-
return { ok: false, reason: "host" };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Reject any explicit port. GCS signed URLs use the default HTTPS
|
|
82
|
-
// port (443), and WHATWG URL normalizes `:443` to an empty port
|
|
83
|
-
// string for HTTPS — so a correctly-issued signed URL always has
|
|
84
|
-
// `parsed.port === ""`. A non-empty port indicates an attacker is
|
|
85
|
-
// trying to redirect the fetch to a non-default port (SSRF vector).
|
|
86
|
-
//
|
|
87
|
-
// For the non-default (test) allowlist, an explicit port is expected
|
|
88
|
-
// (local servers bind to arbitrary ephemeral ports), so the check is
|
|
89
|
-
// skipped in that case.
|
|
90
|
-
if (isDefaultHostList && parsed.port !== "") {
|
|
91
|
-
return { ok: false, reason: "port" };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Every URL must carry a signature param — possession of a complete
|
|
95
|
-
// presigned URL is the capability, and this shape check keeps unsigned
|
|
96
|
-
// object paths from being fetched/PUT even on allowlisted hosts. GCS
|
|
97
|
-
// signs with X-Goog-Signature (V4) or Signature (V2); S3-compatible
|
|
98
|
-
// backends behind a non-default allowlist (local/minikube SeaweedFS)
|
|
99
|
-
// sign with X-Amz-Signature (SigV4), which is only accepted for
|
|
100
|
-
// explicitly allowlisted hosts. The storage backend itself verifies the
|
|
101
|
-
// signature; this validator only checks the shape.
|
|
102
|
-
const hasV4 = parsed.searchParams.has("X-Goog-Signature");
|
|
103
|
-
const hasV2 = parsed.searchParams.has("Signature");
|
|
104
|
-
const hasSigV4 =
|
|
105
|
-
!isDefaultHostList && parsed.searchParams.has("X-Amz-Signature");
|
|
106
|
-
if (!hasV4 && !hasV2 && !hasSigV4) {
|
|
107
|
-
return { ok: false, reason: "missing_signature" };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Defense-in-depth: reject any `..` path segment. The WHATWG URL
|
|
111
|
-
// parser silently normalizes `/bucket/../foo` to `/foo`, which would
|
|
112
|
-
// hide a traversal attempt — so inspect the *raw* input before
|
|
113
|
-
// normalization in addition to the parsed pathname.
|
|
114
|
-
if (hasTraversalSegment(parsed.pathname) || hasTraversalInRawPath(raw)) {
|
|
115
|
-
return { ok: false, reason: "path_traversal" };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return { ok: true, host: parsed.hostname, path: parsed.pathname };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function hasTraversalSegment(pathname: string): boolean {
|
|
122
|
-
for (const segment of pathname.split("/")) {
|
|
123
|
-
if (segment === "..") {
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Look for `..` path segments in the raw input before URL normalization.
|
|
132
|
-
* We slice off the scheme+authority and stop at the first `?` or `#`,
|
|
133
|
-
* then examine each segment split on both `/` and `\` (the WHATWG URL
|
|
134
|
-
* parser treats `\` as equivalent to `/` for special schemes like
|
|
135
|
-
* `https:`, so a backslash-delimited traversal would be normalized
|
|
136
|
-
* away by `new URL()`).
|
|
137
|
-
*
|
|
138
|
-
* Inside each segment we also handle percent-encoded separators
|
|
139
|
-
* (`%2F` = `/`, `%5C` = `\`): we pre-normalize those before the
|
|
140
|
-
* top-level split, and we also re-split each decoded segment on `/`
|
|
141
|
-
* and `\` to catch cases like `%2e%2e%2fother` where the encoded
|
|
142
|
-
* slash hides a traversal within a single raw segment.
|
|
143
|
-
*/
|
|
144
|
-
function hasTraversalInRawPath(raw: string): boolean {
|
|
145
|
-
const schemeEnd = raw.indexOf("://");
|
|
146
|
-
if (schemeEnd === -1) {
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
const afterScheme = raw.slice(schemeEnd + 3);
|
|
150
|
-
const pathStart = afterScheme.search(/[\/\\]/);
|
|
151
|
-
if (pathStart === -1) {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
let path = afterScheme.slice(pathStart);
|
|
155
|
-
const queryIdx = path.indexOf("?");
|
|
156
|
-
if (queryIdx !== -1) {
|
|
157
|
-
path = path.slice(0, queryIdx);
|
|
158
|
-
}
|
|
159
|
-
const hashIdx = path.indexOf("#");
|
|
160
|
-
if (hashIdx !== -1) {
|
|
161
|
-
path = path.slice(0, hashIdx);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Normalize percent-encoded forms of `/` and `\` so that encoded
|
|
165
|
-
// separators participate in the segment split.
|
|
166
|
-
const normalized = path.replace(/%2[fF]/g, "/").replace(/%5[cC]/g, "\\");
|
|
167
|
-
|
|
168
|
-
for (const segment of normalized.split(/[\/\\]/)) {
|
|
169
|
-
if (segment === "..") {
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
|
-
// Percent-decoded forms of ".." — e.g. "%2E%2E", ".%2e", "%2e.".
|
|
173
|
-
// Also re-split on `/` and `\` to catch encoded separators that
|
|
174
|
-
// weren't covered by the top-level normalization (e.g. a decoded
|
|
175
|
-
// segment `../other`).
|
|
176
|
-
let decoded: string;
|
|
177
|
-
try {
|
|
178
|
-
decoded = decodeURIComponent(segment);
|
|
179
|
-
} catch {
|
|
180
|
-
// Ignore malformed percent-encoding; URL parser would handle it.
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
for (const sub of decoded.split(/[\/\\]/)) {
|
|
184
|
-
if (sub === "..") {
|
|
185
|
-
return true;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return false;
|
|
190
|
-
}
|