@springbrand/eval-harness 0.1.0-alpha.0 → 0.1.0-alpha.2
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/README.md +68 -4
- package/dist/{index.d.ts → eval-harness/src/index.d.ts} +57 -3
- package/dist/{index.js → eval-harness/src/index.js} +97 -27
- package/dist/utils/src/attachment.d.ts +12 -0
- package/dist/utils/src/attachment.js +50 -0
- package/dist/utils/src/base64.d.ts +3 -0
- package/dist/utils/src/base64.js +27 -0
- package/dist/utils/src/content-hash.d.ts +5 -0
- package/dist/utils/src/content-hash.js +27 -0
- package/dist/utils/src/database.d.ts +1 -0
- package/dist/utils/src/database.js +9 -0
- package/dist/utils/src/html.d.ts +2 -0
- package/dist/utils/src/html.js +9 -0
- package/dist/utils/src/image.d.ts +33 -0
- package/dist/utils/src/image.js +35 -0
- package/dist/utils/src/index.d.ts +12 -0
- package/dist/utils/src/index.js +12 -0
- package/dist/utils/src/ip-address.d.ts +6 -0
- package/dist/utils/src/ip-address.js +11 -0
- package/dist/utils/src/json.d.ts +6 -0
- package/dist/utils/src/json.js +18 -0
- package/dist/utils/src/ooxml.d.ts +14 -0
- package/dist/utils/src/ooxml.js +170 -0
- package/dist/utils/src/serialized-size.d.ts +9 -0
- package/dist/utils/src/serialized-size.js +11 -0
- package/dist/utils/src/strict-zip.d.ts +68 -0
- package/dist/utils/src/strict-zip.js +177 -0
- package/dist/utils/src/uuid.d.ts +9 -0
- package/dist/utils/src/uuid.js +27 -0
- package/package.json +16 -18
package/README.md
CHANGED
|
@@ -6,6 +6,11 @@ creation and routing, Severn owns Submission and canonical Session history,
|
|
|
6
6
|
Workspace owns files, and API owns billing settlements. Eval Project, Dataset,
|
|
7
7
|
Sampling and scoring remain in `eval-platform`.
|
|
8
8
|
|
|
9
|
+
The root export also exposes `hasZipSignature`, `inspectStrictZip`,
|
|
10
|
+
`StrictZipError`, `STRICT_ZIP_OOXML_LIMITS` and their `StrictZip*` types from
|
|
11
|
+
the shared ZIP inspector. Consumers supply explicit archive limits; inspection
|
|
12
|
+
does not retain expanded bytes unless `retainBytes` is enabled.
|
|
13
|
+
|
|
9
14
|
The Eval Run Worker authenticates with a dedicated service token. Its requests
|
|
10
15
|
include the owning user ID because an SP Inbox is addressed by user. The Chat
|
|
11
16
|
creation facts and each Submission operation key are immutable. Initialization
|
|
@@ -38,11 +43,30 @@ independent Eval SP deployment; the shared SP dev API and database are not used.
|
|
|
38
43
|
|
|
39
44
|
The initialization body includes the Data sample and, only when referenced by
|
|
40
45
|
that sample, `initialSession: {bundleId, sessionId, context?: {format, content}}`
|
|
41
|
-
and/or `initialMaterials: {bundleId, materialId, files}`. `content` is canonical
|
|
46
|
+
and/or `initialMaterials: [{bundleId, materialId, files}]`. `content` is canonical
|
|
42
47
|
Session JSON or JSONL; each material file supplies its path, media type and
|
|
43
|
-
base64 bytes.
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
base64 bytes. The material list must match the sample's `initialMaterials`
|
|
49
|
+
references position by position, without duplicates. Supplied Session context is
|
|
50
|
+
imported into the Chat before the Prompt and only files are written to SpaceDO,
|
|
51
|
+
each material under its own `evalMaterialRoot`. A sample may specify either
|
|
52
|
+
source, both, or neither. There is no Dataset-level source binding. An Evaluator
|
|
53
|
+
initialization must carry `evaluationOutputSchema: {content}`, written as UTF-8
|
|
54
|
+
`application/schema+json` to `EVAL_OUTPUT_SCHEMA_PATH`
|
|
55
|
+
(`/evaluation-output.schema.json`); a Subject Chat rejects it. Evaluators do not
|
|
56
|
+
import an initial Session. Schema validity and result validation belong to Eval
|
|
57
|
+
Platform; SP preserves the supplied text. `evalMaterialFilesSchema`
|
|
58
|
+
validates one material's `EvalMaterialFile` list: at most `EVAL_MATERIAL_MAX_FILES`, with unique
|
|
59
|
+
paths. One initialization carries at most `EVAL_INITIAL_FILE_MAX_BYTES` per file
|
|
60
|
+
and `EVAL_INITIALIZATION_MAX_BYTES` in total; size violations are Zod `too_big`
|
|
61
|
+
issues. `evalMaterialFilesByteSize` returns one material's decoded bytes exactly as
|
|
62
|
+
that total counts them. `readEvalCanonicalContext` accepts at most
|
|
63
|
+
`EVAL_SESSION_CONTENT_MAX_CHARS` characters and `EVAL_SESSION_MAX_ENTRIES`
|
|
64
|
+
entries. `EVAL_INITIALIZATION_BODY_MAX_BYTES` bounds transport JSON, accounting
|
|
65
|
+
for base64 material bytes, escaped Session or Schema text, and JSON framing.
|
|
66
|
+
Eval consumes these constants instead of copying the values. Its result is
|
|
67
|
+
`evalInitializationResultSchema`: `{manifestHash, target, materials:
|
|
68
|
+
[{bundleId, materialId, rootPath}], evaluationOutputSchemaPath?}` in request
|
|
69
|
+
order; replaying the same request returns the stored result.
|
|
46
70
|
|
|
47
71
|
An Evaluator is an ordinary Eval Chat with its own Session, Submission and
|
|
48
72
|
Space; its creation facts add only `evaluationId`. It never reads or writes the
|
|
@@ -61,6 +85,46 @@ hash, writes `/evidence/<path>` and returns `{path, size, sha256}`. Only an
|
|
|
61
85
|
Evaluator imports; an identical repeat is a no-op, different content is a 409,
|
|
62
86
|
and imports stop with the Chat's stop latch.
|
|
63
87
|
|
|
88
|
+
The Evaluator Workspace layout is one public contract, exported by this package:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
/initial-materials/{bundleId}/{materialId}/... # Evaluator's own materials
|
|
92
|
+
/evidence/subject-inputs/{bundleId}/{materialId}/... # Subject's original input materials
|
|
93
|
+
/evidence/artifacts/{original-relative-path} # Subject's produced files
|
|
94
|
+
/evidence/trajectory/events-000001.jsonl # Complete raw trajectory, ordered parts
|
|
95
|
+
/evidence/manifest.json # Evidence bundle inventory and provenance
|
|
96
|
+
/evaluation-output.schema.json # Evaluation output format
|
|
97
|
+
/output/result.json # Evaluator's scored result
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`EVAL_EVIDENCE_PATHS` names the bundle-relative directories. The import route takes
|
|
101
|
+
paths **without** the `/evidence` prefix: `/subject-inputs/...`, `/artifacts/...`,
|
|
102
|
+
`/trajectory/...` or `/manifest.json`. Other paths are rejected. The exported
|
|
103
|
+
`evalSubjectInputEvidencePath`, `evalArtifactEvidencePath` and
|
|
104
|
+
`evalTrajectoryEvidencePath` build those import paths; `evalEvidencePath` turns
|
|
105
|
+
an import path into its absolute Workspace path. IDs are encoded as individual
|
|
106
|
+
path segments and original file hierarchy is preserved, so equal basenames in
|
|
107
|
+
different materials or evidence categories cannot overwrite each other.
|
|
108
|
+
|
|
109
|
+
Eval Platform copies Subject inputs from the original immutable Material bundles
|
|
110
|
+
referenced by this Sampling, not from the Subject's potentially edited working
|
|
111
|
+
files. SP continues to exclude these inputs from artifact exports. Eval Platform
|
|
112
|
+
collects **every** trajectory page until `nextAfterSeq` is null, retaining raw
|
|
113
|
+
`{ingestSeq,event}` records in order; it splits oversized JSONL at event boundaries
|
|
114
|
+
so every part fits `EVAL_EVIDENCE_FILE_MAX_BYTES`. Eval Platform owns the frozen
|
|
115
|
+
bundle, manifest body, file hashes and preparation retries. It imports all files,
|
|
116
|
+
verifies their receipts and uploads `manifest.json` last, then submits its Prompt
|
|
117
|
+
with explicit evidence, Schema and result paths.
|
|
118
|
+
|
|
119
|
+
SP requires completed initialization before evidence import, and a nonempty
|
|
120
|
+
evidence manifest before an Evaluator's first Submission. Input writes and
|
|
121
|
+
Submission admission are serialized; the first reserved Submission durably closes
|
|
122
|
+
evidence imports, including after a DO restart. Replaying initialization returns
|
|
123
|
+
its saved result without restoring files. Stop closes admission immediately and
|
|
124
|
+
waits for accepted input writes and submissions. Evidence, initial materials and
|
|
125
|
+
the fixed Schema input path are never exported as produced artifacts. SP does not
|
|
126
|
+
interpret the manifest or implement Eval Platform's bundle assembly or scoring.
|
|
127
|
+
|
|
64
128
|
Eval Platform writes the Evaluator instructions into the Prompt; SP submits it
|
|
65
129
|
unchanged. The Evaluator's `output` adds `structuredOutput: {path, content,
|
|
66
130
|
contentHash}` with the raw UTF-8 text of `/output/result.json` in its own Space
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
export { hasZipSignature, inspectStrictZip, StrictZipError, STRICT_ZIP_OOXML_LIMITS, type StrictZipEntry, type StrictZipEntryLimit, type StrictZipErrorCode, type StrictZipInspectOptions, type StrictZipLimits, type StrictZipResult, } from "../../utils/src/index.js";
|
|
2
3
|
export declare const EVAL_SERVICE_HEADER = "X-Eval-Service-Token";
|
|
3
4
|
export declare const EVAL_HARNESS_PATH = "/internal/v1/eval-harness";
|
|
4
5
|
export declare const evalEnsureChatSchema: z.ZodObject<{
|
|
5
6
|
chatId: z.ZodUUID;
|
|
6
7
|
role: z.ZodEnum<{
|
|
7
|
-
evaluator: "evaluator";
|
|
8
8
|
subject: "subject";
|
|
9
|
+
evaluator: "evaluator";
|
|
9
10
|
}>;
|
|
10
11
|
framework: z.ZodLiteral<"sp">;
|
|
11
12
|
samplingId: z.ZodString;
|
|
@@ -19,11 +20,29 @@ export declare const evalEnsureChatSchema: z.ZodObject<{
|
|
|
19
20
|
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
20
21
|
}, z.core.$strict>>;
|
|
21
22
|
}, z.core.$strict>;
|
|
22
|
-
export declare const
|
|
23
|
+
export declare const EVAL_INITIAL_FILE_MAX_BYTES = 5000000;
|
|
24
|
+
export declare const EVAL_INITIALIZATION_MAX_BYTES = 20000000;
|
|
25
|
+
export declare const EVAL_MATERIAL_MAX_FILES = 500;
|
|
26
|
+
export declare const EVAL_SESSION_CONTENT_MAX_CHARS = 2000000;
|
|
27
|
+
export declare const EVAL_SESSION_MAX_ENTRIES = 10000;
|
|
28
|
+
export declare const EVAL_OUTPUT_SCHEMA_PATH = "/evaluation-output.schema.json";
|
|
29
|
+
export declare const EVAL_INITIAL_MATERIALS_ROOT = "/initial-materials";
|
|
30
|
+
/** Subject Session JSON and Evaluator Schema JSON can expand to six bytes per character. */
|
|
31
|
+
export declare const EVAL_INITIALIZATION_BODY_MAX_BYTES: number;
|
|
32
|
+
/** Decoded bytes of validated Material files, counted exactly as the initialization budget counts them. */
|
|
33
|
+
export declare const evalMaterialFilesByteSize: (files: readonly {
|
|
34
|
+
readonly contentBase64: string;
|
|
35
|
+
}[]) => number;
|
|
36
|
+
declare const evalMaterialFileSchema: z.ZodObject<{
|
|
23
37
|
path: z.ZodString;
|
|
24
38
|
mediaType: z.ZodString;
|
|
25
39
|
contentBase64: z.ZodBase64;
|
|
26
40
|
}, z.core.$strict>;
|
|
41
|
+
export declare const evalMaterialFilesSchema: z.ZodArray<z.ZodObject<{
|
|
42
|
+
path: z.ZodString;
|
|
43
|
+
mediaType: z.ZodString;
|
|
44
|
+
contentBase64: z.ZodBase64;
|
|
45
|
+
}, z.core.$strict>>;
|
|
27
46
|
export declare const evalInitialContextSchema: z.ZodObject<{
|
|
28
47
|
format: z.ZodEnum<{
|
|
29
48
|
json: "json";
|
|
@@ -47,7 +66,7 @@ export declare const evalInitializationSchema: z.ZodObject<{
|
|
|
47
66
|
content: z.ZodString;
|
|
48
67
|
}, z.core.$strict>>;
|
|
49
68
|
}, z.core.$strict>>;
|
|
50
|
-
initialMaterials: z.ZodOptional<z.ZodObject<{
|
|
69
|
+
initialMaterials: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
51
70
|
bundleId: z.ZodString;
|
|
52
71
|
materialId: z.ZodString;
|
|
53
72
|
files: z.ZodArray<z.ZodObject<{
|
|
@@ -55,8 +74,21 @@ export declare const evalInitializationSchema: z.ZodObject<{
|
|
|
55
74
|
mediaType: z.ZodString;
|
|
56
75
|
contentBase64: z.ZodBase64;
|
|
57
76
|
}, z.core.$strict>>;
|
|
77
|
+
}, z.core.$strict>>>;
|
|
78
|
+
evaluationOutputSchema: z.ZodOptional<z.ZodObject<{
|
|
79
|
+
content: z.ZodString;
|
|
58
80
|
}, z.core.$strict>>;
|
|
59
81
|
}, z.core.$strict>;
|
|
82
|
+
export declare const evalInitializationResultSchema: z.ZodObject<{
|
|
83
|
+
manifestHash: z.ZodString;
|
|
84
|
+
target: z.ZodLiteral<"workspace">;
|
|
85
|
+
materials: z.ZodArray<z.ZodObject<{
|
|
86
|
+
bundleId: z.ZodString;
|
|
87
|
+
materialId: z.ZodString;
|
|
88
|
+
rootPath: z.ZodString;
|
|
89
|
+
}, z.core.$strict>>;
|
|
90
|
+
evaluationOutputSchemaPath: z.ZodExactOptional<z.ZodLiteral<"/evaluation-output.schema.json">>;
|
|
91
|
+
}, z.core.$strict>;
|
|
60
92
|
export declare const evalSubmitSchema: z.ZodObject<{
|
|
61
93
|
idempotencyKey: z.ZodString;
|
|
62
94
|
prompt: z.ZodString;
|
|
@@ -66,7 +98,9 @@ export declare const evalSubmitSchema: z.ZodObject<{
|
|
|
66
98
|
}, z.core.$strict>>;
|
|
67
99
|
}, z.core.$strict>;
|
|
68
100
|
export type EvalEnsureChat = z.output<typeof evalEnsureChatSchema>;
|
|
101
|
+
export type EvalMaterialFile = z.output<typeof evalMaterialFileSchema>;
|
|
69
102
|
export type EvalInitialization = z.output<typeof evalInitializationSchema>;
|
|
103
|
+
export type EvalInitializationResult = z.output<typeof evalInitializationResultSchema>;
|
|
70
104
|
export type EvalSubmit = z.output<typeof evalSubmitSchema>;
|
|
71
105
|
export type EvalSubmissionReceipt = {
|
|
72
106
|
readonly submissionId: string;
|
|
@@ -135,6 +169,13 @@ export declare function evalMaterialRoot(bundleId: string, materialId: string):
|
|
|
135
169
|
export declare function evalMaterialPath(root: string, path: string): string;
|
|
136
170
|
/** Where imported evidence lives in the Evaluator's own Space. */
|
|
137
171
|
export declare const EVAL_EVIDENCE_ROOT = "/evidence";
|
|
172
|
+
/** Bundle-relative paths, used by the import route; evalEvidencePath returns their Workspace paths. */
|
|
173
|
+
export declare const EVAL_EVIDENCE_PATHS: {
|
|
174
|
+
readonly subjectInputs: "/subject-inputs";
|
|
175
|
+
readonly artifacts: "/artifacts";
|
|
176
|
+
readonly trajectory: "/trajectory";
|
|
177
|
+
readonly manifest: "/manifest.json";
|
|
178
|
+
};
|
|
138
179
|
/** One exported or imported file crosses a single Durable Object RPC as bytes. */
|
|
139
180
|
export declare const EVAL_EVIDENCE_FILE_MAX_BYTES = 25000000;
|
|
140
181
|
export declare const EVAL_EVIDENCE_TRAJECTORY_PAGE_MAX = 200;
|
|
@@ -169,6 +210,19 @@ export declare function readEvalTrajectoryPageQuery(params: URLSearchParams): {
|
|
|
169
210
|
};
|
|
170
211
|
/** A Space path for one evidence file; `relative` keeps the bundle's own layout below `/evidence`. */
|
|
171
212
|
export declare function evalEvidencePath(relative: string): string;
|
|
213
|
+
/** Original Subject inputs stay separate from produced artifacts and the Evaluator's own materials. */
|
|
214
|
+
export declare function evalSubjectInputEvidencePath(bundleId: string, materialId: string, path: string): string;
|
|
215
|
+
export declare function evalArtifactEvidencePath(path: string): string;
|
|
216
|
+
/** Positive part numbers preserve the complete raw JSONL trajectory's order. */
|
|
217
|
+
export declare function evalTrajectoryEvidencePath(part: number): string;
|
|
218
|
+
/** One raw evidence file imported into the Evaluator's own Space. */
|
|
219
|
+
export type EvalEvidenceFileInput = {
|
|
220
|
+
/** Bundle-relative path under one of EVAL_EVIDENCE_PATHS, without the /evidence prefix. */
|
|
221
|
+
readonly path: string;
|
|
222
|
+
readonly mediaType: string;
|
|
223
|
+
readonly sha256: string;
|
|
224
|
+
readonly bytes: Uint8Array;
|
|
225
|
+
};
|
|
172
226
|
/** The receipt for one imported file; identical repeats return the same receipt. */
|
|
173
227
|
export type EvalEvidenceFileReceipt = {
|
|
174
228
|
readonly path: string;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { canonicalJsonStringify, sha256Hex } from "../../utils/src/index.js";
|
|
3
|
+
export { hasZipSignature, inspectStrictZip, StrictZipError, STRICT_ZIP_OOXML_LIMITS, } from "../../utils/src/index.js";
|
|
2
4
|
export const EVAL_SERVICE_HEADER = "X-Eval-Service-Token";
|
|
3
5
|
export const EVAL_HARNESS_PATH = "/internal/v1/eval-harness";
|
|
4
6
|
const id = z.string().trim().min(1);
|
|
@@ -18,10 +20,41 @@ export const evalEnsureChatSchema = z.strictObject({
|
|
|
18
20
|
context.addIssue({ code: "custom", path: ["evaluationId"], message: "Evaluation ID must match role" });
|
|
19
21
|
}
|
|
20
22
|
});
|
|
21
|
-
export const
|
|
22
|
-
|
|
23
|
+
export const EVAL_INITIAL_FILE_MAX_BYTES = 5_000_000;
|
|
24
|
+
export const EVAL_INITIALIZATION_MAX_BYTES = 20_000_000;
|
|
25
|
+
export const EVAL_MATERIAL_MAX_FILES = 500;
|
|
26
|
+
export const EVAL_SESSION_CONTENT_MAX_CHARS = 2_000_000;
|
|
27
|
+
export const EVAL_SESSION_MAX_ENTRIES = 10_000;
|
|
28
|
+
export const EVAL_OUTPUT_SCHEMA_PATH = "/evaluation-output.schema.json";
|
|
29
|
+
export const EVAL_INITIAL_MATERIALS_ROOT = "/initial-materials";
|
|
30
|
+
/** Subject Session JSON and Evaluator Schema JSON can expand to six bytes per character. */
|
|
31
|
+
export const EVAL_INITIALIZATION_BODY_MAX_BYTES = Math.max(Math.ceil(EVAL_INITIALIZATION_MAX_BYTES * 4 / 3) + EVAL_SESSION_CONTENT_MAX_CHARS * 6, Math.ceil((EVAL_INITIALIZATION_MAX_BYTES - EVAL_INITIAL_FILE_MAX_BYTES) * 4 / 3) + EVAL_INITIAL_FILE_MAX_BYTES * 6) + 1_000_000;
|
|
32
|
+
const base64Bytes = (value) => value.length / 4 * 3 - (value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0);
|
|
33
|
+
const utf8Bytes = (value) => new TextEncoder().encode(value).byteLength;
|
|
34
|
+
/** Decoded bytes of validated Material files, counted exactly as the initialization budget counts them. */
|
|
35
|
+
export const evalMaterialFilesByteSize = (files) => files.reduce((sum, file) => sum + base64Bytes(file.contentBase64), 0);
|
|
36
|
+
const tooBig = (context, maximum, message, path = []) => context.addIssue({ code: "too_big", origin: "file", maximum, inclusive: true, path, message });
|
|
37
|
+
const maxFileBytes = (bytes) => (value, context) => {
|
|
38
|
+
if (bytes(value) > EVAL_INITIAL_FILE_MAX_BYTES)
|
|
39
|
+
tooBig(context, EVAL_INITIAL_FILE_MAX_BYTES, "File is too large");
|
|
40
|
+
};
|
|
41
|
+
const evalMaterialFileSchema = z.strictObject({
|
|
42
|
+
path: z.string().refine((path) => evalPathParts(path) !== null, "Invalid initial material path"),
|
|
23
43
|
mediaType: id,
|
|
24
|
-
contentBase64: z.base64(),
|
|
44
|
+
contentBase64: z.base64().superRefine(maxFileBytes(base64Bytes)),
|
|
45
|
+
});
|
|
46
|
+
export const evalMaterialFilesSchema = z.array(evalMaterialFileSchema).max(EVAL_MATERIAL_MAX_FILES).superRefine((files, context) => {
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
files.forEach(({ path }, index) => {
|
|
49
|
+
if (seen.has(path))
|
|
50
|
+
context.addIssue({ code: "custom", path: [index, "path"], message: "Duplicate material path" });
|
|
51
|
+
seen.add(path);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
const evalInitialMaterialSchema = z.strictObject({
|
|
55
|
+
bundleId: id,
|
|
56
|
+
materialId: id,
|
|
57
|
+
files: evalMaterialFilesSchema,
|
|
25
58
|
});
|
|
26
59
|
export const evalInitialContextSchema = z.strictObject({
|
|
27
60
|
format: z.enum(["json", "jsonl"]),
|
|
@@ -35,22 +68,42 @@ export const evalInitializationSchema = z.strictObject({
|
|
|
35
68
|
sessionId: id,
|
|
36
69
|
context: evalInitialContextSchema.optional(),
|
|
37
70
|
}).optional(),
|
|
38
|
-
initialMaterials: z.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
files: z.array(evalMaterialFileSchema).max(500),
|
|
71
|
+
initialMaterials: z.array(evalInitialMaterialSchema).min(1).optional(),
|
|
72
|
+
evaluationOutputSchema: z.strictObject({
|
|
73
|
+
content: z.string().min(1).superRefine(maxFileBytes(utf8Bytes)),
|
|
42
74
|
}).optional(),
|
|
43
75
|
}).superRefine((value, context) => {
|
|
44
76
|
const session = value.data.initialSession;
|
|
45
|
-
const
|
|
77
|
+
const refs = value.data.initialMaterials;
|
|
78
|
+
const materials = value.initialMaterials;
|
|
46
79
|
if (session ? !value.initialSession || session.bundleId !== value.initialSession.bundleId ||
|
|
47
80
|
session.sessionId !== value.initialSession.sessionId : value.initialSession !== undefined) {
|
|
48
81
|
context.addIssue({ code: "custom", path: ["initialSession"], message: "Session content must match the Data reference" });
|
|
49
82
|
}
|
|
50
|
-
if (
|
|
51
|
-
materials.
|
|
83
|
+
if (refs === undefined ? materials !== undefined : !Array.isArray(refs) || refs.length !== materials?.length ||
|
|
84
|
+
materials.some((material, index) => {
|
|
85
|
+
const ref = refs[index];
|
|
86
|
+
return ref?.bundleId !== material.bundleId || ref?.materialId !== material.materialId;
|
|
87
|
+
})) {
|
|
52
88
|
context.addIssue({ code: "custom", path: ["initialMaterials"], message: "Material content must match the Data reference" });
|
|
53
89
|
}
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
materials?.forEach(({ bundleId, materialId }, index) => {
|
|
92
|
+
const key = JSON.stringify([bundleId, materialId]);
|
|
93
|
+
if (seen.has(key))
|
|
94
|
+
context.addIssue({ code: "custom", path: ["initialMaterials", index], message: "Duplicate material" });
|
|
95
|
+
seen.add(key);
|
|
96
|
+
});
|
|
97
|
+
const total = (materials ?? []).reduce((sum, material) => sum + evalMaterialFilesByteSize(material.files), value.evaluationOutputSchema ? utf8Bytes(value.evaluationOutputSchema.content) : 0);
|
|
98
|
+
if (total > EVAL_INITIALIZATION_MAX_BYTES) {
|
|
99
|
+
tooBig(context, EVAL_INITIALIZATION_MAX_BYTES, "Initialization files are too large");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
export const evalInitializationResultSchema = z.strictObject({
|
|
103
|
+
manifestHash: id,
|
|
104
|
+
target: z.literal("workspace"),
|
|
105
|
+
materials: z.array(z.strictObject({ bundleId: id, materialId: id, rootPath: z.string().startsWith("/") })),
|
|
106
|
+
evaluationOutputSchemaPath: z.literal(EVAL_OUTPUT_SCHEMA_PATH).exactOptional(),
|
|
54
107
|
});
|
|
55
108
|
export const evalSubmitSchema = z.strictObject({
|
|
56
109
|
idempotencyKey: id,
|
|
@@ -77,7 +130,7 @@ export function projectEvalSubmission(input) {
|
|
|
77
130
|
}
|
|
78
131
|
/** JSON stores one canonical snapshot; JSONL stores one canonical entry per line. */
|
|
79
132
|
export function readEvalCanonicalContext(input) {
|
|
80
|
-
if (input.content.length >
|
|
133
|
+
if (input.content.length > EVAL_SESSION_CONTENT_MAX_CHARS)
|
|
81
134
|
throw new Error("Initial context is too large");
|
|
82
135
|
const parsed = input.format === "json"
|
|
83
136
|
? JSON.parse(input.content)
|
|
@@ -86,7 +139,7 @@ export function readEvalCanonicalContext(input) {
|
|
|
86
139
|
throw new Error("Initial context must contain canonical entries");
|
|
87
140
|
}
|
|
88
141
|
const entries = parsed.entries;
|
|
89
|
-
if (entries.length >
|
|
142
|
+
if (entries.length > EVAL_SESSION_MAX_ENTRIES)
|
|
90
143
|
throw new Error("Initial context has too many entries");
|
|
91
144
|
const seen = new Set();
|
|
92
145
|
for (const entry of entries) {
|
|
@@ -121,12 +174,14 @@ export async function readEvalStructuredOutput(path, bytes) {
|
|
|
121
174
|
catch {
|
|
122
175
|
throw new Error(`${EVAL_RESULT_FILE} is not valid UTF-8`);
|
|
123
176
|
}
|
|
124
|
-
const
|
|
125
|
-
const contentHash = `sha256:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
177
|
+
const contentHash = `sha256:${await sha256Hex(bytes)}`;
|
|
126
178
|
return { path, content, contentHash };
|
|
127
179
|
}
|
|
128
180
|
export function evalMaterialRoot(bundleId, materialId) {
|
|
129
|
-
return
|
|
181
|
+
return `${EVAL_INITIAL_MATERIALS_ROOT}/${evalPathSegment(bundleId)}/${evalPathSegment(materialId)}`;
|
|
182
|
+
}
|
|
183
|
+
function evalPathSegment(value) {
|
|
184
|
+
return encodeURIComponent(value).replaceAll(".", "%2E");
|
|
130
185
|
}
|
|
131
186
|
/** Absolute paths below a root; `.`/`..`, `.git`, backslashes and NUL never reach the Space. */
|
|
132
187
|
function evalPathParts(path) {
|
|
@@ -148,6 +203,13 @@ export function evalMaterialPath(root, path) {
|
|
|
148
203
|
*/
|
|
149
204
|
/** Where imported evidence lives in the Evaluator's own Space. */
|
|
150
205
|
export const EVAL_EVIDENCE_ROOT = "/evidence";
|
|
206
|
+
/** Bundle-relative paths, used by the import route; evalEvidencePath returns their Workspace paths. */
|
|
207
|
+
export const EVAL_EVIDENCE_PATHS = {
|
|
208
|
+
subjectInputs: "/subject-inputs",
|
|
209
|
+
artifacts: "/artifacts",
|
|
210
|
+
trajectory: "/trajectory",
|
|
211
|
+
manifest: "/manifest.json",
|
|
212
|
+
};
|
|
151
213
|
/** One exported or imported file crosses a single Durable Object RPC as bytes. */
|
|
152
214
|
export const EVAL_EVIDENCE_FILE_MAX_BYTES = 25_000_000;
|
|
153
215
|
export const EVAL_EVIDENCE_TRAJECTORY_PAGE_MAX = 200;
|
|
@@ -173,24 +235,32 @@ export function readEvalTrajectoryPageQuery(params) {
|
|
|
173
235
|
/** A Space path for one evidence file; `relative` keeps the bundle's own layout below `/evidence`. */
|
|
174
236
|
export function evalEvidencePath(relative) {
|
|
175
237
|
const parts = evalPathParts(relative);
|
|
176
|
-
if (!parts
|
|
238
|
+
if (!parts || (relative !== EVAL_EVIDENCE_PATHS.manifest &&
|
|
239
|
+
![EVAL_EVIDENCE_PATHS.subjectInputs, EVAL_EVIDENCE_PATHS.artifacts, EVAL_EVIDENCE_PATHS.trajectory]
|
|
240
|
+
.some((root) => relative.startsWith(`${root}/`))))
|
|
177
241
|
throw new Error("Invalid evidence path");
|
|
178
242
|
return `${EVAL_EVIDENCE_ROOT}/${parts.join("/")}`;
|
|
179
243
|
}
|
|
244
|
+
/** Original Subject inputs stay separate from produced artifacts and the Evaluator's own materials. */
|
|
245
|
+
export function evalSubjectInputEvidencePath(bundleId, materialId, path) {
|
|
246
|
+
return evalMaterialPath(`${EVAL_EVIDENCE_PATHS.subjectInputs}/${evalPathSegment(bundleId)}/${evalPathSegment(materialId)}`, path);
|
|
247
|
+
}
|
|
248
|
+
export function evalArtifactEvidencePath(path) {
|
|
249
|
+
return evalMaterialPath(EVAL_EVIDENCE_PATHS.artifacts, path);
|
|
250
|
+
}
|
|
251
|
+
/** Positive part numbers preserve the complete raw JSONL trajectory's order. */
|
|
252
|
+
export function evalTrajectoryEvidencePath(part) {
|
|
253
|
+
if (!Number.isSafeInteger(part) || part < 1)
|
|
254
|
+
throw new Error("Invalid trajectory part");
|
|
255
|
+
return `${EVAL_EVIDENCE_PATHS.trajectory}/events-${String(part).padStart(6, "0")}.jsonl`;
|
|
256
|
+
}
|
|
180
257
|
export function isEvalSha256(value) {
|
|
181
258
|
return value !== null && /^[0-9a-f]{64}$/u.test(value);
|
|
182
259
|
}
|
|
183
260
|
/** Compare creation and operation facts without depending on JSON property order. */
|
|
184
261
|
export function stableEvalFacts(value) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return value.map(sortFacts);
|
|
190
|
-
if (!value || typeof value !== "object")
|
|
191
|
-
return value;
|
|
192
|
-
return Object.fromEntries(Object.entries(value)
|
|
193
|
-
.filter(([, item]) => item !== undefined)
|
|
194
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
195
|
-
.map(([key, item]) => [key, sortFacts(item)]));
|
|
262
|
+
const facts = canonicalJsonStringify(value);
|
|
263
|
+
if (facts === undefined)
|
|
264
|
+
throw new TypeError("Eval facts must be JSON");
|
|
265
|
+
return facts;
|
|
196
266
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const ATTACHMENT_MAX_COUNT = 8;
|
|
2
|
+
export declare const ATTACHMENT_MAX_BYTES: number;
|
|
3
|
+
export declare const ATTACHMENT_MAX_TOTAL_BYTES: number;
|
|
4
|
+
export declare const ATTACHMENT_ID_PATTERN: string;
|
|
5
|
+
export type AttachmentCdnUrl = string;
|
|
6
|
+
export declare function isAttachmentId(value: unknown): value is string;
|
|
7
|
+
export declare function attachmentCdnUrl(publicOrigin: string, attachmentId: string): AttachmentCdnUrl;
|
|
8
|
+
export declare function parseAttachmentCdnUrl(value: unknown): {
|
|
9
|
+
attachmentId: string;
|
|
10
|
+
} | null;
|
|
11
|
+
export declare function isAttachmentCdnUrl(value: unknown): value is AttachmentCdnUrl;
|
|
12
|
+
export declare function isStrictUtf8(bytes: Uint8Array): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const ATTACHMENT_MAX_COUNT = 8;
|
|
2
|
+
export const ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
|
|
3
|
+
export const ATTACHMENT_MAX_TOTAL_BYTES = 80 * 1024 * 1024;
|
|
4
|
+
const ATTACHMENT_ID_BODY = String.raw `\d{2}-(?:0[1-9]|1[0-2])-[A-Za-z0-9_-]{16}`;
|
|
5
|
+
export const ATTACHMENT_ID_PATTERN = `^${ATTACHMENT_ID_BODY}$`;
|
|
6
|
+
const ATTACHMENT_PATH = new RegExp(`^/attachments/(${ATTACHMENT_ID_BODY})$`, "u");
|
|
7
|
+
export function isAttachmentId(value) {
|
|
8
|
+
return typeof value === "string" && ATTACHMENT_PATH.test(`/attachments/${value}`);
|
|
9
|
+
}
|
|
10
|
+
export function attachmentCdnUrl(publicOrigin, attachmentId) {
|
|
11
|
+
if (!isAttachmentId(attachmentId)) {
|
|
12
|
+
throw new Error("Invalid Attachment");
|
|
13
|
+
}
|
|
14
|
+
const url = new URL(`/attachments/${attachmentId}`, publicOrigin);
|
|
15
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
16
|
+
throw new Error("Invalid Attachment");
|
|
17
|
+
}
|
|
18
|
+
return url.href;
|
|
19
|
+
}
|
|
20
|
+
export function parseAttachmentCdnUrl(value) {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
let url;
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(value);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
31
|
+
return null;
|
|
32
|
+
if (url.search || url.hash)
|
|
33
|
+
return null;
|
|
34
|
+
const match = url.pathname.match(ATTACHMENT_PATH);
|
|
35
|
+
return match
|
|
36
|
+
? { attachmentId: match[1] }
|
|
37
|
+
: null;
|
|
38
|
+
}
|
|
39
|
+
export function isAttachmentCdnUrl(value) {
|
|
40
|
+
return parseAttachmentCdnUrl(value) !== null;
|
|
41
|
+
}
|
|
42
|
+
export function isStrictUtf8(bytes) {
|
|
43
|
+
try {
|
|
44
|
+
new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const CHUNK_SIZE = 0x8000;
|
|
2
|
+
export function encodeBase64(bytes, urlSafe = false) {
|
|
3
|
+
let binary = "";
|
|
4
|
+
for (let index = 0; index < bytes.length; index += CHUNK_SIZE) {
|
|
5
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + CHUNK_SIZE));
|
|
6
|
+
}
|
|
7
|
+
const encoded = btoa(binary);
|
|
8
|
+
return urlSafe
|
|
9
|
+
? encoded.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "")
|
|
10
|
+
: encoded;
|
|
11
|
+
}
|
|
12
|
+
export function decodeBase64(value, urlSafe = false) {
|
|
13
|
+
const normalized = urlSafe
|
|
14
|
+
? value.replaceAll("-", "+").replaceAll("_", "/")
|
|
15
|
+
: value;
|
|
16
|
+
const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="));
|
|
17
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
18
|
+
}
|
|
19
|
+
export function hexToBase64(hex) {
|
|
20
|
+
if (!/^(?:[a-f\d]{2})+$/iu.test(hex))
|
|
21
|
+
throw new TypeError("Invalid hexadecimal bytes");
|
|
22
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
23
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
24
|
+
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
25
|
+
}
|
|
26
|
+
return encodeBase64(bytes);
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export async function sha256Hex(value) {
|
|
2
|
+
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
|
|
3
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
4
|
+
new Uint8Array(buffer).set(bytes);
|
|
5
|
+
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
|
6
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
7
|
+
}
|
|
8
|
+
export function hashFileSet(files) {
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const entries = [...files]
|
|
11
|
+
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
12
|
+
.map((file) => ({ path: encoder.encode(file.path), bytes: file.bytes }));
|
|
13
|
+
const framed = new Uint8Array(entries.reduce((total, entry) => total + 8 + entry.path.byteLength + entry.bytes.byteLength, 0));
|
|
14
|
+
const view = new DataView(framed.buffer);
|
|
15
|
+
let offset = 0;
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
view.setUint32(offset, entry.path.byteLength);
|
|
18
|
+
offset += 4;
|
|
19
|
+
framed.set(entry.path, offset);
|
|
20
|
+
offset += entry.path.byteLength;
|
|
21
|
+
view.setUint32(offset, entry.bytes.byteLength);
|
|
22
|
+
offset += 4;
|
|
23
|
+
framed.set(entry.bytes, offset);
|
|
24
|
+
offset += entry.bytes.byteLength;
|
|
25
|
+
}
|
|
26
|
+
return sha256Hex(framed);
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isUniqueConstraintViolation(error: unknown): boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function isUniqueConstraintViolation(error) {
|
|
2
|
+
for (let current = error; current instanceof Error; current = current.cause) {
|
|
3
|
+
if (/UNIQUE constraint failed/i.test(current.message))
|
|
4
|
+
return true;
|
|
5
|
+
if (/duplicate key value violates unique constraint/i.test(current.message))
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Escapes text for a place where markup would otherwise be read as markup. */
|
|
2
|
+
export function escapeHtml(value) {
|
|
3
|
+
return value
|
|
4
|
+
.replace(/&/gu, "&")
|
|
5
|
+
.replace(/</gu, "<")
|
|
6
|
+
.replace(/>/gu, ">")
|
|
7
|
+
.replace(/"/gu, """)
|
|
8
|
+
.replace(/'/gu, "'");
|
|
9
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a capture is blank, and what it was judged against.
|
|
3
|
+
*
|
|
4
|
+
* `bytes` and `threshold` are reported either way, because a caller that
|
|
5
|
+
* retries a blank capture has to be able to say what it actually saw.
|
|
6
|
+
*/
|
|
7
|
+
export type BlankScreenshotVerdict = {
|
|
8
|
+
readonly blank: boolean;
|
|
9
|
+
readonly bytes: number;
|
|
10
|
+
readonly threshold: number;
|
|
11
|
+
};
|
|
12
|
+
export type BlankScreenshotOptions = {
|
|
13
|
+
/** Captured area in pixels: the threshold has to scale with it. */
|
|
14
|
+
readonly pixels: number;
|
|
15
|
+
readonly bytesPerPixel?: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Decide whether an encoded screenshot is blank, from its size alone.
|
|
19
|
+
*
|
|
20
|
+
* A page that has not painted yet still screenshots successfully, so a capture
|
|
21
|
+
* cannot be trusted just because the browser returned one. What separates the
|
|
22
|
+
* two is how much an encoder can throw away: a uniform surface collapses to a
|
|
23
|
+
* couple of kilobytes while even a heading and a background colour do not.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately does not look at the bytes' entropy. That is the obvious second
|
|
26
|
+
* signal and it does not work here: a blank webp's compressed tail measures
|
|
27
|
+
* ~4.97 bits per byte, higher than pages full of content, because the entropy
|
|
28
|
+
* of a compressed stream says nothing about the image it encodes.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately does not decode the image either — the point is to run inside a
|
|
31
|
+
* Worker right after the capture, without paying to inflate the pixels.
|
|
32
|
+
*/
|
|
33
|
+
export declare function detectBlankScreenshot(bytes: Uint8Array, options: BlankScreenshotOptions): BlankScreenshotVerdict;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halfway between a blank capture and the thinnest real one, measured on
|
|
3
|
+
* Cloudflare Browser Rendering at 1280x720, webp quality 76:
|
|
4
|
+
*
|
|
5
|
+
* | page | bytes | bytes/pixel |
|
|
6
|
+
* | --------------------------- | ------ | ----------- |
|
|
7
|
+
* | about:blank, empty body | 2_182 | 0.00237 |
|
|
8
|
+
* | solid background + heading | 3_524 | 0.00382 |
|
|
9
|
+
* | gradient hero | 4_628 | 0.00502 |
|
|
10
|
+
* | page of body copy | 47_224 | 0.05124 |
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_BYTES_PER_PIXEL = 0.0031;
|
|
13
|
+
/** A capture below this is truncated or empty at any viewport. */
|
|
14
|
+
const ABSOLUTE_MIN_BYTES = 1_024;
|
|
15
|
+
/**
|
|
16
|
+
* Decide whether an encoded screenshot is blank, from its size alone.
|
|
17
|
+
*
|
|
18
|
+
* A page that has not painted yet still screenshots successfully, so a capture
|
|
19
|
+
* cannot be trusted just because the browser returned one. What separates the
|
|
20
|
+
* two is how much an encoder can throw away: a uniform surface collapses to a
|
|
21
|
+
* couple of kilobytes while even a heading and a background colour do not.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately does not look at the bytes' entropy. That is the obvious second
|
|
24
|
+
* signal and it does not work here: a blank webp's compressed tail measures
|
|
25
|
+
* ~4.97 bits per byte, higher than pages full of content, because the entropy
|
|
26
|
+
* of a compressed stream says nothing about the image it encodes.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately does not decode the image either — the point is to run inside a
|
|
29
|
+
* Worker right after the capture, without paying to inflate the pixels.
|
|
30
|
+
*/
|
|
31
|
+
export function detectBlankScreenshot(bytes, options) {
|
|
32
|
+
const perPixel = options.bytesPerPixel ?? DEFAULT_BYTES_PER_PIXEL;
|
|
33
|
+
const threshold = Math.max(ABSOLUTE_MIN_BYTES, Math.round(Math.max(0, options.pixels) * perPixel));
|
|
34
|
+
return { blank: bytes.byteLength < threshold, bytes: bytes.byteLength, threshold };
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { canonicalJsonBytes, canonicalJsonStringify, equalJson } from "./json.js";
|
|
2
|
+
export { attachmentCdnUrl, ATTACHMENT_ID_PATTERN, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_COUNT, ATTACHMENT_MAX_TOTAL_BYTES, isAttachmentCdnUrl, isAttachmentId, isStrictUtf8, parseAttachmentCdnUrl, type AttachmentCdnUrl, } from "./attachment.js";
|
|
3
|
+
export { hasZipSignature, inspectStrictZip, StrictZipError, STRICT_ZIP_OOXML_LIMITS, type StrictZipEntry, type StrictZipEntryLimit, type StrictZipErrorCode, type StrictZipInspectOptions, type StrictZipLimits, type StrictZipResult, } from "./strict-zip.js";
|
|
4
|
+
export { inspectSafeOoxml, OoxmlPackageError, resolveOoxmlRelationshipPart, safeOoxmlExternalHyperlinkUrl, type OoxmlKind, type OoxmlPackageErrorCode, } from "./ooxml.js";
|
|
5
|
+
export { decodeBase64, encodeBase64, hexToBase64 } from "./base64.js";
|
|
6
|
+
export { hashFileSet, sha256Hex } from "./content-hash.js";
|
|
7
|
+
export { escapeHtml } from "./html.js";
|
|
8
|
+
export { classifyIpAddress, type IpAddressScope } from "./ip-address.js";
|
|
9
|
+
export { isUniqueConstraintViolation } from "./database.js";
|
|
10
|
+
export { serializedSize } from "./serialized-size.js";
|
|
11
|
+
export { detectBlankScreenshot, type BlankScreenshotOptions, type BlankScreenshotVerdict, } from "./image.js";
|
|
12
|
+
export { createId, deterministicUuid, isUuid, UUID_PATTERN } from "./uuid.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { canonicalJsonBytes, canonicalJsonStringify, equalJson } from "./json.js";
|
|
2
|
+
export { attachmentCdnUrl, ATTACHMENT_ID_PATTERN, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_COUNT, ATTACHMENT_MAX_TOTAL_BYTES, isAttachmentCdnUrl, isAttachmentId, isStrictUtf8, parseAttachmentCdnUrl, } from "./attachment.js";
|
|
3
|
+
export { hasZipSignature, inspectStrictZip, StrictZipError, STRICT_ZIP_OOXML_LIMITS, } from "./strict-zip.js";
|
|
4
|
+
export { inspectSafeOoxml, OoxmlPackageError, resolveOoxmlRelationshipPart, safeOoxmlExternalHyperlinkUrl, } from "./ooxml.js";
|
|
5
|
+
export { decodeBase64, encodeBase64, hexToBase64 } from "./base64.js";
|
|
6
|
+
export { hashFileSet, sha256Hex } from "./content-hash.js";
|
|
7
|
+
export { escapeHtml } from "./html.js";
|
|
8
|
+
export { classifyIpAddress } from "./ip-address.js";
|
|
9
|
+
export { isUniqueConstraintViolation } from "./database.js";
|
|
10
|
+
export { serializedSize } from "./serialized-size.js";
|
|
11
|
+
export { detectBlankScreenshot, } from "./image.js";
|
|
12
|
+
export { createId, deterministicUuid, isUuid, UUID_PATTERN } from "./uuid.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type IpAddressScope = "public" | "non-public";
|
|
2
|
+
/**
|
|
3
|
+
* Classifies an IP literal without treating DNS names as addresses. IPv4-mapped
|
|
4
|
+
* IPv6 is normalized before classification so it cannot bypass IPv4 policy.
|
|
5
|
+
*/
|
|
6
|
+
export declare function classifyIpAddress(value: string): IpAddressScope | undefined;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import ipaddr from "ipaddr.js";
|
|
2
|
+
/**
|
|
3
|
+
* Classifies an IP literal without treating DNS names as addresses. IPv4-mapped
|
|
4
|
+
* IPv6 is normalized before classification so it cannot bypass IPv4 policy.
|
|
5
|
+
*/
|
|
6
|
+
export function classifyIpAddress(value) {
|
|
7
|
+
const literal = value.replace(/^\[|\]$/gu, "");
|
|
8
|
+
if (!ipaddr.isValid(literal))
|
|
9
|
+
return undefined;
|
|
10
|
+
return ipaddr.process(literal).range() === "unicast" ? "public" : "non-public";
|
|
11
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Serializes JSON with recursively sorted object keys while preserving array order. */
|
|
2
|
+
export declare function canonicalJsonStringify(value: unknown): string | undefined;
|
|
3
|
+
/** Encodes canonical JSON and rejects values that JSON cannot represent at the root. */
|
|
4
|
+
export declare function canonicalJsonBytes(value: unknown): Uint8Array;
|
|
5
|
+
/** JSON persistence ignores object key order and omits undefined object fields. */
|
|
6
|
+
export declare function equalJson(left: unknown, right: unknown): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Serializes JSON with recursively sorted object keys while preserving array order. */
|
|
2
|
+
export function canonicalJsonStringify(value) {
|
|
3
|
+
return JSON.stringify(value, (_key, item) => item !== null && typeof item === "object" && !Array.isArray(item)
|
|
4
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
|
5
|
+
: item);
|
|
6
|
+
}
|
|
7
|
+
/** Encodes canonical JSON and rejects values that JSON cannot represent at the root. */
|
|
8
|
+
export function canonicalJsonBytes(value) {
|
|
9
|
+
const serialized = canonicalJsonStringify(value);
|
|
10
|
+
if (serialized === undefined) {
|
|
11
|
+
throw new TypeError("Value cannot be represented as JSON");
|
|
12
|
+
}
|
|
13
|
+
return new TextEncoder().encode(serialized);
|
|
14
|
+
}
|
|
15
|
+
/** JSON persistence ignores object key order and omits undefined object fields. */
|
|
16
|
+
export function equalJson(left, right) {
|
|
17
|
+
return canonicalJsonStringify(left) === canonicalJsonStringify(right);
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type StrictZipEntry, type StrictZipResult } from "./strict-zip.js";
|
|
2
|
+
export type OoxmlKind = "docx" | "xlsx" | "pptx";
|
|
3
|
+
export type OoxmlPackageErrorCode = "unsafe-content" | "missing-part";
|
|
4
|
+
export declare class OoxmlPackageError extends Error {
|
|
5
|
+
readonly code: OoxmlPackageErrorCode;
|
|
6
|
+
constructor(message: string, code: OoxmlPackageErrorCode);
|
|
7
|
+
}
|
|
8
|
+
export declare function inspectSafeOoxml(bytes: Uint8Array, kind: OoxmlKind, options?: {
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
allowExternalHyperlinks?: boolean;
|
|
11
|
+
onPart?: (entry: Required<StrictZipEntry>) => void | Promise<void>;
|
|
12
|
+
}): Promise<StrictZipResult>;
|
|
13
|
+
export declare function safeOoxmlExternalHyperlinkUrl(target: string | null | undefined): string | null;
|
|
14
|
+
export declare function resolveOoxmlRelationshipPart(sourcePart: string, target: string): string | null;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { SaxesParser } from "saxes";
|
|
2
|
+
import { inspectStrictZip, STRICT_ZIP_OOXML_LIMITS, } from "./strict-zip.js";
|
|
3
|
+
export class OoxmlPackageError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "OoxmlPackageError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const REQUIRED_PARTS = {
|
|
12
|
+
docx: ["[Content_Types].xml", "_rels/.rels", "word/document.xml"],
|
|
13
|
+
xlsx: [
|
|
14
|
+
"[Content_Types].xml",
|
|
15
|
+
"_rels/.rels",
|
|
16
|
+
"xl/workbook.xml",
|
|
17
|
+
"xl/_rels/workbook.xml.rels",
|
|
18
|
+
],
|
|
19
|
+
pptx: [
|
|
20
|
+
"[Content_Types].xml",
|
|
21
|
+
"_rels/.rels",
|
|
22
|
+
"ppt/presentation.xml",
|
|
23
|
+
"ppt/_rels/presentation.xml.rels",
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
const UNSAFE_METADATA = /(?:macroenabled|vbaproject|activex|oleobject)/iu;
|
|
27
|
+
const XML_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
|
|
28
|
+
export async function inspectSafeOoxml(bytes, kind, options = {}) {
|
|
29
|
+
options.signal?.throwIfAborted();
|
|
30
|
+
const absoluteTargets = new Set();
|
|
31
|
+
const result = await inspectStrictZip(bytes, STRICT_ZIP_OOXML_LIMITS, undefined, {
|
|
32
|
+
onEntry: async (entry) => {
|
|
33
|
+
options.signal?.throwIfAborted();
|
|
34
|
+
validateOoxmlPart(entry, absoluteTargets, options.allowExternalHyperlinks === true);
|
|
35
|
+
await options.onPart?.(entry);
|
|
36
|
+
options.signal?.throwIfAborted();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
options.signal?.throwIfAborted();
|
|
40
|
+
const paths = new Set(result.entries.filter((entry) => !entry.directory).map((entry) => entry.path));
|
|
41
|
+
for (const target of absoluteTargets) {
|
|
42
|
+
if (!paths.has(target)) {
|
|
43
|
+
throw new OoxmlPackageError(`Absolute relationship target ${target} is not a package part`, "unsafe-content");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const path of REQUIRED_PARTS[kind]) {
|
|
47
|
+
if (!paths.has(path))
|
|
48
|
+
throw new OoxmlPackageError(`Missing required ${kind.toUpperCase()} part ${path}`, "missing-part");
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
function validateOoxmlPart({ path, bytes }, absoluteTargets, allowExternalHyperlinks) {
|
|
53
|
+
const folded = path.toLocaleLowerCase("en-US");
|
|
54
|
+
const segments = folded.split("/");
|
|
55
|
+
if (folded.includes("vbaproject") ||
|
|
56
|
+
segments.some((segment) => segment.startsWith("activex")) ||
|
|
57
|
+
segments.some((segment) => /^(?:encryptioninfo|encryptedpackage|dataspaces)(?:\.|$)/u.test(segment)) ||
|
|
58
|
+
(segments.includes("embeddings") && segments.some((segment) => segment.includes("oleobject") || segment.endsWith(".bin")))) {
|
|
59
|
+
throw new OoxmlPackageError(`Unsafe OOXML part ${path}`, "unsafe-content");
|
|
60
|
+
}
|
|
61
|
+
if (!/\.(?:xml|rels)$/iu.test(path))
|
|
62
|
+
return;
|
|
63
|
+
const parser = new SaxesParser({ xmlns: true, fragment: false });
|
|
64
|
+
parser.on("doctype", () => {
|
|
65
|
+
throw new OoxmlPackageError(`${path} contains a DOCTYPE`, "unsafe-content");
|
|
66
|
+
});
|
|
67
|
+
parser.on("processinginstruction", () => {
|
|
68
|
+
throw new OoxmlPackageError(`${path} contains a processing instruction`, "unsafe-content");
|
|
69
|
+
});
|
|
70
|
+
parser.on("opentag", (tag) => {
|
|
71
|
+
if (/\.rels$/iu.test(path)) {
|
|
72
|
+
assertSafeRelationship(tag, path, absoluteTargets, allowExternalHyperlinks);
|
|
73
|
+
}
|
|
74
|
+
if (path === "[Content_Types].xml") {
|
|
75
|
+
const contentType = attributeValue(tag, "contenttype");
|
|
76
|
+
if (contentType && UNSAFE_METADATA.test(contentType)) {
|
|
77
|
+
throw new OoxmlPackageError(`${path} contains unsafe metadata`, "unsafe-content");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
parser.write(XML_DECODER.decode(bytes)).close();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error instanceof OoxmlPackageError)
|
|
86
|
+
throw error;
|
|
87
|
+
throw new OoxmlPackageError(`${path} contains invalid or unsafe XML`, "unsafe-content");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function assertSafeRelationship(tag, path, absoluteTargets, allowExternalHyperlinks) {
|
|
91
|
+
if (tag.local.toLocaleLowerCase("en-US") !== "relationship")
|
|
92
|
+
return;
|
|
93
|
+
const target = attributeValue(tag, "target");
|
|
94
|
+
const resolved = target === undefined ? undefined : resolveOoxmlRelationshipPart(sourcePartForRelationships(path), target);
|
|
95
|
+
const relationshipType = attributeValue(tag, "type") ?? "";
|
|
96
|
+
const targetMode = attributeValue(tag, "targetmode")?.toLocaleLowerCase("en-US");
|
|
97
|
+
if (targetMode === "external" && allowExternalHyperlinks && isSafeExternalHyperlink(relationshipType, target)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (targetMode === "external" ||
|
|
101
|
+
(target !== undefined && resolved === null)) {
|
|
102
|
+
throw new OoxmlPackageError(`${path} contains an unsafe relationship target`, "unsafe-content");
|
|
103
|
+
}
|
|
104
|
+
if (target?.startsWith("/") && resolved)
|
|
105
|
+
absoluteTargets.add(resolved);
|
|
106
|
+
if (UNSAFE_METADATA.test(relationshipType)) {
|
|
107
|
+
throw new OoxmlPackageError(`${path} contains an unsafe relationship type`, "unsafe-content");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function isSafeExternalHyperlink(type, target) {
|
|
111
|
+
return /\/hyperlink$/iu.test(type) && safeOoxmlExternalHyperlinkUrl(target) !== null;
|
|
112
|
+
}
|
|
113
|
+
export function safeOoxmlExternalHyperlinkUrl(target) {
|
|
114
|
+
if (!target)
|
|
115
|
+
return null;
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(target);
|
|
118
|
+
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password
|
|
119
|
+
? url.href
|
|
120
|
+
: null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function resolveOoxmlRelationshipPart(sourcePart, target) {
|
|
127
|
+
let decoded;
|
|
128
|
+
try {
|
|
129
|
+
decoded = decodeURIComponent(target);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const path = decoded.split(/[?#]/u, 1)[0] ?? "";
|
|
135
|
+
if (!path || path.startsWith("//") || decoded.includes("\\") ||
|
|
136
|
+
(decoded.startsWith("/") && !target.startsWith("/")) ||
|
|
137
|
+
/[\u0000-\u001f\u007f]/u.test(decoded) ||
|
|
138
|
+
/^[A-Za-z][A-Za-z\d+.-]*:/u.test(decoded) ||
|
|
139
|
+
/%(?:2e|2f|5c|3a)/iu.test(target) ||
|
|
140
|
+
/%(?:2e|2f|5c|3a)/iu.test(decoded))
|
|
141
|
+
return null;
|
|
142
|
+
const absolute = path.startsWith("/");
|
|
143
|
+
const segments = (absolute ? path.slice(1) : path).split("/");
|
|
144
|
+
if (segments.some((segment) => !segment) || (absolute && segments.includes("..")))
|
|
145
|
+
return null;
|
|
146
|
+
const normalized = absolute ? [] : sourcePart.split("/").slice(0, -1);
|
|
147
|
+
for (const segment of segments) {
|
|
148
|
+
if (segment === ".")
|
|
149
|
+
continue;
|
|
150
|
+
if (segment === "..") {
|
|
151
|
+
if (normalized.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
normalized.pop();
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
normalized.push(segment);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const resolved = normalized.join("/");
|
|
160
|
+
return resolved || null;
|
|
161
|
+
}
|
|
162
|
+
function sourcePartForRelationships(path) {
|
|
163
|
+
if (path === "_rels/.rels")
|
|
164
|
+
return "";
|
|
165
|
+
const separator = path.lastIndexOf("/_rels/");
|
|
166
|
+
return separator < 0 ? "" : `${path.slice(0, separator)}/${path.slice(separator + 7, -5)}`;
|
|
167
|
+
}
|
|
168
|
+
function attributeValue(tag, name) {
|
|
169
|
+
return Object.values(tag.attributes).find((attribute) => attribute.local.toLocaleLowerCase("en-US") === name)?.value;
|
|
170
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byte cost of a value once it is JSON.
|
|
3
|
+
*
|
|
4
|
+
* Every budget that bounds what a model may receive is stated in bytes, and a
|
|
5
|
+
* string's `length` is UTF-16 code units — so measuring with `length` silently
|
|
6
|
+
* under-counts any non-ASCII payload, which is exactly the payload most likely
|
|
7
|
+
* to be near a limit.
|
|
8
|
+
*/
|
|
9
|
+
export declare function serializedSize(value: unknown): number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byte cost of a value once it is JSON.
|
|
3
|
+
*
|
|
4
|
+
* Every budget that bounds what a model may receive is stated in bytes, and a
|
|
5
|
+
* string's `length` is UTF-16 code units — so measuring with `length` silently
|
|
6
|
+
* under-counts any non-ASCII payload, which is exactly the payload most likely
|
|
7
|
+
* to be near a limit.
|
|
8
|
+
*/
|
|
9
|
+
export function serializedSize(value) {
|
|
10
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
11
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict-ZIP structural safety limits. Every field is required so each caller
|
|
3
|
+
* states its own budget explicitly; pass `Infinity` for a check the caller does
|
|
4
|
+
* not want the shared inspector to enforce.
|
|
5
|
+
*/
|
|
6
|
+
export type StrictZipLimits = {
|
|
7
|
+
/** Maximum number of entries in the archive. */
|
|
8
|
+
entries: number;
|
|
9
|
+
/** Maximum uncompressed size of a single entry. */
|
|
10
|
+
maxEntryBytes: number;
|
|
11
|
+
/** Maximum aggregate uncompressed size across all entries. */
|
|
12
|
+
maxExpandedBytes: number;
|
|
13
|
+
/** Maximum compression ratio (uncompressed / compressed) for a non-empty entry. */
|
|
14
|
+
maxCompressionRatio: number;
|
|
15
|
+
/** Maximum filename length in characters. */
|
|
16
|
+
pathCharacters: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Default limits for OOXML packages (DOCX/XLSX/PPTX). These match the bounds the
|
|
20
|
+
* existing Attachment XLSX validator enforced and the Creation admission matrix
|
|
21
|
+
* requires.
|
|
22
|
+
*/
|
|
23
|
+
export declare const STRICT_ZIP_OOXML_LIMITS: StrictZipLimits;
|
|
24
|
+
export type StrictZipErrorCode = "not-zip" | "invalid-archive" | "too-many-entries" | "path-too-long" | "unsafe-path" | "duplicate-path" | "case-collision" | "non-regular" | "encrypted" | "unsupported-compression" | "invalid-size" | "entry-too-large" | "aggregate-too-large" | "compression-ratio" | "invalid-entry" | "size-mismatch";
|
|
25
|
+
export declare class StrictZipError extends Error {
|
|
26
|
+
readonly code: StrictZipErrorCode;
|
|
27
|
+
readonly path?: string | undefined;
|
|
28
|
+
constructor(message: string, code: StrictZipErrorCode, path?: string | undefined);
|
|
29
|
+
}
|
|
30
|
+
/** One inspected archive entry with its safe logical path and optional expanded bytes. */
|
|
31
|
+
export type StrictZipEntry = {
|
|
32
|
+
path: string;
|
|
33
|
+
bytes?: Uint8Array;
|
|
34
|
+
directory: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** Bounded package facts returned to a caller for further policy validation. */
|
|
37
|
+
export type StrictZipResult = {
|
|
38
|
+
/** All entries sorted by path. Directory entries carry an empty byte array. */
|
|
39
|
+
entries: StrictZipEntry[];
|
|
40
|
+
compressedBytes: number;
|
|
41
|
+
expandedBytes: number;
|
|
42
|
+
};
|
|
43
|
+
/** Optional per-path ceiling, so a caller can state its own budget for a known path. */
|
|
44
|
+
export type StrictZipEntryLimit = (path: string) => {
|
|
45
|
+
maxBytes: number;
|
|
46
|
+
message: string;
|
|
47
|
+
} | undefined;
|
|
48
|
+
export type StrictZipInspectOptions = {
|
|
49
|
+
/** Consume one validated file while its expanded bytes are live. */
|
|
50
|
+
onEntry?: (entry: Required<StrictZipEntry>) => void | Promise<void>;
|
|
51
|
+
/** Retain every expanded file in the returned result. Defaults to false. */
|
|
52
|
+
retainBytes?: boolean;
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
};
|
|
55
|
+
export declare function hasZipSignature(bytes: Uint8Array): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Inspects an untrusted ZIP archive and returns bounded package facts. Every
|
|
58
|
+
* structural bound that protects a caller from a hostile archive — path shape,
|
|
59
|
+
* entry count, regular-file type, encryption, compression method, per-entry and
|
|
60
|
+
* aggregate expansion, compression ratio, overlapping entries, invalid CRCs,
|
|
61
|
+
* duplicate and case-colliding paths — is enforced here so no consumer needs to
|
|
62
|
+
* copy archive safety logic.
|
|
63
|
+
*
|
|
64
|
+
* Callers that inspect content should use `onEntry`, which releases each
|
|
65
|
+
* expanded file before the next is read. Only callers that need a complete
|
|
66
|
+
* in-memory archive should opt into `retainBytes`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function inspectStrictZip(untrustedBytes: Uint8Array, limits: StrictZipLimits, entryLimit?: StrictZipEntryLimit, options?: StrictZipInspectOptions): Promise<StrictZipResult>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const MiB = 1024 * 1024;
|
|
2
|
+
/**
|
|
3
|
+
* Default limits for OOXML packages (DOCX/XLSX/PPTX). These match the bounds the
|
|
4
|
+
* existing Attachment XLSX validator enforced and the Creation admission matrix
|
|
5
|
+
* requires.
|
|
6
|
+
*/
|
|
7
|
+
export const STRICT_ZIP_OOXML_LIMITS = {
|
|
8
|
+
entries: 2_048,
|
|
9
|
+
maxEntryBytes: 64 * MiB,
|
|
10
|
+
maxExpandedBytes: 200 * MiB,
|
|
11
|
+
maxCompressionRatio: 1_000,
|
|
12
|
+
pathCharacters: 768,
|
|
13
|
+
};
|
|
14
|
+
export class StrictZipError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
path;
|
|
17
|
+
constructor(message, code, path) {
|
|
18
|
+
super(path ? `${path}: ${message}` : message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.path = path;
|
|
21
|
+
this.name = "StrictZipError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function strictZipFail(message, code, path) {
|
|
25
|
+
throw new StrictZipError(message, code, path);
|
|
26
|
+
}
|
|
27
|
+
export function hasZipSignature(bytes) {
|
|
28
|
+
return (bytes.length >= 4 &&
|
|
29
|
+
bytes[0] === 0x50 &&
|
|
30
|
+
bytes[1] === 0x4b &&
|
|
31
|
+
((bytes[2] === 0x03 && bytes[3] === 0x04) ||
|
|
32
|
+
(bytes[2] === 0x05 && bytes[3] === 0x06) ||
|
|
33
|
+
(bytes[2] === 0x07 && bytes[3] === 0x08)));
|
|
34
|
+
}
|
|
35
|
+
function logicalPath(entry, pathCharacters) {
|
|
36
|
+
const raw = entry.filename;
|
|
37
|
+
if (raw.length > pathCharacters) {
|
|
38
|
+
strictZipFail(`Path exceeds ${pathCharacters} characters`, "path-too-long", raw);
|
|
39
|
+
}
|
|
40
|
+
if (!raw || raw.includes("\\") || raw.includes("\0") || raw.startsWith("/") || /^[A-Za-z]:/u.test(raw)) {
|
|
41
|
+
strictZipFail("Unsafe archive path", "unsafe-path", raw || "<empty path>");
|
|
42
|
+
}
|
|
43
|
+
const path = entry.directory && raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
|
44
|
+
if (!path || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
|
45
|
+
strictZipFail("Unsafe archive path", "unsafe-path", raw);
|
|
46
|
+
}
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
function assertRegularFile(entry, path, limits) {
|
|
50
|
+
const unixType = entry.unixMode === undefined ? 0 : entry.unixMode & 0o170000;
|
|
51
|
+
if (unixType !== 0 && unixType !== 0o100000) {
|
|
52
|
+
strictZipFail("Archive entry must be a regular file", "non-regular", path);
|
|
53
|
+
}
|
|
54
|
+
if (entry.encrypted)
|
|
55
|
+
strictZipFail("Encrypted archive entries are not supported", "encrypted", path);
|
|
56
|
+
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
|
|
57
|
+
strictZipFail(`Unsupported ZIP compression method ${entry.compressionMethod}`, "unsupported-compression", path);
|
|
58
|
+
}
|
|
59
|
+
if (!Number.isSafeInteger(entry.compressedSize) ||
|
|
60
|
+
!Number.isSafeInteger(entry.uncompressedSize) ||
|
|
61
|
+
entry.compressedSize < 0 ||
|
|
62
|
+
entry.uncompressedSize < 0) {
|
|
63
|
+
strictZipFail("Invalid declared size", "invalid-size", path);
|
|
64
|
+
}
|
|
65
|
+
if (entry.uncompressedSize > limits.maxEntryBytes) {
|
|
66
|
+
strictZipFail(`Entry exceeds ${limits.maxEntryBytes} bytes`, "entry-too-large", path);
|
|
67
|
+
}
|
|
68
|
+
if (entry.uncompressedSize > 0 &&
|
|
69
|
+
entry.compressedSize > 0 &&
|
|
70
|
+
entry.uncompressedSize / entry.compressedSize > limits.maxCompressionRatio) {
|
|
71
|
+
strictZipFail("Compression ratio exceeds the allowed ceiling", "compression-ratio", path);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Inspects an untrusted ZIP archive and returns bounded package facts. Every
|
|
76
|
+
* structural bound that protects a caller from a hostile archive — path shape,
|
|
77
|
+
* entry count, regular-file type, encryption, compression method, per-entry and
|
|
78
|
+
* aggregate expansion, compression ratio, overlapping entries, invalid CRCs,
|
|
79
|
+
* duplicate and case-colliding paths — is enforced here so no consumer needs to
|
|
80
|
+
* copy archive safety logic.
|
|
81
|
+
*
|
|
82
|
+
* Callers that inspect content should use `onEntry`, which releases each
|
|
83
|
+
* expanded file before the next is read. Only callers that need a complete
|
|
84
|
+
* in-memory archive should opt into `retainBytes`.
|
|
85
|
+
*/
|
|
86
|
+
export async function inspectStrictZip(untrustedBytes, limits, entryLimit, options = {}) {
|
|
87
|
+
options.signal?.throwIfAborted();
|
|
88
|
+
if (!hasZipSignature(untrustedBytes))
|
|
89
|
+
strictZipFail("Body is not a ZIP archive", "not-zip");
|
|
90
|
+
const { Uint8ArrayReader, Uint8ArrayWriter, ZipReader } = await import("@zip.js/zip.js");
|
|
91
|
+
const reader = new ZipReader(new Uint8ArrayReader(untrustedBytes), {
|
|
92
|
+
strictness: "strict",
|
|
93
|
+
useWebWorkers: false,
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = await reader.getEntries({ strictness: "strict" });
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
options.signal?.throwIfAborted();
|
|
102
|
+
strictZipFail(error instanceof Error ? `Invalid ZIP archive: ${error.message}` : "Invalid ZIP archive", "invalid-archive");
|
|
103
|
+
}
|
|
104
|
+
options.signal?.throwIfAborted();
|
|
105
|
+
if (entries.length > limits.entries) {
|
|
106
|
+
strictZipFail(`ZIP contains more than ${limits.entries} entries`, "too-many-entries");
|
|
107
|
+
}
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const seenCaseFolded = new Set();
|
|
110
|
+
const paths = new Map();
|
|
111
|
+
const directories = [];
|
|
112
|
+
let totalSize = 0;
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
options.signal?.throwIfAborted();
|
|
115
|
+
const path = logicalPath(entry, limits.pathCharacters);
|
|
116
|
+
const folded = path.toLocaleLowerCase("en-US");
|
|
117
|
+
if (seen.has(path))
|
|
118
|
+
strictZipFail("Duplicate normalized archive path", "duplicate-path", path);
|
|
119
|
+
if (seenCaseFolded.has(folded))
|
|
120
|
+
strictZipFail("Case-insensitive archive path collision", "case-collision", path);
|
|
121
|
+
seen.add(path);
|
|
122
|
+
seenCaseFolded.add(folded);
|
|
123
|
+
paths.set(entry, path);
|
|
124
|
+
if (entry.directory) {
|
|
125
|
+
directories.push(path);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
assertRegularFile(entry, path, limits);
|
|
129
|
+
totalSize += entry.uncompressedSize;
|
|
130
|
+
if (totalSize > limits.maxExpandedBytes) {
|
|
131
|
+
strictZipFail(`Total uncompressed size exceeds ${limits.maxExpandedBytes} bytes`, "aggregate-too-large");
|
|
132
|
+
}
|
|
133
|
+
const limit = entryLimit?.(path);
|
|
134
|
+
if (limit && entry.uncompressedSize > limit.maxBytes)
|
|
135
|
+
strictZipFail(limit.message, "entry-too-large", path);
|
|
136
|
+
}
|
|
137
|
+
const fileEntries = entries
|
|
138
|
+
.filter((entry) => !entry.directory)
|
|
139
|
+
.sort((left, right) => paths.get(left).localeCompare(paths.get(right)));
|
|
140
|
+
const result = [];
|
|
141
|
+
for (const entry of fileEntries) {
|
|
142
|
+
options.signal?.throwIfAborted();
|
|
143
|
+
const path = paths.get(entry);
|
|
144
|
+
let bytes;
|
|
145
|
+
try {
|
|
146
|
+
bytes = await entry.getData(new Uint8ArrayWriter(), {
|
|
147
|
+
checkSignature: true,
|
|
148
|
+
checkOverlappingEntry: true,
|
|
149
|
+
strictness: "strict",
|
|
150
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
options.signal?.throwIfAborted();
|
|
155
|
+
strictZipFail(error instanceof Error ? `Invalid ZIP entry: ${error.message}` : "Invalid ZIP entry", "invalid-entry", path);
|
|
156
|
+
}
|
|
157
|
+
options.signal?.throwIfAborted();
|
|
158
|
+
if (bytes.byteLength !== entry.uncompressedSize)
|
|
159
|
+
strictZipFail("Declared and actual sizes differ", "size-mismatch", path);
|
|
160
|
+
await options.onEntry?.({ path, bytes, directory: false });
|
|
161
|
+
options.signal?.throwIfAborted();
|
|
162
|
+
result.push({ path, ...(options.retainBytes ? { bytes } : {}), directory: false });
|
|
163
|
+
}
|
|
164
|
+
for (const directory of directories.sort((left, right) => left.localeCompare(right))) {
|
|
165
|
+
options.signal?.throwIfAborted();
|
|
166
|
+
result.push({ path: directory, bytes: new Uint8Array(), directory: true });
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
entries: result,
|
|
170
|
+
compressedBytes: untrustedBytes.byteLength,
|
|
171
|
+
expandedBytes: totalSize,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await reader.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UUID pattern without anchors, for embedding in larger patterns such as route
|
|
3
|
+
* matchers. Matches the RFC 4122 shape produced by `crypto.randomUUID()`.
|
|
4
|
+
*/
|
|
5
|
+
export declare const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
|
6
|
+
/** Generates the opaque UUID used by newly persisted application entities. */
|
|
7
|
+
export declare function createId(): string;
|
|
8
|
+
export declare function isUuid(value: unknown): value is string;
|
|
9
|
+
export declare function deterministicUuid(value: string): Promise<string>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { sha256Hex } from "./content-hash.js";
|
|
2
|
+
/**
|
|
3
|
+
* UUID pattern without anchors, for embedding in larger patterns such as route
|
|
4
|
+
* matchers. Matches the RFC 4122 shape produced by `crypto.randomUUID()`.
|
|
5
|
+
*/
|
|
6
|
+
export const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
|
7
|
+
const UUID_RE = new RegExp(`^${UUID_PATTERN}$`);
|
|
8
|
+
/** Generates the opaque UUID used by newly persisted application entities. */
|
|
9
|
+
export function createId() {
|
|
10
|
+
if (typeof crypto.randomUUID === "function")
|
|
11
|
+
return crypto.randomUUID();
|
|
12
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
13
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
14
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
15
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
16
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
17
|
+
}
|
|
18
|
+
export function isUuid(value) {
|
|
19
|
+
return typeof value === "string" && UUID_RE.test(value);
|
|
20
|
+
}
|
|
21
|
+
export async function deterministicUuid(value) {
|
|
22
|
+
const chars = (await sha256Hex(value)).slice(0, 32).split("");
|
|
23
|
+
chars[12] = "5";
|
|
24
|
+
chars[16] = ((Number.parseInt(chars[16], 16) & 0x3) | 0x8).toString(16);
|
|
25
|
+
const hex = chars.join("");
|
|
26
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,34 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/eval-harness",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
4
|
-
"description": "SP service contract for Eval-managed Chats: routes, headers and wire schemas.",
|
|
5
|
-
"license": "MIT",
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
6
4
|
"type": "module",
|
|
5
|
+
"license": "UNLICENSED",
|
|
7
6
|
"exports": {
|
|
8
7
|
".": {
|
|
9
|
-
"types": "./dist/index.d.ts",
|
|
10
|
-
"import": "./dist/index.js"
|
|
8
|
+
"types": "./dist/eval-harness/src/index.d.ts",
|
|
9
|
+
"import": "./dist/eval-harness/src/index.js"
|
|
11
10
|
}
|
|
12
11
|
},
|
|
13
12
|
"files": [
|
|
14
13
|
"dist",
|
|
15
14
|
"README.md"
|
|
16
15
|
],
|
|
17
|
-
"
|
|
18
|
-
"access": "public",
|
|
19
|
-
"tag": "alpha"
|
|
20
|
-
},
|
|
16
|
+
"sideEffects": false,
|
|
21
17
|
"dependencies": {
|
|
18
|
+
"@zip.js/zip.js": "^2.8.34",
|
|
19
|
+
"ipaddr.js": "2.5.0",
|
|
20
|
+
"saxes": "^6.0.0",
|
|
22
21
|
"zod": "^4.6.5"
|
|
23
22
|
},
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"tag": "alpha"
|
|
27
26
|
},
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"build:dist": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/springbrand-lab/sp-platform.git",
|
|
30
|
+
"directory": "packages/eval-harness"
|
|
33
31
|
}
|
|
34
|
-
}
|
|
32
|
+
}
|