@hraness/kb 0.17.1
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/DISCLOSURE +43 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/agent-context.js +35 -0
- package/dist/agent-guide-audit.js +18 -0
- package/dist/attachments.js +19 -0
- package/dist/authoring.js +33 -0
- package/dist/benchmark.js +19 -0
- package/dist/capture.js +49 -0
- package/dist/cli.js +3646 -0
- package/dist/clip/acquire.js +57 -0
- package/dist/clip/args.js +21 -0
- package/dist/clip/bounded-byte-buffer.js +9 -0
- package/dist/clip/browser-profiles.js +17 -0
- package/dist/clip/bundle-reader.js +11 -0
- package/dist/clip/cli.js +27 -0
- package/dist/clip/cookies.js +21 -0
- package/dist/clip/defuddle-worker.js +84 -0
- package/dist/clip/doctor.js +32 -0
- package/dist/clip/jobs.js +784 -0
- package/dist/clip/network-proxy.js +9 -0
- package/dist/clip/network.js +28 -0
- package/dist/clip/persist.js +30 -0
- package/dist/clip/refresh.js +11 -0
- package/dist/clip/terminal.js +11 -0
- package/dist/evaluation-builder.js +12238 -0
- package/dist/evaluation-kb.js +26 -0
- package/dist/evaluation.js +35 -0
- package/dist/git.js +39 -0
- package/dist/graph.js +39 -0
- package/dist/index-01jj6rbv.js +1103 -0
- package/dist/index-06c9ctr6.js +738 -0
- package/dist/index-0kavxzqj.js +2480 -0
- package/dist/index-1gwbassd.js +743 -0
- package/dist/index-1n418kb9.js +827 -0
- package/dist/index-1vrd1rmn.js +56 -0
- package/dist/index-1xxnjn0d.js +171 -0
- package/dist/index-3rm7cz6h.js +373 -0
- package/dist/index-3v2z4f0q.js +328 -0
- package/dist/index-48pz4jpc.js +392 -0
- package/dist/index-4j3tt0c3.js +234 -0
- package/dist/index-5n05se68.js +307 -0
- package/dist/index-5vwpzb5a.js +764 -0
- package/dist/index-6g2pv9d2.js +370 -0
- package/dist/index-7fzc32gf.js +712 -0
- package/dist/index-84x0vjjp.js +432 -0
- package/dist/index-adx6khj5.js +488 -0
- package/dist/index-b88v3vtm.js +805 -0
- package/dist/index-cv6fh7z5.js +462 -0
- package/dist/index-cxfrakt7.js +1067 -0
- package/dist/index-d13v9ckt.js +150 -0
- package/dist/index-dycjbhw2.js +1943 -0
- package/dist/index-dyqwejk5.js +531 -0
- package/dist/index-e5fbsywq.js +658 -0
- package/dist/index-f984hw45.js +927 -0
- package/dist/index-gh719d91.js +63 -0
- package/dist/index-hgve9rh2.js +2046 -0
- package/dist/index-hya40gb2.js +474 -0
- package/dist/index-j4zgmzjr.js +152 -0
- package/dist/index-jsmvyyvf.js +1166 -0
- package/dist/index-kh0pwvdd.js +1974 -0
- package/dist/index-mqx4nd6v.js +119 -0
- package/dist/index-mxxxytys.js +1254 -0
- package/dist/index-n5dd7r0v.js +711 -0
- package/dist/index-npg9z1a4.js +431 -0
- package/dist/index-pj501bh1.js +140 -0
- package/dist/index-qry4vhxk.js +1041 -0
- package/dist/index-s2gw5aw9.js +199 -0
- package/dist/index-vvdj6kca.js +70 -0
- package/dist/index-w2zc0vwa.js +571 -0
- package/dist/index-x3fthpsc.js +585 -0
- package/dist/index-zr53sf63.js +44 -0
- package/dist/index-zxdy5pby.js +2728 -0
- package/dist/index-zzhgcwyt.js +966 -0
- package/dist/index.js +453 -0
- package/dist/navigation.js +13 -0
- package/dist/pdf.js +85 -0
- package/dist/percolate.js +24 -0
- package/dist/portfolio.js +77 -0
- package/dist/query.js +29 -0
- package/dist/repository-memory.js +60 -0
- package/dist/sdk.js +36 -0
- package/dist/search-rules.js +34 -0
- package/dist/search.js +22 -0
- package/dist/semantic.js +39 -0
- package/dist/source-inbox.js +17 -0
- package/dist/untrusted-content.js +32 -0
- package/dist/url-intelligence.js +74 -0
- package/dist/workflow.js +23 -0
- package/dist/workflows/decision-context.js +20 -0
- package/dist/workflows/explain-change.js +8 -0
- package/dist/workflows/index.js +28 -0
- package/dist/workflows/plan-radar.js +10 -0
- package/package.json +409 -0
- package/skills/kb/AGENTS.md +19 -0
- package/skills/kb/SKILL.md +82 -0
- package/skills/kb/agents/openai.yaml +4 -0
- package/skills/kb/references/pdf-review.md +51 -0
- package/skills/kb/references/percolate.md +144 -0
- package/skills/kb/references/plan-structure.md +105 -0
- package/skills/kb/references/plan.md +115 -0
- package/skills/kb/references/query.md +231 -0
- package/skills/kb/references/refresh.md +133 -0
- package/skills/kb/references/save-pdf.md +125 -0
- package/skills/kb/references/save-url.md +178 -0
- package/skills/kb/references/url-authentication.md +75 -0
- package/skills/kb/references/url-platforms.md +25 -0
- package/src/agent-context.ts +1241 -0
- package/src/agent-guide-audit.ts +750 -0
- package/src/attachments.ts +723 -0
- package/src/authoring.ts +1725 -0
- package/src/benchmark.ts +362 -0
- package/src/capture.ts +68 -0
- package/src/cli.ts +3660 -0
- package/src/clip/acquire.ts +1273 -0
- package/src/clip/archive-today.ts +402 -0
- package/src/clip/args.ts +423 -0
- package/src/clip/assets.ts +262 -0
- package/src/clip/bounded-byte-buffer.ts +75 -0
- package/src/clip/browser-profiles.ts +372 -0
- package/src/clip/bundle-reader.ts +608 -0
- package/src/clip/capture.ts +1054 -0
- package/src/clip/cli.ts +181 -0
- package/src/clip/cookies.ts +512 -0
- package/src/clip/doctor.ts +1110 -0
- package/src/clip/extract.ts +868 -0
- package/src/clip/jobs.ts +959 -0
- package/src/clip/lib.ts +861 -0
- package/src/clip/media.ts +1474 -0
- package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
- package/src/clip/metadata-search-tool/Cargo.toml +18 -0
- package/src/clip/metadata-search-tool/runner.ts +685 -0
- package/src/clip/metadata-search-tool/src/main.rs +389 -0
- package/src/clip/metadata-search.ts +687 -0
- package/src/clip/network-proxy.ts +627 -0
- package/src/clip/network.ts +950 -0
- package/src/clip/package-root.ts +54 -0
- package/src/clip/persist.ts +1472 -0
- package/src/clip/platforms.ts +1824 -0
- package/src/clip/refresh.ts +197 -0
- package/src/clip/structured.ts +432 -0
- package/src/clip/terminal.ts +197 -0
- package/src/clip/url-intelligence.ts +581 -0
- package/src/clip/url-metadata-backfill.ts +616 -0
- package/src/clip/url-metadata-cli.ts +251 -0
- package/src/clip/url-metadata.ts +1397 -0
- package/src/evaluation-analysis-v2.ts +1440 -0
- package/src/evaluation-builder.ts +2539 -0
- package/src/evaluation-corpus-authoring.ts +2052 -0
- package/src/evaluation-evidence.ts +1147 -0
- package/src/evaluation-execution-v2.ts +426 -0
- package/src/evaluation-implementation.ts +131 -0
- package/src/evaluation-kb-closure.ts +1430 -0
- package/src/evaluation-kb-runner-v2.ts +735 -0
- package/src/evaluation-kb-v2.ts +2567 -0
- package/src/evaluation-kb.ts +1018 -0
- package/src/evaluation-measurement-v2.ts +1462 -0
- package/src/evaluation-packing-v2.ts +452 -0
- package/src/evaluation-redaction.ts +28 -0
- package/src/evaluation-v2.ts +3857 -0
- package/src/evaluation.ts +1220 -0
- package/src/git.ts +1276 -0
- package/src/graph.ts +1487 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/navigation.ts +277 -0
- package/src/note-lock.ts +529 -0
- package/src/pdf/args.ts +202 -0
- package/src/pdf/capture.ts +298 -0
- package/src/pdf/cli.ts +159 -0
- package/src/pdf/extract.ts +303 -0
- package/src/pdf/layout.ts +618 -0
- package/src/pdf/markdown.ts +154 -0
- package/src/pdf/model.ts +234 -0
- package/src/pdf/ocr.ts +190 -0
- package/src/pdf/persist.ts +314 -0
- package/src/pdf/source.ts +135 -0
- package/src/pdf/tools.ts +146 -0
- package/src/pdf.ts +92 -0
- package/src/percolate.ts +877 -0
- package/src/portfolio-audit.ts +574 -0
- package/src/portfolio-identity.ts +162 -0
- package/src/portfolio-registry.ts +473 -0
- package/src/portfolio.ts +765 -0
- package/src/query.ts +646 -0
- package/src/repository-memory.ts +1150 -0
- package/src/sdk.ts +1452 -0
- package/src/search-rules.ts +712 -0
- package/src/search.ts +710 -0
- package/src/semantic-runtime.ts +1139 -0
- package/src/semantic.ts +2480 -0
- package/src/source-inbox.ts +232 -0
- package/src/untrusted-content.ts +401 -0
- package/src/url-intelligence.ts +104 -0
- package/src/vault.ts +636 -0
- package/src/workflow.ts +653 -0
- package/src/workflows/decision-context.ts +81 -0
- package/src/workflows/explain-change.ts +73 -0
- package/src/workflows/index.ts +3 -0
- package/src/workflows/plan-radar.ts +101 -0
|
@@ -0,0 +1,1472 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { sanitizeTerminalText } from "./terminal.js";
|
|
19
|
+
|
|
20
|
+
export const CAPTURE_MANIFEST_SCHEMA_VERSION = 4 as const;
|
|
21
|
+
export const CAPTURE_MANIFEST_FILENAME = "capture.json";
|
|
22
|
+
export const CAPTURE_SOURCE_EVIDENCE_PATH = "evidence/source.html";
|
|
23
|
+
|
|
24
|
+
export type CaptureManifestAsset = {
|
|
25
|
+
readonly source: string;
|
|
26
|
+
readonly url: string;
|
|
27
|
+
readonly path: string;
|
|
28
|
+
readonly mimeType: string;
|
|
29
|
+
readonly bytes: number;
|
|
30
|
+
readonly sha256: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ArtifactCaptureStatus = "not-requested" | "captured" | "partial" | "unavailable" | "unsupported" | "failed";
|
|
34
|
+
export type EvidenceCaptureStatus = "not-requested" | "captured" | "unavailable";
|
|
35
|
+
|
|
36
|
+
export type VideoContextManifestMetadata = {
|
|
37
|
+
readonly id?: string;
|
|
38
|
+
readonly title?: string;
|
|
39
|
+
readonly description?: string;
|
|
40
|
+
readonly uploader?: string;
|
|
41
|
+
readonly uploaderId?: string;
|
|
42
|
+
readonly channel?: string;
|
|
43
|
+
readonly channelId?: string;
|
|
44
|
+
readonly webpageUrl?: string;
|
|
45
|
+
readonly extractor?: string;
|
|
46
|
+
readonly durationSeconds?: number;
|
|
47
|
+
readonly timestamp?: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type CaptureManifestInput = {
|
|
51
|
+
readonly sourceUrl: string;
|
|
52
|
+
readonly canonicalUrl: string;
|
|
53
|
+
readonly capturedAt: string;
|
|
54
|
+
readonly platform: string;
|
|
55
|
+
readonly status: string;
|
|
56
|
+
readonly scope: string;
|
|
57
|
+
readonly acquisition: {
|
|
58
|
+
readonly method: string;
|
|
59
|
+
readonly finalUrl: string;
|
|
60
|
+
readonly contentType: string | null;
|
|
61
|
+
};
|
|
62
|
+
readonly extraction: {
|
|
63
|
+
readonly extractor: string;
|
|
64
|
+
readonly score: number;
|
|
65
|
+
readonly wordCount: number;
|
|
66
|
+
readonly capturedItems: number;
|
|
67
|
+
readonly expectedItems: number | null;
|
|
68
|
+
};
|
|
69
|
+
readonly attempts: readonly {
|
|
70
|
+
readonly method: string;
|
|
71
|
+
readonly outcome: string;
|
|
72
|
+
readonly message: string;
|
|
73
|
+
}[];
|
|
74
|
+
readonly assets: readonly CaptureManifestAsset[];
|
|
75
|
+
readonly artifacts: {
|
|
76
|
+
readonly images: {
|
|
77
|
+
readonly requested: boolean;
|
|
78
|
+
readonly status: "not-requested" | "captured" | "partial";
|
|
79
|
+
readonly files: number;
|
|
80
|
+
};
|
|
81
|
+
readonly media: {
|
|
82
|
+
readonly requested: boolean;
|
|
83
|
+
readonly status: ArtifactCaptureStatus;
|
|
84
|
+
readonly files: number;
|
|
85
|
+
};
|
|
86
|
+
readonly videoContext: {
|
|
87
|
+
readonly requested: boolean;
|
|
88
|
+
readonly status: ArtifactCaptureStatus;
|
|
89
|
+
readonly thumbnailPath: string | null;
|
|
90
|
+
readonly transcriptLanguage: string | null;
|
|
91
|
+
readonly transcriptCueCount: number;
|
|
92
|
+
readonly transcriptTruncated: boolean;
|
|
93
|
+
readonly metadata: VideoContextManifestMetadata | null;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
readonly evidence: {
|
|
97
|
+
readonly requested: "none" | "source" | "screenshot" | "all";
|
|
98
|
+
readonly screenshotPath: string | null;
|
|
99
|
+
readonly screenshotStatus: EvidenceCaptureStatus;
|
|
100
|
+
readonly sourceHtmlStatus: EvidenceCaptureStatus;
|
|
101
|
+
};
|
|
102
|
+
readonly warnings: readonly string[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type CaptureManifest = CaptureManifestInput & {
|
|
106
|
+
readonly schemaVersion: typeof CAPTURE_MANIFEST_SCHEMA_VERSION;
|
|
107
|
+
/** Digest of the exact redacted, newline-terminated Markdown bytes on disk. */
|
|
108
|
+
readonly document: {
|
|
109
|
+
readonly path: string;
|
|
110
|
+
readonly bytes: number;
|
|
111
|
+
readonly sha256: string;
|
|
112
|
+
};
|
|
113
|
+
readonly evidence: CaptureManifestInput["evidence"] & {
|
|
114
|
+
readonly sourceHtmlPath: typeof CAPTURE_SOURCE_EVIDENCE_PATH | null;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type CaptureBundleInput = {
|
|
119
|
+
readonly markdown: string;
|
|
120
|
+
readonly manifest: CaptureManifestInput;
|
|
121
|
+
readonly sourceHtml?: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type TransactionState = "open" | "written" | "committed" | "aborted";
|
|
125
|
+
const transactionState = Symbol("captureBundleTransactionState");
|
|
126
|
+
|
|
127
|
+
export type CaptureBundleTransaction = {
|
|
128
|
+
readonly outputRoot: string;
|
|
129
|
+
readonly targetDirectory: string;
|
|
130
|
+
readonly stagingDirectory: string;
|
|
131
|
+
readonly assetsDirectory: string;
|
|
132
|
+
readonly slug: string;
|
|
133
|
+
readonly force: boolean;
|
|
134
|
+
readonly expectedTargetIdentity: { readonly device: number; readonly inode: number } | null;
|
|
135
|
+
[transactionState]: TransactionState;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Hooks exist so tests can deterministically exercise otherwise rare rename failures. */
|
|
139
|
+
export type CaptureCommitHooks = {
|
|
140
|
+
readonly afterBackup?: () => void;
|
|
141
|
+
readonly afterInstall?: () => void;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const secretKeyPattern = /(?:auth(?:orization)?|cookie|credential|csrf|xsrf|jwt|pass(?:word|wd)?|secret|session|token|api[-_]?key|private[-_]?key|signature|signed[-_]?url|key[-_]?pair[-_]?id|hdne[at])/i;
|
|
145
|
+
const oneTimeCredentialKeyPattern = /^(?:code|ticket|otp|nonce|key|magic[-_]?link|magic[-_]?token|one[-_]?time(?:[-_]?code|[-_]?token)?)$/i;
|
|
146
|
+
const providerSignatureKeyPattern = /^(?:x-amz-.+|x-goog-.+|signature|sig|policy|key-pair-id|googleaccessid|awsaccesskeyid|hdne[at])$/i;
|
|
147
|
+
const azureSasKeyPattern = /^(?:sv|ss|srt|sp|se|st|spr|sip|sr|sig)$/i;
|
|
148
|
+
const safeManifestTokenPattern = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
|
|
149
|
+
const safeArtifactMimePattern = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
|
|
150
|
+
const MAX_ARTIFACT_URL_CODE_UNITS = 64 * 1024;
|
|
151
|
+
const MAX_ARTIFACT_URL_PARAMETERS = 4_096;
|
|
152
|
+
|
|
153
|
+
function requireSafeSlug(slug: string): void {
|
|
154
|
+
if (slug.length === 0 || slug.length > 240) throw new Error("unsafe capture slug");
|
|
155
|
+
const normalized = slug.normalize("NFKC");
|
|
156
|
+
const codePoints = [...slug];
|
|
157
|
+
const validCharacters = /^[\p{Letter}\p{Number}](?:[\p{Letter}\p{Number}._-]*[\p{Letter}\p{Number}])?$/u;
|
|
158
|
+
if (
|
|
159
|
+
slug !== normalized
|
|
160
|
+
|| codePoints.length === 0
|
|
161
|
+
|| codePoints.length > 80
|
|
162
|
+
|| new TextEncoder().encode(slug).byteLength > 240
|
|
163
|
+
|| !validCharacters.test(slug)
|
|
164
|
+
|| slug === "."
|
|
165
|
+
|| slug === ".."
|
|
166
|
+
) {
|
|
167
|
+
throw new Error("unsafe capture slug");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Return the vault-conventional Markdown filename for a validated capture slug. */
|
|
172
|
+
export function captureMarkdownFilename(slug: string): string {
|
|
173
|
+
requireSafeSlug(slug);
|
|
174
|
+
return `${slug}.md`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isConfinedChild(root: string, path: string): boolean {
|
|
178
|
+
const child = relative(root, path);
|
|
179
|
+
return child !== "" && !isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function assertConfinedChild(root: string, path: string, label: string): void {
|
|
183
|
+
if (!isConfinedChild(root, path)) throw new Error(`${label} escapes the capture output root`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ensureOutputRoot(outputRoot: string): string {
|
|
187
|
+
const absolute = resolve(outputRoot);
|
|
188
|
+
mkdirSync(absolute, { recursive: true, mode: 0o755 });
|
|
189
|
+
const stats = lstatSync(absolute);
|
|
190
|
+
if (!stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
191
|
+
throw new Error(`capture output root is not a directory: ${absolute}`);
|
|
192
|
+
}
|
|
193
|
+
const canonical = realpathSync(absolute);
|
|
194
|
+
if (!lstatSync(canonical).isDirectory()) throw new Error(`capture output root is not a directory: ${canonical}`);
|
|
195
|
+
if (dirname(canonical) === canonical || canonical === realpathSync(homedir())) {
|
|
196
|
+
throw new Error(`refusing dangerous capture output root: ${canonical}`);
|
|
197
|
+
}
|
|
198
|
+
return canonical;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function ownedTargetIdentity(targetDirectory: string, slug: string): { readonly device: number; readonly inode: number } {
|
|
202
|
+
const directory = lstatSync(targetDirectory);
|
|
203
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
204
|
+
throw new Error(`--force only replaces a regular clip-owned directory: ${targetDirectory}`);
|
|
205
|
+
}
|
|
206
|
+
const manifestPath = join(targetDirectory, CAPTURE_MANIFEST_FILENAME);
|
|
207
|
+
const markdownPath = join(targetDirectory, captureMarkdownFilename(slug));
|
|
208
|
+
for (const [label, path] of [["manifest", manifestPath], ["Markdown", markdownPath]] as const) {
|
|
209
|
+
let stats;
|
|
210
|
+
try {
|
|
211
|
+
stats = lstatSync(path);
|
|
212
|
+
} catch {
|
|
213
|
+
throw new Error(`--force refused an unowned target without its expected ${label}: ${targetDirectory}`);
|
|
214
|
+
}
|
|
215
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
216
|
+
throw new Error(`--force refused an unowned target with an unsafe ${label}: ${targetDirectory}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const manifestStats = lstatSync(manifestPath);
|
|
220
|
+
if (manifestStats.size > 1024 * 1024) {
|
|
221
|
+
throw new Error(`--force refused an unowned target with an oversized manifest: ${targetDirectory}`);
|
|
222
|
+
}
|
|
223
|
+
let parsed: unknown;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as unknown;
|
|
226
|
+
} catch {
|
|
227
|
+
throw new Error(`--force refused an unowned target with an invalid manifest: ${targetDirectory}`);
|
|
228
|
+
}
|
|
229
|
+
if (
|
|
230
|
+
typeof parsed !== "object"
|
|
231
|
+
|| parsed === null
|
|
232
|
+
|| Array.isArray(parsed)
|
|
233
|
+
|| !("schemaVersion" in parsed)
|
|
234
|
+
|| (
|
|
235
|
+
parsed.schemaVersion !== 1
|
|
236
|
+
&& parsed.schemaVersion !== 2
|
|
237
|
+
&& parsed.schemaVersion !== 3
|
|
238
|
+
&& parsed.schemaVersion !== CAPTURE_MANIFEST_SCHEMA_VERSION
|
|
239
|
+
)
|
|
240
|
+
|| !("sourceUrl" in parsed)
|
|
241
|
+
|| typeof parsed.sourceUrl !== "string"
|
|
242
|
+
) {
|
|
243
|
+
throw new Error(`--force refused an unowned target with an incompatible manifest: ${targetDirectory}`);
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const source = new URL(parsed.sourceUrl);
|
|
247
|
+
if (source.protocol !== "http:" && source.protocol !== "https:") throw new Error("unsupported source protocol");
|
|
248
|
+
} catch {
|
|
249
|
+
throw new Error(`--force refused an unowned target with an invalid manifest source: ${targetDirectory}`);
|
|
250
|
+
}
|
|
251
|
+
return { device: directory.dev, inode: directory.ino };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Create an owned staging directory in the same filesystem as its final target. */
|
|
255
|
+
export function beginCaptureBundle(options: {
|
|
256
|
+
readonly outputRoot: string;
|
|
257
|
+
readonly slug: string;
|
|
258
|
+
readonly force: boolean;
|
|
259
|
+
}): CaptureBundleTransaction {
|
|
260
|
+
requireSafeSlug(options.slug);
|
|
261
|
+
const outputRoot = ensureOutputRoot(options.outputRoot);
|
|
262
|
+
const targetDirectory = join(outputRoot, options.slug);
|
|
263
|
+
assertConfinedChild(outputRoot, targetDirectory, "capture target");
|
|
264
|
+
const targetExists = existsSync(targetDirectory);
|
|
265
|
+
if (targetExists && !options.force) {
|
|
266
|
+
throw new Error(`capture already exists: ${targetDirectory}; pass --force to replace it`);
|
|
267
|
+
}
|
|
268
|
+
const expectedTargetIdentity = targetExists ? ownedTargetIdentity(targetDirectory, options.slug) : null;
|
|
269
|
+
|
|
270
|
+
const stagingDirectory = mkdtempSync(join(outputRoot, ".capture-staging-"));
|
|
271
|
+
chmodSync(stagingDirectory, 0o700);
|
|
272
|
+
assertConfinedChild(outputRoot, stagingDirectory, "capture staging directory");
|
|
273
|
+
return {
|
|
274
|
+
outputRoot,
|
|
275
|
+
targetDirectory,
|
|
276
|
+
stagingDirectory,
|
|
277
|
+
assetsDirectory: join(stagingDirectory, "assets"),
|
|
278
|
+
slug: options.slug,
|
|
279
|
+
force: options.force,
|
|
280
|
+
expectedTargetIdentity,
|
|
281
|
+
[transactionState]: "open",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function requireState(transaction: CaptureBundleTransaction, expected: TransactionState): void {
|
|
286
|
+
if (transaction[transactionState] !== expected) {
|
|
287
|
+
throw new Error(`capture transaction is ${transaction[transactionState]}, expected ${expected}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function ensureTrailingNewline(value: string): string {
|
|
292
|
+
return value.endsWith("\n") ? value : `${value}\n`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export type RedactedText = { readonly text: string; readonly count: number };
|
|
296
|
+
|
|
297
|
+
type ProjectedText = {
|
|
298
|
+
readonly text: string;
|
|
299
|
+
readonly limitExceeded: boolean;
|
|
300
|
+
readonly entities: readonly {
|
|
301
|
+
readonly projectedStart: number;
|
|
302
|
+
readonly projectedEnd: number;
|
|
303
|
+
readonly sourceStart: number;
|
|
304
|
+
readonly sourceEnd: number;
|
|
305
|
+
readonly deltaAfter: number;
|
|
306
|
+
}[];
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const MAX_PROJECTED_CREDENTIAL_ENTITIES = 4_096;
|
|
310
|
+
const MAX_CREDENTIAL_REPLACEMENTS = 4_096;
|
|
311
|
+
const MAX_PROJECTED_URL_CANDIDATES = 4_096;
|
|
312
|
+
const MAX_PROJECTED_URL_LENGTH = 16 * 1024;
|
|
313
|
+
const ENTITY_DENSE_REDACTION = "[REDACTED ENTITY-DENSE CONTENT]";
|
|
314
|
+
const CREDENTIAL_DENSE_REDACTION = "[REDACTED CREDENTIAL-DENSE CONTENT]";
|
|
315
|
+
const URL_DENSE_REDACTION = "[REDACTED URL-DENSE CONTENT]";
|
|
316
|
+
const exactCredentialAssignmentKeys = new Set([
|
|
317
|
+
"authorization",
|
|
318
|
+
"cookie",
|
|
319
|
+
"credential",
|
|
320
|
+
"credentials",
|
|
321
|
+
"csrf",
|
|
322
|
+
"hdnea",
|
|
323
|
+
"hdnat",
|
|
324
|
+
"jwt",
|
|
325
|
+
"password",
|
|
326
|
+
"passwd",
|
|
327
|
+
"proxy_authorization",
|
|
328
|
+
"_auth",
|
|
329
|
+
"secret",
|
|
330
|
+
"session",
|
|
331
|
+
"set_cookie",
|
|
332
|
+
"token",
|
|
333
|
+
"xsrf",
|
|
334
|
+
]);
|
|
335
|
+
const credentialAssignmentKeySuffixes = [
|
|
336
|
+
"api_key",
|
|
337
|
+
"authorization",
|
|
338
|
+
"cookie",
|
|
339
|
+
"credential",
|
|
340
|
+
"credentials",
|
|
341
|
+
"csrf",
|
|
342
|
+
"csrf_token",
|
|
343
|
+
"jwt",
|
|
344
|
+
"key_pair_id",
|
|
345
|
+
"passwd",
|
|
346
|
+
"password",
|
|
347
|
+
"private_key",
|
|
348
|
+
"secret",
|
|
349
|
+
"secret_key",
|
|
350
|
+
"session",
|
|
351
|
+
"session_id",
|
|
352
|
+
"session_key",
|
|
353
|
+
"session_token",
|
|
354
|
+
"signature",
|
|
355
|
+
"signed_url",
|
|
356
|
+
"token",
|
|
357
|
+
"xsrf",
|
|
358
|
+
"xsrf_token",
|
|
359
|
+
] as const;
|
|
360
|
+
|
|
361
|
+
function normalizeCredentialKey(key: string): string {
|
|
362
|
+
return key
|
|
363
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
364
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
365
|
+
.replace(/-+/g, "_")
|
|
366
|
+
.toLowerCase();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isCredentialAssignmentKey(key: string): boolean {
|
|
370
|
+
const normalized = normalizeCredentialKey(key);
|
|
371
|
+
return exactCredentialAssignmentKeys.has(normalized)
|
|
372
|
+
|| normalized === "aws_access_key_id"
|
|
373
|
+
|| normalized === "aws_secret_access_key"
|
|
374
|
+
|| credentialAssignmentKeySuffixes.some((suffix) => normalized === suffix || normalized.endsWith(`_${suffix}`));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const authenticationQueryKeys = new Set([
|
|
378
|
+
"action_code",
|
|
379
|
+
"activation_code",
|
|
380
|
+
"assertion",
|
|
381
|
+
"confirmation_code",
|
|
382
|
+
"invite_code",
|
|
383
|
+
"magic_code",
|
|
384
|
+
"oob_code",
|
|
385
|
+
"recovery_code",
|
|
386
|
+
"relay_state",
|
|
387
|
+
"reset_code",
|
|
388
|
+
"saml_request",
|
|
389
|
+
"saml_response",
|
|
390
|
+
"verification_code",
|
|
391
|
+
"verify_code",
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
function isAuthenticationQueryKey(key: string): boolean {
|
|
395
|
+
return authenticationQueryKeys.has(normalizeCredentialKey(key));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const credentialEntityCharacters: Readonly<Record<string, string>> = {
|
|
399
|
+
amp: "&",
|
|
400
|
+
apos: "'",
|
|
401
|
+
bsol: "\\",
|
|
402
|
+
colon: ":",
|
|
403
|
+
comma: ",",
|
|
404
|
+
commat: "@",
|
|
405
|
+
dash: "-",
|
|
406
|
+
equals: "=",
|
|
407
|
+
gt: ">",
|
|
408
|
+
hyphen: "-",
|
|
409
|
+
lowbar: "_",
|
|
410
|
+
lt: "<",
|
|
411
|
+
newline: "\n",
|
|
412
|
+
nbsp: " ",
|
|
413
|
+
num: "#",
|
|
414
|
+
percnt: "%",
|
|
415
|
+
period: ".",
|
|
416
|
+
plus: "+",
|
|
417
|
+
quot: '"',
|
|
418
|
+
semi: ";",
|
|
419
|
+
sol: "/",
|
|
420
|
+
tab: "\t",
|
|
421
|
+
underbar: "_",
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
function decodeCredentialEntity(entity: string): string | null {
|
|
425
|
+
const numeric = /^&#(?:x([0-9a-f]+)|(\d+));?$/i.exec(entity);
|
|
426
|
+
if (numeric !== null) {
|
|
427
|
+
const codePoint = Number.parseInt(numeric[1] ?? numeric[2] ?? "", numeric[1] === undefined ? 10 : 16);
|
|
428
|
+
if (!Number.isSafeInteger(codePoint) || codePoint <= 0 || codePoint > 0x10ffff) return null;
|
|
429
|
+
if (codePoint >= 0xd800 && codePoint <= 0xdfff) return null;
|
|
430
|
+
return String.fromCodePoint(codePoint);
|
|
431
|
+
}
|
|
432
|
+
const named = /^&([a-z][a-z0-9]+);$/i.exec(entity)?.[1]?.toLowerCase();
|
|
433
|
+
return named === undefined ? null : (credentialEntityCharacters[named] ?? null);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Build an entity-decoded view only for matching. Source offsets let redaction
|
|
438
|
+
* replace the original bytes, so entity decoding can never introduce markup.
|
|
439
|
+
*/
|
|
440
|
+
function projectCredentialEntities(value: string): ProjectedText {
|
|
441
|
+
if (!value.includes("&")) return { text: value, entities: [], limitExceeded: false };
|
|
442
|
+
const entities: Array<ProjectedText["entities"][number]> = [];
|
|
443
|
+
const parts: string[] = [];
|
|
444
|
+
const entityPattern = /&(?:#[xX][0-9a-fA-F]{1,6};|#\d{1,7};|#[xX][0-9a-fA-F]{1,6}(?![0-9a-fA-F])|#\d{1,7}(?!\d)|[a-zA-Z][a-zA-Z0-9]+;)/g;
|
|
445
|
+
let sourceCursor = 0;
|
|
446
|
+
let projectedLength = 0;
|
|
447
|
+
for (const match of value.matchAll(entityPattern)) {
|
|
448
|
+
const sourceStart = match.index ?? 0;
|
|
449
|
+
const sourceEnd = sourceStart + match[0].length;
|
|
450
|
+
const decoded = decodeCredentialEntity(match[0]);
|
|
451
|
+
if (decoded === null) continue;
|
|
452
|
+
if (entities.length >= MAX_PROJECTED_CREDENTIAL_ENTITIES) {
|
|
453
|
+
return { text: "", entities: [], limitExceeded: true };
|
|
454
|
+
}
|
|
455
|
+
const unchanged = value.slice(sourceCursor, sourceStart);
|
|
456
|
+
parts.push(unchanged, decoded);
|
|
457
|
+
projectedLength += unchanged.length;
|
|
458
|
+
entities.push({
|
|
459
|
+
projectedStart: projectedLength,
|
|
460
|
+
projectedEnd: projectedLength + decoded.length,
|
|
461
|
+
sourceStart,
|
|
462
|
+
sourceEnd,
|
|
463
|
+
deltaAfter: sourceEnd - (projectedLength + decoded.length),
|
|
464
|
+
});
|
|
465
|
+
projectedLength += decoded.length;
|
|
466
|
+
sourceCursor = sourceEnd;
|
|
467
|
+
}
|
|
468
|
+
if (entities.length === 0) return { text: value, entities, limitExceeded: false };
|
|
469
|
+
parts.push(value.slice(sourceCursor));
|
|
470
|
+
return { text: parts.join(""), entities, limitExceeded: false };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
type CredentialReplacement = {
|
|
474
|
+
readonly start: number;
|
|
475
|
+
readonly end: number;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
type TextReplacement = CredentialReplacement & {
|
|
479
|
+
readonly text: string;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
function applyTextReplacements(value: string, replacements: readonly TextReplacement[]): string {
|
|
483
|
+
if (replacements.length === 0) return value;
|
|
484
|
+
const ordered = [...replacements].sort((left, right) => left.start - right.start);
|
|
485
|
+
const parts: string[] = [];
|
|
486
|
+
let cursor = 0;
|
|
487
|
+
for (const replacement of ordered) {
|
|
488
|
+
if (replacement.start < cursor || replacement.end <= replacement.start) continue;
|
|
489
|
+
parts.push(value.slice(cursor, replacement.start), replacement.text);
|
|
490
|
+
cursor = replacement.end;
|
|
491
|
+
}
|
|
492
|
+
parts.push(value.slice(cursor));
|
|
493
|
+
return parts.join("");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function quotedValueEnd(value: string, start: number): number | null {
|
|
497
|
+
const quote = value[start];
|
|
498
|
+
if (quote !== '"' && quote !== "'") return null;
|
|
499
|
+
for (let cursor = start + 1; cursor < value.length; cursor += 1) {
|
|
500
|
+
if (value[cursor] === "\\") {
|
|
501
|
+
cursor += 1;
|
|
502
|
+
} else if (value[cursor] === quote) {
|
|
503
|
+
return cursor;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function firstUnencodedDelimiter(projected: ProjectedText, start: number, delimiter: RegExp): number {
|
|
510
|
+
let low = 0;
|
|
511
|
+
let high = projected.entities.length;
|
|
512
|
+
while (low < high) {
|
|
513
|
+
const middle = Math.floor((low + high) / 2);
|
|
514
|
+
if ((projected.entities[middle]?.projectedEnd ?? 0) <= start) low = middle + 1;
|
|
515
|
+
else high = middle;
|
|
516
|
+
}
|
|
517
|
+
let entityIndex = low;
|
|
518
|
+
for (let cursor = start; cursor < projected.text.length; cursor += 1) {
|
|
519
|
+
while ((projected.entities[entityIndex]?.projectedEnd ?? Number.POSITIVE_INFINITY) <= cursor) entityIndex += 1;
|
|
520
|
+
const entity = projected.entities[entityIndex];
|
|
521
|
+
const insideEntity = entity !== undefined
|
|
522
|
+
&& cursor >= entity.projectedStart
|
|
523
|
+
&& cursor < entity.projectedEnd;
|
|
524
|
+
if (!insideEntity && delimiter.test(projected.text[cursor] ?? "")) return cursor;
|
|
525
|
+
}
|
|
526
|
+
return projected.text.length;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function projectedBoundaryToSource(projected: ProjectedText, offset: number, side: "start" | "end"): number {
|
|
530
|
+
let low = 0;
|
|
531
|
+
let high = projected.entities.length;
|
|
532
|
+
while (low < high) {
|
|
533
|
+
const middle = Math.floor((low + high) / 2);
|
|
534
|
+
if ((projected.entities[middle]?.projectedStart ?? Number.POSITIVE_INFINITY) <= offset) low = middle + 1;
|
|
535
|
+
else high = middle;
|
|
536
|
+
}
|
|
537
|
+
const entity = projected.entities[low - 1];
|
|
538
|
+
if (entity === undefined) return offset;
|
|
539
|
+
if (side === "start" && offset < entity.projectedEnd) return entity.sourceStart;
|
|
540
|
+
if (side === "end" && offset > entity.projectedStart && offset <= entity.projectedEnd) return entity.sourceEnd;
|
|
541
|
+
if (offset >= entity.projectedEnd) return offset + entity.deltaAfter;
|
|
542
|
+
return offset + (projected.entities[low - 2]?.deltaAfter ?? 0);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function credentialValueEnd(projected: ProjectedText, start: number, key: string): { readonly start: number; readonly end: number } {
|
|
546
|
+
const quoteEnd = quotedValueEnd(projected.text, start);
|
|
547
|
+
if (quoteEnd !== null) return { start: start + 1, end: quoteEnd };
|
|
548
|
+
|
|
549
|
+
const normalizedKey = key.toLowerCase().replace(/_/g, "-");
|
|
550
|
+
if (normalizedKey === "cookie" || normalizedKey === "set-cookie") {
|
|
551
|
+
return { start, end: firstUnencodedDelimiter(projected, start, /[\r\n<]/) };
|
|
552
|
+
}
|
|
553
|
+
if (normalizedKey === "authorization" || normalizedKey === "proxy-authorization") {
|
|
554
|
+
const schemeAtStart = /(?:basic|bearer)\s+/iy;
|
|
555
|
+
schemeAtStart.lastIndex = start;
|
|
556
|
+
const scheme = schemeAtStart.exec(projected.text);
|
|
557
|
+
const secretStart = start + (scheme?.[0].length ?? 0);
|
|
558
|
+
return { start, end: firstUnencodedDelimiter(projected, secretStart, /[\s<>&,"'\r\n]/) };
|
|
559
|
+
}
|
|
560
|
+
if (/(?:credential|pass(?:word|wd)?|private-key|secret)/i.test(normalizedKey)) {
|
|
561
|
+
return { start, end: firstUnencodedDelimiter(projected, start, /[\r\n<]/) };
|
|
562
|
+
}
|
|
563
|
+
return { start, end: firstUnencodedDelimiter(projected, start, /[\s<>&;,)\]\r\n]/) };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function redactProjectedCredentialAssignments(value: string): RedactedText {
|
|
567
|
+
const projected = projectCredentialEntities(value);
|
|
568
|
+
if (projected.limitExceeded) return { text: ENTITY_DENSE_REDACTION, count: 1 };
|
|
569
|
+
const assignments = /(?<![a-z0-9_-])(?:(['"])([a-z_][a-z0-9_-]{0,127})\1|([a-z_][a-z0-9_-]{0,127}))\s*[:=]\s*/gi;
|
|
570
|
+
const replacements: CredentialReplacement[] = [];
|
|
571
|
+
let match: RegExpExecArray | null;
|
|
572
|
+
while ((match = assignments.exec(projected.text)) !== null) {
|
|
573
|
+
const key = match[2] ?? match[3] ?? "";
|
|
574
|
+
if (!isCredentialAssignmentKey(key)) continue;
|
|
575
|
+
const valueStart = (match.index ?? 0) + match[0].length;
|
|
576
|
+
const range = credentialValueEnd(projected, valueStart, key);
|
|
577
|
+
if (range.end <= range.start) continue;
|
|
578
|
+
const sourceStart = projectedBoundaryToSource(projected, range.start, "start");
|
|
579
|
+
const sourceEnd = projectedBoundaryToSource(projected, range.end, "end");
|
|
580
|
+
if (sourceEnd <= sourceStart) continue;
|
|
581
|
+
if (replacements.length >= MAX_CREDENTIAL_REPLACEMENTS) {
|
|
582
|
+
return { text: CREDENTIAL_DENSE_REDACTION, count: 1 };
|
|
583
|
+
}
|
|
584
|
+
replacements.push({ start: sourceStart, end: sourceEnd });
|
|
585
|
+
assignments.lastIndex = Math.max(assignments.lastIndex, range.end);
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
text: applyTextReplacements(value, replacements.map((replacement) => ({ ...replacement, text: "[REDACTED]" }))),
|
|
589
|
+
count: replacements.length,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function redactPemCredentialBlocks(value: string): RedactedText {
|
|
594
|
+
let count = 0;
|
|
595
|
+
const label = "((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY|PGP PRIVATE KEY BLOCK|(?:X509 |TRUSTED )?CERTIFICATE)";
|
|
596
|
+
const pattern = new RegExp(`-----BEGIN ${label}-----[\\s\\S]*?(?:-----END \\1-----|$)`, "gi");
|
|
597
|
+
const text = value.replace(pattern, () => {
|
|
598
|
+
count += 1;
|
|
599
|
+
return "[REDACTED_PEM_CREDENTIAL]";
|
|
600
|
+
});
|
|
601
|
+
return { text, count };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function redactCredentialText(value: string): RedactedText {
|
|
605
|
+
const pemBlocks = redactPemCredentialBlocks(value);
|
|
606
|
+
const assignments = redactProjectedCredentialAssignments(pemBlocks.text);
|
|
607
|
+
let count = pemBlocks.count + assignments.count;
|
|
608
|
+
const replace = (replacement: string) => (): string => {
|
|
609
|
+
count += 1;
|
|
610
|
+
return replacement;
|
|
611
|
+
};
|
|
612
|
+
const text = assignments.text
|
|
613
|
+
.replace(/\beyJ[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9_-]{8,}\b/g, replace("[REDACTED JWT]"))
|
|
614
|
+
.replace(/\bAKIA[A-Z0-9]{16}\b/g, replace("[REDACTED ACCESS KEY]"));
|
|
615
|
+
return { text, count };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function repeatedlyDecodeURIComponent(value: string): string {
|
|
619
|
+
let decoded = value;
|
|
620
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
621
|
+
let next: string;
|
|
622
|
+
try {
|
|
623
|
+
next = decodeURIComponent(decoded);
|
|
624
|
+
} catch {
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
if (next === decoded) break;
|
|
628
|
+
decoded = next;
|
|
629
|
+
}
|
|
630
|
+
return decoded;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const directCredentialPathMarkerPattern = /^(?:magic[-_]?link|magic[-_]?token|one[-_]?time(?:[-_]?code|[-_]?token)?|password[-_]?reset|reset[-_]?password)$/i;
|
|
634
|
+
const contextualCredentialPathMarkerPattern = /^(?:code|key|nonce|otp|ticket|token)$/i;
|
|
635
|
+
const credentialPathContextPattern = /^(?:account|activate|activation|auth|callback|confirm|confirmation|invite|invitation|login|magic|oauth2?|password|recover|recovery|redeem|reset|signin|verify|verification)$/i;
|
|
636
|
+
|
|
637
|
+
function looksLikeCredentialPathValue(value: string): boolean {
|
|
638
|
+
if (value.length >= 20) return true;
|
|
639
|
+
if (/^\d{4,}$/.test(value)) return true;
|
|
640
|
+
return value.length >= 6 && /[a-z]/i.test(value) && /\d/.test(value);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function sanitizeCredentialPath(url: URL): number {
|
|
644
|
+
const segments = url.pathname.split("/");
|
|
645
|
+
const decodedSegments = segments.map((segment) => repeatedlyDecodeURIComponent(segment));
|
|
646
|
+
for (let index = 0; index < decodedSegments.length - 1; index += 1) {
|
|
647
|
+
const marker = decodedSegments[index] ?? "";
|
|
648
|
+
const credential = decodedSegments[index + 1] ?? "";
|
|
649
|
+
if (credential === "") continue;
|
|
650
|
+
const previous = decodedSegments[index - 1] ?? "";
|
|
651
|
+
const isCredentialBoundary = directCredentialPathMarkerPattern.test(marker)
|
|
652
|
+
|| (credentialPathContextPattern.test(marker) && looksLikeCredentialPathValue(credential))
|
|
653
|
+
|| (contextualCredentialPathMarkerPattern.test(marker)
|
|
654
|
+
&& (credentialPathContextPattern.test(previous) || looksLikeCredentialPathValue(credential)));
|
|
655
|
+
if (!isCredentialBoundary) continue;
|
|
656
|
+
url.pathname = `${segments.slice(0, index + 1).join("/")}/`;
|
|
657
|
+
return 1;
|
|
658
|
+
}
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function sanitizeArtifactUrlWithCount(value: string): RedactedText {
|
|
663
|
+
if (value.length > MAX_ARTIFACT_URL_CODE_UNITS) {
|
|
664
|
+
throw new Error("manifest URL exceeds the 65536 code-unit safety limit");
|
|
665
|
+
}
|
|
666
|
+
let url: URL;
|
|
667
|
+
try {
|
|
668
|
+
url = new URL(value);
|
|
669
|
+
} catch {
|
|
670
|
+
throw new Error("manifest URL is invalid");
|
|
671
|
+
}
|
|
672
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
673
|
+
throw new Error("manifest URL must use http or https");
|
|
674
|
+
}
|
|
675
|
+
let count = url.username === "" && url.password === "" ? 0 : 1;
|
|
676
|
+
url.username = "";
|
|
677
|
+
url.password = "";
|
|
678
|
+
count += sanitizeCredentialPath(url);
|
|
679
|
+
const entries: Array<readonly [string, string]> = [];
|
|
680
|
+
const valuesByKey = new Map<string, string[]>();
|
|
681
|
+
let queryLimitExceeded = false;
|
|
682
|
+
for (const [key, queryValue] of url.searchParams) {
|
|
683
|
+
if (entries.length >= MAX_ARTIFACT_URL_PARAMETERS) {
|
|
684
|
+
queryLimitExceeded = true;
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
entries.push([key, queryValue]);
|
|
688
|
+
const values = valuesByKey.get(key);
|
|
689
|
+
if (values === undefined) valuesByKey.set(key, [queryValue]);
|
|
690
|
+
else values.push(queryValue);
|
|
691
|
+
}
|
|
692
|
+
if (queryLimitExceeded) {
|
|
693
|
+
url.search = "";
|
|
694
|
+
count += 1;
|
|
695
|
+
}
|
|
696
|
+
const keys = queryLimitExceeded ? [] : [...valuesByKey.keys()];
|
|
697
|
+
const decodedKeys = keys.map((key) => repeatedlyDecodeURIComponent(key));
|
|
698
|
+
const hasAzureSignature = decodedKeys.some((key) => key.toLowerCase() === "sig")
|
|
699
|
+
&& decodedKeys.some((key) => /^(?:sv|sp|se|sr)$/i.test(key));
|
|
700
|
+
const hasAuthenticationCredential = decodedKeys.some((key) => (
|
|
701
|
+
isCredentialAssignmentKey(key)
|
|
702
|
+
|| oneTimeCredentialKeyPattern.test(key)
|
|
703
|
+
|| isAuthenticationQueryKey(key)
|
|
704
|
+
));
|
|
705
|
+
const removedKeys = new Set<string>();
|
|
706
|
+
for (const key of keys) {
|
|
707
|
+
const values = valuesByKey.get(key) ?? [];
|
|
708
|
+
const decodedKey = repeatedlyDecodeURIComponent(key);
|
|
709
|
+
if (
|
|
710
|
+
isCredentialAssignmentKey(decodedKey)
|
|
711
|
+
|| oneTimeCredentialKeyPattern.test(decodedKey)
|
|
712
|
+
|| isAuthenticationQueryKey(decodedKey)
|
|
713
|
+
|| (normalizeCredentialKey(decodedKey) === "state" && hasAuthenticationCredential)
|
|
714
|
+
|| providerSignatureKeyPattern.test(decodedKey)
|
|
715
|
+
|| (hasAzureSignature && azureSasKeyPattern.test(decodedKey))
|
|
716
|
+
|| values.some((item) => {
|
|
717
|
+
const decodedValue = repeatedlyDecodeURIComponent(item);
|
|
718
|
+
return redactCredentialText(decodedValue).text !== decodedValue;
|
|
719
|
+
})
|
|
720
|
+
) {
|
|
721
|
+
removedKeys.add(key);
|
|
722
|
+
count += Math.max(1, values.length);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (removedKeys.size > 0) {
|
|
726
|
+
const retained = new URLSearchParams();
|
|
727
|
+
for (const [key, queryValue] of entries) {
|
|
728
|
+
if (!removedKeys.has(key)) retained.append(key, queryValue);
|
|
729
|
+
}
|
|
730
|
+
url.search = retained.toString();
|
|
731
|
+
}
|
|
732
|
+
const decodedHash = repeatedlyDecodeURIComponent(url.hash);
|
|
733
|
+
const fragmentHasCredential = decodedHash
|
|
734
|
+
.split(/[?&#;]/)
|
|
735
|
+
.some((segment) => {
|
|
736
|
+
const equals = segment.indexOf("=");
|
|
737
|
+
if (equals < 0) return false;
|
|
738
|
+
const pathTail = segment.slice(0, equals).trim().split("/").at(-1) ?? "";
|
|
739
|
+
const decodedKey = repeatedlyDecodeURIComponent(pathTail);
|
|
740
|
+
return isCredentialAssignmentKey(decodedKey)
|
|
741
|
+
|| oneTimeCredentialKeyPattern.test(decodedKey)
|
|
742
|
+
|| isAuthenticationQueryKey(decodedKey)
|
|
743
|
+
|| providerSignatureKeyPattern.test(decodedKey);
|
|
744
|
+
});
|
|
745
|
+
if (
|
|
746
|
+
fragmentHasCredential
|
|
747
|
+
|| redactCredentialText(decodedHash).text !== decodedHash
|
|
748
|
+
) {
|
|
749
|
+
url.hash = "";
|
|
750
|
+
count += 1;
|
|
751
|
+
}
|
|
752
|
+
return { text: url.href, count };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function redactProjectedUrls(value: string): RedactedText {
|
|
756
|
+
const projected = projectCredentialEntities(value);
|
|
757
|
+
if (projected.limitExceeded) return { text: ENTITY_DENSE_REDACTION, count: 1 };
|
|
758
|
+
const replacements: TextReplacement[] = [];
|
|
759
|
+
let count = 0;
|
|
760
|
+
let candidates = 0;
|
|
761
|
+
const webUrl = /https?:\/\/[^\s<>"']+/gi;
|
|
762
|
+
let match: RegExpExecArray | null;
|
|
763
|
+
while ((match = webUrl.exec(projected.text)) !== null) {
|
|
764
|
+
candidates += 1;
|
|
765
|
+
if (candidates > MAX_PROJECTED_URL_CANDIDATES || match[0].length > MAX_PROJECTED_URL_LENGTH) {
|
|
766
|
+
return { text: URL_DENSE_REDACTION, count: 1 };
|
|
767
|
+
}
|
|
768
|
+
let urlText = match[0];
|
|
769
|
+
while (/[),.!?;:]$/.test(urlText)) urlText = urlText.slice(0, -1);
|
|
770
|
+
try {
|
|
771
|
+
const normalized = new URL(urlText).href;
|
|
772
|
+
const sanitized = sanitizeArtifactUrlWithCount(urlText);
|
|
773
|
+
if (sanitized.text === normalized) continue;
|
|
774
|
+
const projectedStart = match.index ?? 0;
|
|
775
|
+
const projectedEnd = projectedStart + urlText.length;
|
|
776
|
+
const sourceStart = projectedBoundaryToSource(projected, projectedStart, "start");
|
|
777
|
+
const sourceEnd = projectedBoundaryToSource(projected, projectedEnd, "end");
|
|
778
|
+
if (sourceEnd <= sourceStart) continue;
|
|
779
|
+
replacements.push({ start: sourceStart, end: sourceEnd, text: sanitized.text });
|
|
780
|
+
count += Math.max(1, sanitized.count);
|
|
781
|
+
} catch {
|
|
782
|
+
// A malformed projected candidate is left unchanged for the plain-text redactor.
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return { text: applyTextReplacements(value, replacements), count };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Redact recognizable credentials and report how many credential contexts changed. */
|
|
789
|
+
export function redactSensitiveTextWithCount(value: string): RedactedText {
|
|
790
|
+
const urls = redactProjectedUrls(value);
|
|
791
|
+
const credentials = redactCredentialText(urls.text);
|
|
792
|
+
return { text: credentials.text, count: urls.count + credentials.count };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Redact recognizable credentials without erasing ordinary Basic or Bearer prose. */
|
|
796
|
+
export function redactSensitiveText(value: string): string {
|
|
797
|
+
return redactSensitiveTextWithCount(value).text;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export function sanitizeArtifactUrl(value: string): string {
|
|
801
|
+
return sanitizeArtifactUrlWithCount(value).text;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function sanitizeAssetUrl(value: string): string {
|
|
805
|
+
const url = new URL(sanitizeArtifactUrl(value));
|
|
806
|
+
url.search = "";
|
|
807
|
+
url.hash = "";
|
|
808
|
+
return url.href;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function stripEvidenceReferenceControls(value: string): string {
|
|
812
|
+
let segmentStart = 0;
|
|
813
|
+
let output: string[] | null = null;
|
|
814
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
815
|
+
const code = value.charCodeAt(index);
|
|
816
|
+
const unsafe = code <= 0x1f
|
|
817
|
+
|| (code >= 0x7f && code <= 0x9f)
|
|
818
|
+
|| code === 0x061c
|
|
819
|
+
|| code === 0x200e
|
|
820
|
+
|| code === 0x200f
|
|
821
|
+
|| (code >= 0x202a && code <= 0x202e)
|
|
822
|
+
|| (code >= 0x2066 && code <= 0x2069);
|
|
823
|
+
if (!unsafe) continue;
|
|
824
|
+
output ??= [];
|
|
825
|
+
output.push(value.slice(segmentStart, index));
|
|
826
|
+
segmentStart = index + 1;
|
|
827
|
+
}
|
|
828
|
+
if (output === null) return value;
|
|
829
|
+
output.push(value.slice(segmentStart));
|
|
830
|
+
return output.join("");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function sanitizeEvidenceReference(value: string): string | null {
|
|
834
|
+
if (value.length > 16 * 1024) return null;
|
|
835
|
+
const trimmed = stripEvidenceReferenceControls(value.trim());
|
|
836
|
+
if (trimmed === "" || /^(?:data|javascript|vbscript|file|blob):/i.test(trimmed)) return null;
|
|
837
|
+
try {
|
|
838
|
+
const base = new URL("https://capture.invalid/");
|
|
839
|
+
const parsed = new URL(trimmed, base);
|
|
840
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
841
|
+
const sanitized = new URL(sanitizeArtifactUrl(parsed.href));
|
|
842
|
+
sanitized.search = "";
|
|
843
|
+
sanitized.hash = "";
|
|
844
|
+
if (sanitized.origin !== base.origin) return sanitized.href;
|
|
845
|
+
return sanitized.pathname;
|
|
846
|
+
} catch {
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function escapeAttribute(value: string): string {
|
|
852
|
+
return value
|
|
853
|
+
.replace(/&/g, "&")
|
|
854
|
+
.replace(/"/g, """)
|
|
855
|
+
.replace(/</g, "<")
|
|
856
|
+
.replace(/>/g, ">");
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const removedContentElements = new Set([
|
|
860
|
+
"applet",
|
|
861
|
+
"audio",
|
|
862
|
+
"button",
|
|
863
|
+
"datalist",
|
|
864
|
+
"embed",
|
|
865
|
+
"form",
|
|
866
|
+
"frame",
|
|
867
|
+
"frameset",
|
|
868
|
+
"iframe",
|
|
869
|
+
"math",
|
|
870
|
+
"noscript",
|
|
871
|
+
"object",
|
|
872
|
+
"plaintext",
|
|
873
|
+
"script",
|
|
874
|
+
"select",
|
|
875
|
+
"style",
|
|
876
|
+
"svg",
|
|
877
|
+
"template",
|
|
878
|
+
"textarea",
|
|
879
|
+
"video",
|
|
880
|
+
"xmp",
|
|
881
|
+
]);
|
|
882
|
+
const removedVoidElements = new Set(["base", "input", "link", "meta", "param", "source", "track"]);
|
|
883
|
+
const documentElements = new Set(["body", "head", "html"]);
|
|
884
|
+
const safeElements = new Set([
|
|
885
|
+
"a", "abbr", "address", "article", "aside", "b", "bdi", "bdo", "blockquote", "br", "caption",
|
|
886
|
+
"cite", "code", "col", "colgroup", "dd", "del", "details", "dfn", "div", "dl", "dt", "em",
|
|
887
|
+
"figcaption", "figure", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr",
|
|
888
|
+
"i", "img", "ins", "kbd", "li", "main", "mark", "nav", "ol", "p", "picture", "pre", "q", "rp",
|
|
889
|
+
"rt", "ruby", "s", "samp", "section", "small", "span", "strong", "sub", "summary", "sup", "table",
|
|
890
|
+
"tbody", "td", "tfoot", "th", "thead", "time", "title", "tr", "u", "ul", "var", "wbr",
|
|
891
|
+
]);
|
|
892
|
+
const voidElements = new Set(["br", "col", "hr", "img", "wbr"]);
|
|
893
|
+
const safeAttributes = new Set([
|
|
894
|
+
"alt", "class", "colspan", "datetime", "dir", "height", "headers", "id", "lang", "open", "role",
|
|
895
|
+
"rowspan", "scope", "span", "start", "title", "width",
|
|
896
|
+
]);
|
|
897
|
+
const inertUrlAttributes = new Set(["cite", "href", "poster", "src"]);
|
|
898
|
+
|
|
899
|
+
function findTagEnd(html: string, start: number): number {
|
|
900
|
+
let quote: "\"" | "'" | null = null;
|
|
901
|
+
for (let cursor = start + 1; cursor < html.length; cursor += 1) {
|
|
902
|
+
const character = html[cursor];
|
|
903
|
+
if (quote !== null) {
|
|
904
|
+
if (character === quote) quote = null;
|
|
905
|
+
} else if (character === "\"" || character === "'") {
|
|
906
|
+
quote = character;
|
|
907
|
+
} else if (character === ">") {
|
|
908
|
+
return cursor;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return -1;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function findRemovedElementEnd(html: string, start: number, tagName: string): number {
|
|
915
|
+
const closing = new RegExp(`<\\/\\s*${tagName}\\b`, "gi");
|
|
916
|
+
closing.lastIndex = start;
|
|
917
|
+
const match = closing.exec(html);
|
|
918
|
+
if (match === null) return html.length;
|
|
919
|
+
const end = findTagEnd(html, match.index);
|
|
920
|
+
return end === -1 ? html.length : end + 1;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
type ParsedAttribute = { readonly name: string; readonly value: string | null };
|
|
924
|
+
|
|
925
|
+
const MAX_EVIDENCE_TAG_LENGTH = 64 * 1024;
|
|
926
|
+
const MAX_EVIDENCE_ATTRIBUTES_PER_TAG = 512;
|
|
927
|
+
const MAX_EVIDENCE_STRUCTURAL_TOKENS = 50_000;
|
|
928
|
+
const EVIDENCE_LIMIT_MARKER = "<p>[Source evidence omitted after a structural safety limit.]</p>";
|
|
929
|
+
|
|
930
|
+
function parseAttributes(tag: string, start: number): readonly ParsedAttribute[] {
|
|
931
|
+
const attributes: ParsedAttribute[] = [];
|
|
932
|
+
let cursor = start;
|
|
933
|
+
while (cursor < tag.length && attributes.length < MAX_EVIDENCE_ATTRIBUTES_PER_TAG) {
|
|
934
|
+
while (/\s/.test(tag[cursor] ?? "")) cursor += 1;
|
|
935
|
+
if (cursor >= tag.length || tag[cursor] === "/" || tag[cursor] === ">") break;
|
|
936
|
+
const nameStart = cursor;
|
|
937
|
+
while (cursor < tag.length && !/[\s"'<>/=]/.test(tag[cursor] ?? "")) cursor += 1;
|
|
938
|
+
if (cursor === nameStart) {
|
|
939
|
+
cursor += 1;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const name = tag.slice(nameStart, cursor).toLowerCase();
|
|
943
|
+
while (/\s/.test(tag[cursor] ?? "")) cursor += 1;
|
|
944
|
+
if (tag[cursor] !== "=") {
|
|
945
|
+
attributes.push({ name, value: null });
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
cursor += 1;
|
|
949
|
+
while (/\s/.test(tag[cursor] ?? "")) cursor += 1;
|
|
950
|
+
const quote = tag[cursor];
|
|
951
|
+
if (quote === "\"" || quote === "'") {
|
|
952
|
+
cursor += 1;
|
|
953
|
+
const valueStart = cursor;
|
|
954
|
+
while (cursor < tag.length && tag[cursor] !== quote) cursor += 1;
|
|
955
|
+
attributes.push({ name, value: tag.slice(valueStart, cursor) });
|
|
956
|
+
if (tag[cursor] === quote) cursor += 1;
|
|
957
|
+
} else {
|
|
958
|
+
const valueStart = cursor;
|
|
959
|
+
while (cursor < tag.length && !/[\s>]/.test(tag[cursor] ?? "")) cursor += 1;
|
|
960
|
+
attributes.push({ name, value: tag.slice(valueStart, cursor) });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return attributes;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function sanitizedAttributes(tag: string, nameEnd: number): string {
|
|
967
|
+
const output: string[] = [];
|
|
968
|
+
for (const attribute of parseAttributes(tag, nameEnd)) {
|
|
969
|
+
if (attribute.name.startsWith("on") || attribute.name === "style" || secretKeyPattern.test(attribute.name)) continue;
|
|
970
|
+
if (inertUrlAttributes.has(attribute.name)) {
|
|
971
|
+
if (attribute.value === null) continue;
|
|
972
|
+
const reference = sanitizeEvidenceReference(attribute.value);
|
|
973
|
+
if (reference !== null) {
|
|
974
|
+
output.push(`data-captured-${attribute.name}="${escapeAttribute(reference)}"`);
|
|
975
|
+
}
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (!safeAttributes.has(attribute.name) && !attribute.name.startsWith("aria-")) continue;
|
|
979
|
+
if (attribute.value === null) {
|
|
980
|
+
output.push(attribute.name);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
output.push(`${attribute.name}="${escapeAttribute(redactSensitiveText(attribute.value))}"`);
|
|
984
|
+
}
|
|
985
|
+
return output.length === 0 ? "" : ` ${output.join(" ")}`;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Produce inert, offline evidence. Active subtrees and form state are removed,
|
|
990
|
+
* resource URLs are converted to non-loading annotations, and recognizable
|
|
991
|
+
* credentials are redacted before a restrictive CSP wrapper is added.
|
|
992
|
+
*/
|
|
993
|
+
export function sanitizeSourceHtml(html: string): string {
|
|
994
|
+
const output: string[] = [];
|
|
995
|
+
let cursor = 0;
|
|
996
|
+
let structuralTokens = 0;
|
|
997
|
+
while (cursor < html.length) {
|
|
998
|
+
const opening = html.indexOf("<", cursor);
|
|
999
|
+
if (opening === -1) {
|
|
1000
|
+
output.push(redactSensitiveText(html.slice(cursor)));
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
output.push(redactSensitiveText(html.slice(cursor, opening)));
|
|
1004
|
+
structuralTokens += 1;
|
|
1005
|
+
if (structuralTokens > MAX_EVIDENCE_STRUCTURAL_TOKENS) {
|
|
1006
|
+
output.push(EVIDENCE_LIMIT_MARKER);
|
|
1007
|
+
break;
|
|
1008
|
+
}
|
|
1009
|
+
if (html.startsWith("<!--", opening)) {
|
|
1010
|
+
const commentEnd = html.indexOf("-->", opening + 4);
|
|
1011
|
+
cursor = commentEnd === -1 ? html.length : commentEnd + 3;
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const tagEnd = findTagEnd(html, opening);
|
|
1015
|
+
if (tagEnd === -1) {
|
|
1016
|
+
if (html.length - opening > MAX_EVIDENCE_TAG_LENGTH) output.push(EVIDENCE_LIMIT_MARKER);
|
|
1017
|
+
else output.push("<", redactSensitiveText(html.slice(opening + 1)));
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
if (tagEnd - opening + 1 > MAX_EVIDENCE_TAG_LENGTH) {
|
|
1021
|
+
output.push(EVIDENCE_LIMIT_MARKER);
|
|
1022
|
+
cursor = tagEnd + 1;
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
const tag = html.slice(opening, tagEnd + 1);
|
|
1026
|
+
const nameMatch = /^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9:-]*)/.exec(tag);
|
|
1027
|
+
if (nameMatch === null) {
|
|
1028
|
+
cursor = tagEnd + 1;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
const closing = nameMatch[1] === "/";
|
|
1032
|
+
const name = (nameMatch[2] ?? "").toLowerCase();
|
|
1033
|
+
const nameEnd = nameMatch[0].length;
|
|
1034
|
+
if (removedContentElements.has(name)) {
|
|
1035
|
+
cursor = closing || /\/\s*>$/.test(tag)
|
|
1036
|
+
? tagEnd + 1
|
|
1037
|
+
: findRemovedElementEnd(html, tagEnd + 1, name);
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (removedVoidElements.has(name) || documentElements.has(name) || !safeElements.has(name)) {
|
|
1041
|
+
cursor = tagEnd + 1;
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (closing) {
|
|
1045
|
+
if (!voidElements.has(name)) output.push(`</${name}>`);
|
|
1046
|
+
} else {
|
|
1047
|
+
output.push(`<${name}${sanitizedAttributes(tag, nameEnd)}>`);
|
|
1048
|
+
}
|
|
1049
|
+
cursor = tagEnd + 1;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const csp = "default-src 'none'; img-src 'none'; media-src 'none'; style-src 'none'; script-src 'none'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
1053
|
+
return ensureTrailingNewline(sanitizeTerminalText(
|
|
1054
|
+
`<!doctype html>\n<html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><meta name="referrer" content="no-referrer"></head><body><main data-captured-source="true">${output.join("")}</main></body></html>`,
|
|
1055
|
+
));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function requireManifestToken(value: string, label: string): string {
|
|
1059
|
+
if (!safeManifestTokenPattern.test(value)) throw new Error(`${label} is not a safe manifest token`);
|
|
1060
|
+
return value;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function requireFiniteNumber(value: number, label: string): number {
|
|
1064
|
+
if (!Number.isFinite(value)) throw new Error(`${label} must be a finite number`);
|
|
1065
|
+
return value;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function requireNonNegativeInteger(value: number, label: string): number {
|
|
1069
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a non-negative safe integer`);
|
|
1070
|
+
return value;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function optionalManifestText(
|
|
1074
|
+
value: string | undefined,
|
|
1075
|
+
maximum: number,
|
|
1076
|
+
label: string,
|
|
1077
|
+
): string | undefined {
|
|
1078
|
+
if (value === undefined) return undefined;
|
|
1079
|
+
if (maximum < 1) throw new Error(`${label} has an invalid bound`);
|
|
1080
|
+
const sanitized = sanitizeTerminalText(redactSensitiveText(value)).trim();
|
|
1081
|
+
if (sanitized === "") return undefined;
|
|
1082
|
+
if (sanitized.length > maximum) return `${sanitized.slice(0, Math.max(0, maximum - 1))}…`;
|
|
1083
|
+
return sanitized;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function normalizeVideoContextMetadata(
|
|
1087
|
+
value: VideoContextManifestMetadata | null,
|
|
1088
|
+
): VideoContextManifestMetadata | null {
|
|
1089
|
+
if (value === null) return null;
|
|
1090
|
+
const id = optionalManifestText(value.id, 512, "video id");
|
|
1091
|
+
const title = optionalManifestText(value.title, 2_048, "video title");
|
|
1092
|
+
const description = optionalManifestText(value.description, 8_192, "video description");
|
|
1093
|
+
const uploader = optionalManifestText(value.uploader, 1_024, "video uploader");
|
|
1094
|
+
const uploaderId = optionalManifestText(value.uploaderId, 512, "video uploader id");
|
|
1095
|
+
const channel = optionalManifestText(value.channel, 1_024, "video channel");
|
|
1096
|
+
const channelId = optionalManifestText(value.channelId, 512, "video channel id");
|
|
1097
|
+
const extractor = optionalManifestText(value.extractor, 512, "video extractor");
|
|
1098
|
+
const durationSeconds = value.durationSeconds === undefined
|
|
1099
|
+
? undefined
|
|
1100
|
+
: requireFiniteNumber(value.durationSeconds, "artifacts.videoContext.metadata.durationSeconds");
|
|
1101
|
+
if (durationSeconds !== undefined && durationSeconds < 0) {
|
|
1102
|
+
throw new Error("artifacts.videoContext.metadata.durationSeconds must not be negative");
|
|
1103
|
+
}
|
|
1104
|
+
const timestamp = value.timestamp === undefined
|
|
1105
|
+
? undefined
|
|
1106
|
+
: requireFiniteNumber(value.timestamp, "artifacts.videoContext.metadata.timestamp");
|
|
1107
|
+
return {
|
|
1108
|
+
...(id === undefined ? {} : { id }),
|
|
1109
|
+
...(title === undefined ? {} : { title }),
|
|
1110
|
+
...(description === undefined ? {} : { description }),
|
|
1111
|
+
...(uploader === undefined ? {} : { uploader }),
|
|
1112
|
+
...(uploaderId === undefined ? {} : { uploaderId }),
|
|
1113
|
+
...(channel === undefined ? {} : { channel }),
|
|
1114
|
+
...(channelId === undefined ? {} : { channelId }),
|
|
1115
|
+
...(value.webpageUrl === undefined
|
|
1116
|
+
? {}
|
|
1117
|
+
: { webpageUrl: sanitizeArtifactUrl(value.webpageUrl) }),
|
|
1118
|
+
...(extractor === undefined ? {} : { extractor }),
|
|
1119
|
+
...(durationSeconds === undefined ? {} : { durationSeconds }),
|
|
1120
|
+
...(timestamp === undefined ? {} : { timestamp }),
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function sanitizeArtifactPath(value: string, label: string): string {
|
|
1125
|
+
if (
|
|
1126
|
+
value === ""
|
|
1127
|
+
|| value.includes("\\")
|
|
1128
|
+
|| value.includes("\0")
|
|
1129
|
+
|| isAbsolute(value)
|
|
1130
|
+
|| value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
1131
|
+
) {
|
|
1132
|
+
throw new Error(`${label} must be a confined relative artifact path`);
|
|
1133
|
+
}
|
|
1134
|
+
return value;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function sanitizeContentType(value: string | null): string | null {
|
|
1138
|
+
if (value === null) return null;
|
|
1139
|
+
const [rawMimeType, ...parameters] = value.split(";");
|
|
1140
|
+
const mimeType = rawMimeType?.trim().toLowerCase();
|
|
1141
|
+
if (mimeType === undefined || !safeArtifactMimePattern.test(mimeType)) return null;
|
|
1142
|
+
const charset = parameters
|
|
1143
|
+
.map((parameter) => /^\s*charset\s*=\s*["']?([a-z0-9._-]+)["']?\s*$/i.exec(parameter)?.[1])
|
|
1144
|
+
.find((candidate) => candidate !== undefined);
|
|
1145
|
+
return charset === undefined ? mimeType : `${mimeType}; charset=${charset.toLowerCase()}`;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function requireListedValue<const T extends string>(
|
|
1149
|
+
value: string,
|
|
1150
|
+
allowed: readonly T[],
|
|
1151
|
+
label: string,
|
|
1152
|
+
): T {
|
|
1153
|
+
const match = allowed.find((candidate) => candidate === value);
|
|
1154
|
+
if (match === undefined) throw new Error(`${label} is invalid`);
|
|
1155
|
+
return match;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function normalizeManifest(
|
|
1159
|
+
input: CaptureManifestInput,
|
|
1160
|
+
hasSourceHtml: boolean,
|
|
1161
|
+
document: CaptureManifest["document"],
|
|
1162
|
+
): CaptureManifest {
|
|
1163
|
+
const capturedAtTime = Date.parse(input.capturedAt);
|
|
1164
|
+
if (!Number.isFinite(capturedAtTime)) throw new Error("capturedAt must be an ISO-compatible timestamp");
|
|
1165
|
+
const assets = input.assets.map((asset, index): CaptureManifestAsset => {
|
|
1166
|
+
if (!safeArtifactMimePattern.test(asset.mimeType)) throw new Error(`assets[${index}].mimeType is invalid`);
|
|
1167
|
+
if (!/^[a-f0-9]{64}$/i.test(asset.sha256)) throw new Error(`assets[${index}].sha256 must be a SHA-256 digest`);
|
|
1168
|
+
return {
|
|
1169
|
+
source: (() => {
|
|
1170
|
+
try {
|
|
1171
|
+
return sanitizeAssetUrl(asset.source);
|
|
1172
|
+
} catch {
|
|
1173
|
+
return sanitizeEvidenceReference(asset.source) ?? redactSensitiveText(asset.source);
|
|
1174
|
+
}
|
|
1175
|
+
})(),
|
|
1176
|
+
url: sanitizeAssetUrl(asset.url),
|
|
1177
|
+
path: sanitizeArtifactPath(asset.path, `assets[${index}].path`),
|
|
1178
|
+
mimeType: asset.mimeType.toLowerCase(),
|
|
1179
|
+
bytes: requireNonNegativeInteger(asset.bytes, `assets[${index}].bytes`),
|
|
1180
|
+
sha256: asset.sha256.toLowerCase(),
|
|
1181
|
+
};
|
|
1182
|
+
}).sort((left, right) => left.path.localeCompare(right.path));
|
|
1183
|
+
const screenshotPath = input.evidence.screenshotPath === null
|
|
1184
|
+
? null
|
|
1185
|
+
: sanitizeArtifactPath(input.evidence.screenshotPath, "evidence.screenshotPath");
|
|
1186
|
+
if (input.artifacts.images.requested === (input.artifacts.images.status === "not-requested")) {
|
|
1187
|
+
throw new Error("artifacts.images requested/status fields disagree");
|
|
1188
|
+
}
|
|
1189
|
+
if (input.artifacts.media.requested === (input.artifacts.media.status === "not-requested")) {
|
|
1190
|
+
throw new Error("artifacts.media requested/status fields disagree");
|
|
1191
|
+
}
|
|
1192
|
+
if (input.artifacts.videoContext.requested === (input.artifacts.videoContext.status === "not-requested")) {
|
|
1193
|
+
throw new Error("artifacts.videoContext requested/status fields disagree");
|
|
1194
|
+
}
|
|
1195
|
+
if (!input.artifacts.images.requested && input.artifacts.images.files !== 0) {
|
|
1196
|
+
throw new Error("artifacts.images cannot record files when not requested");
|
|
1197
|
+
}
|
|
1198
|
+
if (!input.artifacts.media.requested && input.artifacts.media.files !== 0) {
|
|
1199
|
+
throw new Error("artifacts.media cannot record files when not requested");
|
|
1200
|
+
}
|
|
1201
|
+
if (
|
|
1202
|
+
!input.artifacts.videoContext.requested
|
|
1203
|
+
&& (
|
|
1204
|
+
input.artifacts.videoContext.thumbnailPath !== null
|
|
1205
|
+
|| input.artifacts.videoContext.transcriptLanguage !== null
|
|
1206
|
+
|| input.artifacts.videoContext.transcriptCueCount !== 0
|
|
1207
|
+
|| input.artifacts.videoContext.transcriptTruncated
|
|
1208
|
+
|| input.artifacts.videoContext.metadata !== null
|
|
1209
|
+
)
|
|
1210
|
+
) {
|
|
1211
|
+
throw new Error("artifacts.videoContext cannot record context when not requested");
|
|
1212
|
+
}
|
|
1213
|
+
const videoThumbnailPath = input.artifacts.videoContext.thumbnailPath === null
|
|
1214
|
+
? null
|
|
1215
|
+
: sanitizeArtifactPath(input.artifacts.videoContext.thumbnailPath, "artifacts.videoContext.thumbnailPath");
|
|
1216
|
+
if (
|
|
1217
|
+
videoThumbnailPath !== null
|
|
1218
|
+
&& !assets.some((asset) => asset.path === videoThumbnailPath && asset.mimeType.startsWith("image/"))
|
|
1219
|
+
) {
|
|
1220
|
+
throw new Error("artifacts.videoContext.thumbnailPath must reference a captured image asset");
|
|
1221
|
+
}
|
|
1222
|
+
const transcriptLanguage = input.artifacts.videoContext.transcriptLanguage === null
|
|
1223
|
+
? null
|
|
1224
|
+
: requireManifestToken(input.artifacts.videoContext.transcriptLanguage, "artifacts.videoContext.transcriptLanguage");
|
|
1225
|
+
if (
|
|
1226
|
+
transcriptLanguage === null
|
|
1227
|
+
&& (
|
|
1228
|
+
input.artifacts.videoContext.transcriptCueCount !== 0
|
|
1229
|
+
|| input.artifacts.videoContext.transcriptTruncated
|
|
1230
|
+
)
|
|
1231
|
+
) {
|
|
1232
|
+
throw new Error("artifacts.videoContext transcript fields disagree");
|
|
1233
|
+
}
|
|
1234
|
+
const screenshotRequested = input.evidence.requested === "screenshot" || input.evidence.requested === "all";
|
|
1235
|
+
const sourceRequested = input.evidence.requested === "source" || input.evidence.requested === "all";
|
|
1236
|
+
if (screenshotRequested === (input.evidence.screenshotStatus === "not-requested")) {
|
|
1237
|
+
throw new Error("evidence screenshot requested/status fields disagree");
|
|
1238
|
+
}
|
|
1239
|
+
if ((input.evidence.screenshotStatus === "captured") !== (screenshotPath !== null)) {
|
|
1240
|
+
throw new Error("evidence screenshot status/path fields disagree");
|
|
1241
|
+
}
|
|
1242
|
+
if (sourceRequested === (input.evidence.sourceHtmlStatus === "not-requested")) {
|
|
1243
|
+
throw new Error("evidence source requested/status fields disagree");
|
|
1244
|
+
}
|
|
1245
|
+
if ((input.evidence.sourceHtmlStatus === "captured") !== hasSourceHtml) {
|
|
1246
|
+
throw new Error("evidence source status/file fields disagree");
|
|
1247
|
+
}
|
|
1248
|
+
return {
|
|
1249
|
+
schemaVersion: CAPTURE_MANIFEST_SCHEMA_VERSION,
|
|
1250
|
+
document,
|
|
1251
|
+
sourceUrl: sanitizeArtifactUrl(input.sourceUrl),
|
|
1252
|
+
canonicalUrl: sanitizeArtifactUrl(input.canonicalUrl),
|
|
1253
|
+
capturedAt: new Date(capturedAtTime).toISOString(),
|
|
1254
|
+
platform: requireManifestToken(input.platform, "platform"),
|
|
1255
|
+
status: requireManifestToken(input.status, "status"),
|
|
1256
|
+
scope: requireManifestToken(input.scope, "scope"),
|
|
1257
|
+
acquisition: {
|
|
1258
|
+
method: requireManifestToken(input.acquisition.method, "acquisition.method"),
|
|
1259
|
+
finalUrl: sanitizeArtifactUrl(input.acquisition.finalUrl),
|
|
1260
|
+
contentType: sanitizeContentType(input.acquisition.contentType),
|
|
1261
|
+
},
|
|
1262
|
+
extraction: {
|
|
1263
|
+
extractor: redactSensitiveText(input.extraction.extractor),
|
|
1264
|
+
score: requireFiniteNumber(input.extraction.score, "extraction.score"),
|
|
1265
|
+
wordCount: requireNonNegativeInteger(input.extraction.wordCount, "extraction.wordCount"),
|
|
1266
|
+
capturedItems: requireNonNegativeInteger(input.extraction.capturedItems, "extraction.capturedItems"),
|
|
1267
|
+
expectedItems: input.extraction.expectedItems === null
|
|
1268
|
+
? null
|
|
1269
|
+
: requireNonNegativeInteger(input.extraction.expectedItems, "extraction.expectedItems"),
|
|
1270
|
+
},
|
|
1271
|
+
attempts: input.attempts.map((attempt) => ({
|
|
1272
|
+
method: requireManifestToken(attempt.method, "attempts.method"),
|
|
1273
|
+
outcome: requireManifestToken(attempt.outcome, "attempts.outcome"),
|
|
1274
|
+
message: redactSensitiveText(attempt.message).replace(/[\r\n]+/g, " ").slice(0, 1_000),
|
|
1275
|
+
})),
|
|
1276
|
+
assets,
|
|
1277
|
+
artifacts: {
|
|
1278
|
+
images: {
|
|
1279
|
+
requested: input.artifacts.images.requested,
|
|
1280
|
+
status: requireListedValue(input.artifacts.images.status, ["not-requested", "captured", "partial"] as const, "artifacts.images.status"),
|
|
1281
|
+
files: requireNonNegativeInteger(input.artifacts.images.files, "artifacts.images.files"),
|
|
1282
|
+
},
|
|
1283
|
+
media: {
|
|
1284
|
+
requested: input.artifacts.media.requested,
|
|
1285
|
+
status: requireListedValue(
|
|
1286
|
+
input.artifacts.media.status,
|
|
1287
|
+
["not-requested", "captured", "partial", "unavailable", "unsupported", "failed"] as const,
|
|
1288
|
+
"artifacts.media.status",
|
|
1289
|
+
),
|
|
1290
|
+
files: requireNonNegativeInteger(input.artifacts.media.files, "artifacts.media.files"),
|
|
1291
|
+
},
|
|
1292
|
+
videoContext: {
|
|
1293
|
+
requested: input.artifacts.videoContext.requested,
|
|
1294
|
+
status: requireListedValue(
|
|
1295
|
+
input.artifacts.videoContext.status,
|
|
1296
|
+
["not-requested", "captured", "partial", "unavailable", "unsupported", "failed"] as const,
|
|
1297
|
+
"artifacts.videoContext.status",
|
|
1298
|
+
),
|
|
1299
|
+
thumbnailPath: videoThumbnailPath,
|
|
1300
|
+
transcriptLanguage,
|
|
1301
|
+
transcriptCueCount: requireNonNegativeInteger(
|
|
1302
|
+
input.artifacts.videoContext.transcriptCueCount,
|
|
1303
|
+
"artifacts.videoContext.transcriptCueCount",
|
|
1304
|
+
),
|
|
1305
|
+
transcriptTruncated: input.artifacts.videoContext.transcriptTruncated,
|
|
1306
|
+
metadata: normalizeVideoContextMetadata(input.artifacts.videoContext.metadata),
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
evidence: {
|
|
1310
|
+
requested: requireListedValue(input.evidence.requested, ["none", "source", "screenshot", "all"] as const, "evidence.requested"),
|
|
1311
|
+
screenshotPath,
|
|
1312
|
+
screenshotStatus: requireListedValue(
|
|
1313
|
+
input.evidence.screenshotStatus,
|
|
1314
|
+
["not-requested", "captured", "unavailable"] as const,
|
|
1315
|
+
"evidence.screenshotStatus",
|
|
1316
|
+
),
|
|
1317
|
+
sourceHtmlStatus: requireListedValue(
|
|
1318
|
+
input.evidence.sourceHtmlStatus,
|
|
1319
|
+
["not-requested", "captured", "unavailable"] as const,
|
|
1320
|
+
"evidence.sourceHtmlStatus",
|
|
1321
|
+
),
|
|
1322
|
+
sourceHtmlPath: hasSourceHtml ? CAPTURE_SOURCE_EVIDENCE_PATH : null,
|
|
1323
|
+
},
|
|
1324
|
+
warnings: input.warnings.map(redactSensitiveText),
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function removeOwnedStaging(transaction: CaptureBundleTransaction): void {
|
|
1329
|
+
assertConfinedChild(transaction.outputRoot, transaction.stagingDirectory, "capture staging directory");
|
|
1330
|
+
if (existsSync(transaction.stagingDirectory)) {
|
|
1331
|
+
rmSync(transaction.stagingDirectory, { recursive: true, force: true });
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Write the bundle-owned files into staging; callers may stage assets first. */
|
|
1336
|
+
export function writeCaptureBundle(transaction: CaptureBundleTransaction, input: CaptureBundleInput): CaptureManifest {
|
|
1337
|
+
requireState(transaction, "open");
|
|
1338
|
+
try {
|
|
1339
|
+
const markdown = ensureTrailingNewline(redactSensitiveText(input.markdown));
|
|
1340
|
+
const markdownBytes = Buffer.from(markdown, "utf8");
|
|
1341
|
+
const document: CaptureManifest["document"] = {
|
|
1342
|
+
path: captureMarkdownFilename(transaction.slug),
|
|
1343
|
+
bytes: markdownBytes.byteLength,
|
|
1344
|
+
sha256: createHash("sha256").update(markdownBytes).digest("hex"),
|
|
1345
|
+
};
|
|
1346
|
+
const manifest = normalizeManifest(input.manifest, input.sourceHtml !== undefined, document);
|
|
1347
|
+
writeFileSync(
|
|
1348
|
+
join(transaction.stagingDirectory, document.path),
|
|
1349
|
+
markdownBytes,
|
|
1350
|
+
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
|
1351
|
+
);
|
|
1352
|
+
writeFileSync(
|
|
1353
|
+
join(transaction.stagingDirectory, CAPTURE_MANIFEST_FILENAME),
|
|
1354
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
1355
|
+
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
|
1356
|
+
);
|
|
1357
|
+
if (input.sourceHtml !== undefined) {
|
|
1358
|
+
const evidenceDirectory = join(transaction.stagingDirectory, dirname(CAPTURE_SOURCE_EVIDENCE_PATH));
|
|
1359
|
+
mkdirSync(evidenceDirectory, { recursive: true, mode: 0o700 });
|
|
1360
|
+
chmodSync(evidenceDirectory, 0o700);
|
|
1361
|
+
const evidencePath = join(transaction.stagingDirectory, CAPTURE_SOURCE_EVIDENCE_PATH);
|
|
1362
|
+
writeFileSync(evidencePath, sanitizeSourceHtml(input.sourceHtml), {
|
|
1363
|
+
encoding: "utf8",
|
|
1364
|
+
flag: "wx",
|
|
1365
|
+
mode: 0o600,
|
|
1366
|
+
});
|
|
1367
|
+
chmodSync(evidencePath, 0o600);
|
|
1368
|
+
}
|
|
1369
|
+
transaction[transactionState] = "written";
|
|
1370
|
+
return manifest;
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
removeOwnedStaging(transaction);
|
|
1373
|
+
transaction[transactionState] = "aborted";
|
|
1374
|
+
throw error;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function assertStagingTreeSafe(root: string, directory = root): void {
|
|
1379
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1380
|
+
const path = join(directory, entry.name);
|
|
1381
|
+
assertConfinedChild(root, path, "staged artifact");
|
|
1382
|
+
const stats = lstatSync(path);
|
|
1383
|
+
if (stats.isSymbolicLink()) throw new Error(`staged artifact must not be a symbolic link: ${path}`);
|
|
1384
|
+
if (stats.isDirectory()) assertStagingTreeSafe(root, path);
|
|
1385
|
+
else if (!stats.isFile()) throw new Error(`staged artifact must be a regular file: ${path}`);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function unusedBackupPath(outputRoot: string): string {
|
|
1390
|
+
for (;;) {
|
|
1391
|
+
const candidate = join(outputRoot, `.capture-backup-${crypto.randomUUID()}`);
|
|
1392
|
+
assertConfinedChild(outputRoot, candidate, "capture backup");
|
|
1393
|
+
if (!existsSync(candidate)) return candidate;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function errorMessage(error: unknown): string {
|
|
1398
|
+
return error instanceof Error ? error.message : String(error);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** Atomically install staging, restoring a forced-replacement target on failure. */
|
|
1402
|
+
export function commitCaptureBundle(
|
|
1403
|
+
transaction: CaptureBundleTransaction,
|
|
1404
|
+
hooks: CaptureCommitHooks = {},
|
|
1405
|
+
): string {
|
|
1406
|
+
requireState(transaction, "written");
|
|
1407
|
+
assertStagingTreeSafe(transaction.stagingDirectory);
|
|
1408
|
+
const targetExists = existsSync(transaction.targetDirectory);
|
|
1409
|
+
if (targetExists && !transaction.force) {
|
|
1410
|
+
throw new Error(`capture already exists: ${transaction.targetDirectory}; pass --force to replace it`);
|
|
1411
|
+
}
|
|
1412
|
+
if (targetExists !== (transaction.expectedTargetIdentity !== null)) {
|
|
1413
|
+
throw new Error(`capture target changed during the transaction: ${transaction.targetDirectory}`);
|
|
1414
|
+
}
|
|
1415
|
+
if (targetExists) {
|
|
1416
|
+
const currentIdentity = ownedTargetIdentity(transaction.targetDirectory, transaction.slug);
|
|
1417
|
+
if (
|
|
1418
|
+
currentIdentity.device !== transaction.expectedTargetIdentity?.device
|
|
1419
|
+
|| currentIdentity.inode !== transaction.expectedTargetIdentity.inode
|
|
1420
|
+
) {
|
|
1421
|
+
throw new Error(`capture target changed during the transaction: ${transaction.targetDirectory}`);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const backupDirectory = targetExists ? unusedBackupPath(transaction.outputRoot) : null;
|
|
1426
|
+
let installed = false;
|
|
1427
|
+
let backedUp = false;
|
|
1428
|
+
try {
|
|
1429
|
+
if (backupDirectory !== null) {
|
|
1430
|
+
renameSync(transaction.targetDirectory, backupDirectory);
|
|
1431
|
+
backedUp = true;
|
|
1432
|
+
hooks.afterBackup?.();
|
|
1433
|
+
}
|
|
1434
|
+
renameSync(transaction.stagingDirectory, transaction.targetDirectory);
|
|
1435
|
+
installed = true;
|
|
1436
|
+
hooks.afterInstall?.();
|
|
1437
|
+
transaction[transactionState] = "committed";
|
|
1438
|
+
if (backupDirectory !== null) rmSync(backupDirectory, { recursive: true, force: true });
|
|
1439
|
+
return transaction.targetDirectory;
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
let rollbackError: unknown;
|
|
1442
|
+
try {
|
|
1443
|
+
if (installed && existsSync(transaction.targetDirectory)) {
|
|
1444
|
+
rmSync(transaction.targetDirectory, { recursive: true, force: true });
|
|
1445
|
+
}
|
|
1446
|
+
if (backedUp && backupDirectory !== null && existsSync(backupDirectory)) {
|
|
1447
|
+
if (existsSync(transaction.targetDirectory)) {
|
|
1448
|
+
throw new Error(`cannot restore backup because target was recreated: ${transaction.targetDirectory}`);
|
|
1449
|
+
}
|
|
1450
|
+
renameSync(backupDirectory, transaction.targetDirectory);
|
|
1451
|
+
}
|
|
1452
|
+
} catch (caught) {
|
|
1453
|
+
rollbackError = caught;
|
|
1454
|
+
}
|
|
1455
|
+
transaction[transactionState] = existsSync(transaction.stagingDirectory) ? "written" : "aborted";
|
|
1456
|
+
if (rollbackError !== undefined) {
|
|
1457
|
+
const recovery = backupDirectory === null ? "none" : backupDirectory;
|
|
1458
|
+
throw new Error(
|
|
1459
|
+
`capture commit failed (${errorMessage(error)}) and rollback failed (${errorMessage(rollbackError)}); recovery backup: ${recovery}`,
|
|
1460
|
+
{ cause: error },
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
throw error;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** Remove only this transaction's uncommitted, randomly named staging tree. */
|
|
1468
|
+
export function abortCaptureBundle(transaction: CaptureBundleTransaction): void {
|
|
1469
|
+
if (transaction[transactionState] === "committed" || transaction[transactionState] === "aborted") return;
|
|
1470
|
+
removeOwnedStaging(transaction);
|
|
1471
|
+
transaction[transactionState] = "aborted";
|
|
1472
|
+
}
|