Checkpoint web parity through Chromium input tasks
This commit is contained in:
119
tools/web/server-job-output.mjs
Normal file
119
tools/web/server-job-output.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
export const SERVER_JOB_OUTPUT_SCHEMA = 1;
|
||||
export const SERVER_JOB_OUTPUT_LIMITS = Object.freeze({
|
||||
stdoutBytes: 64 * 1024,
|
||||
stderrBytes: 64 * 1024,
|
||||
totalBytes: 128 * 1024,
|
||||
});
|
||||
|
||||
const REDACTION = "<redacted>";
|
||||
const PATH_REDACTION = "<internal-path>";
|
||||
const TRUNCATION = "\n<output-truncated>";
|
||||
const CREDENTIAL_KEY = "(?:token|api[_-]?key|secret|password|passwd|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret)";
|
||||
const CREDENTIAL_ASSIGNMENT = /(\b(?:token|api[_-]?key|secret|password|passwd|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*)(["']?)([^\s,;"']+)\2/giu;
|
||||
const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/giu;
|
||||
const BASIC = /\bBasic\s+[A-Za-z0-9+/=]+/giu;
|
||||
const SECRET_HEADER = /(\b(?:authorization|proxy-authorization)\s*:\s*)([^\r\n]+)/giu;
|
||||
const UNIX_PATH = /\/(?:[^\s/\\:*?"<>|]+\/)*[^\s/\\:*?"<>|]+/gu;
|
||||
const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\s\\/:*?"<>|]+\\)*[^\s\\/:*?"<>|]*/gu;
|
||||
const FILE_URL = /\bfile:\/\/[^\s"']+/giu;
|
||||
|
||||
function invalid(message) {
|
||||
throw new Error(`SERVER_JOB_OUTPUT_INVALID: ${message}`);
|
||||
}
|
||||
|
||||
function assertText(value, field) {
|
||||
if (typeof value !== "string") invalid(`${field} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertLimit(value, field) {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > SERVER_JOB_OUTPUT_LIMITS[field]) {
|
||||
invalid(`${field} is outside the fixed output budget`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function redactCredentials(value) {
|
||||
let text = value;
|
||||
let count = 0;
|
||||
const replace = (pattern, replacement) => {
|
||||
text = text.replace(pattern, (...args) => {
|
||||
count += 1;
|
||||
return typeof replacement === "function" ? replacement(...args) : replacement;
|
||||
});
|
||||
};
|
||||
replace(CREDENTIAL_ASSIGNMENT, (_match, prefix, quote) => `${prefix}${quote}${REDACTION}${quote}`);
|
||||
replace(BEARER, `Bearer ${REDACTION}`);
|
||||
replace(BASIC, `Basic ${REDACTION}`);
|
||||
replace(SECRET_HEADER, (_match, prefix) => `${prefix}${REDACTION}`);
|
||||
return { text, count };
|
||||
}
|
||||
|
||||
function redactPaths(value) {
|
||||
let count = 0;
|
||||
let text = value.replace(FILE_URL, () => { count += 1; return PATH_REDACTION; });
|
||||
text = text.replace(WINDOWS_PATH, () => { count += 1; return PATH_REDACTION; });
|
||||
text = text.replace(UNIX_PATH, (match, offset, source) => {
|
||||
// Keep URL paths and protocol markers readable; only redact filesystem-looking paths.
|
||||
const before = source.slice(Math.max(0, offset - 8), offset);
|
||||
if (/https?:$|https?:\/\/$/iu.test(before) || match === "/") return match;
|
||||
count += 1;
|
||||
return PATH_REDACTION;
|
||||
});
|
||||
return { text, count };
|
||||
}
|
||||
|
||||
function truncateUtf8(value, maxBytes) {
|
||||
const source = Buffer.from(value, "utf8");
|
||||
if (source.byteLength <= maxBytes) return { text: value, truncated: false };
|
||||
const marker = Buffer.from(TRUNCATION, "utf8");
|
||||
const available = Math.max(0, maxBytes - marker.byteLength);
|
||||
let end = Math.min(available, source.byteLength);
|
||||
while (end > 0 && (source[end] & 0xc0) === 0x80) end -= 1;
|
||||
return { text: `${source.subarray(0, end).toString("utf8")}${TRUNCATION}`, truncated: true };
|
||||
}
|
||||
|
||||
export function sanitizeServerJobOutput(value, maxBytes) {
|
||||
assertText(value, "output");
|
||||
assertLimit(maxBytes, "stdoutBytes");
|
||||
const originalBytes = Buffer.byteLength(value, "utf8");
|
||||
const credentials = redactCredentials(value);
|
||||
const paths = redactPaths(credentials.text);
|
||||
const truncated = truncateUtf8(paths.text, maxBytes);
|
||||
return Object.freeze({
|
||||
text: truncated.text,
|
||||
originalBytes,
|
||||
emittedBytes: Buffer.byteLength(truncated.text, "utf8"),
|
||||
redactionCount: credentials.count + paths.count,
|
||||
truncated: truncated.truncated,
|
||||
});
|
||||
}
|
||||
|
||||
export function createServerJobOutputReceipt(value, limits = SERVER_JOB_OUTPUT_LIMITS) {
|
||||
if (!value || typeof value !== "object") invalid("receipt input is invalid");
|
||||
if (!limits || typeof limits !== "object") invalid("limits are invalid");
|
||||
const stdoutBytes = assertLimit(limits.stdoutBytes, "stdoutBytes");
|
||||
const stderrBytes = assertLimit(limits.stderrBytes, "stderrBytes");
|
||||
const totalBytes = assertLimit(limits.totalBytes, "totalBytes");
|
||||
if (stdoutBytes + stderrBytes > totalBytes) invalid("stream budgets exceed total budget");
|
||||
const stdout = sanitizeServerJobOutput(value.stdout ?? "", "stdoutBytes" in limits ? stdoutBytes : stdoutBytes);
|
||||
const stderr = sanitizeServerJobOutput(value.stderr ?? "", "stderrBytes" in limits ? stderrBytes : stderrBytes);
|
||||
const totalOriginalBytes = stdout.originalBytes + stderr.originalBytes;
|
||||
const totalEmittedBytes = stdout.emittedBytes + stderr.emittedBytes;
|
||||
const totalTruncated = stdout.truncated || stderr.truncated || totalOriginalBytes > totalBytes;
|
||||
return Object.freeze({
|
||||
schemaVersion: SERVER_JOB_OUTPUT_SCHEMA,
|
||||
stdout,
|
||||
stderr,
|
||||
limits: Object.freeze({ stdoutBytes, stderrBytes, totalBytes }),
|
||||
totalOriginalBytes,
|
||||
totalEmittedBytes,
|
||||
totalRedactions: stdout.redactionCount + stderr.redactionCount,
|
||||
totalTruncated,
|
||||
execution: "DISABLED",
|
||||
});
|
||||
}
|
||||
|
||||
export function redactServerJobOutput(value, maxBytes) {
|
||||
return sanitizeServerJobOutput(value, maxBytes).text;
|
||||
}
|
||||
Reference in New Issue
Block a user