@springbrand/eval-harness 0.1.0-alpha.1 → 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 +5 -0
- package/dist/{index.d.ts → eval-harness/src/index.d.ts} +1 -1
- package/dist/eval-harness/src/index.js +266 -0
- 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 +6 -3
- package/dist/index.js +0 -294
|
@@ -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,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/eval-harness",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
|
-
"types": "./dist/index.d.ts",
|
|
9
|
-
"import": "./dist/index.js"
|
|
8
|
+
"types": "./dist/eval-harness/src/index.d.ts",
|
|
9
|
+
"import": "./dist/eval-harness/src/index.js"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
],
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"dependencies": {
|
|
18
|
+
"@zip.js/zip.js": "^2.8.34",
|
|
19
|
+
"ipaddr.js": "2.5.0",
|
|
20
|
+
"saxes": "^6.0.0",
|
|
18
21
|
"zod": "^4.6.5"
|
|
19
22
|
},
|
|
20
23
|
"publishConfig": {
|