Files
pikasTech-unidesk/scripts/src/gh/auth-and-safety.ts
T
2026-06-26 00:16:53 +08:00

121 lines
5.1 KiB
TypeScript

// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:1557-1667.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readIssueCommentBody } from "./body-input";
import { PREVIEW_CHARS } from "./types";
import type { GitHubOptions, GitHubTokenProbe } from "./types";
export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
if (options.comment === undefined && options.commentFile === undefined) return null;
if (options.comment !== undefined && options.commentFile !== undefined) {
throw new Error(`${command} --comment and --comment-file/--comment-stdin are mutually exclusive`);
}
if (options.body !== undefined || options.bodyFile !== undefined) {
throw new Error(`${command} --comment/--comment-file/--comment-stdin cannot be combined with --body/--body-file/--body-stdin`);
}
if (options.commentFile !== undefined) {
return readIssueCommentBody({ ...options, body: undefined, bodyFile: options.commentFile });
}
return readIssueCommentBody({ ...options, body: options.comment, bodyFile: undefined });
}
export function tokenFromEnvironment(): GitHubTokenProbe {
if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) {
return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false };
}
if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) {
return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false };
}
return { present: false, source: null, ghFallbackAttempted: false };
}
export function ghBinaryPath(): string | null {
try {
const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
return output.length > 0 ? output : null;
} catch {
return null;
}
}
export function ghAuthToken(): string | null {
try {
const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
return output.length > 0 ? output : null;
} catch {
return null;
}
}
export function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } {
const envProbe = tokenFromEnvironment();
if (envProbe.present) {
const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null;
return { token, probe: envProbe };
}
if (!allowGhFallback) return { token: null, probe: envProbe };
const ghPath = ghBinaryPath();
if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
const token = ghAuthToken();
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } };
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
}
export function repoParts(repo: string): { owner: string; name: string } {
const [owner, name, extra] = repo.split("/");
if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format");
return { owner, name };
}
export function preview(text: string): string {
return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text;
}
export function previewLines(text: string, maxLines = 12): string[] {
return text.split(/\r?\n/).slice(0, maxLines).map((line) => preview(line));
}
export function bodySha(text: string): string {
return createHash("sha256").update(text, "utf8").digest("hex");
}
export function binarySha(data: Buffer): string {
return createHash("sha256").update(data).digest("hex");
}
export function normalizeExpectedSha(raw: string): string {
const value = raw.replace(/^sha256:/iu, "").toLowerCase();
if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("--expect-body-sha must be a 64-character SHA-256 hex value, optionally prefixed with sha256:");
return value;
}
export function shellPollutionEvidence(body: string): string[] {
const evidence: string[] = [];
if (body.includes("\\n")) evidence.push("literal-backslash-n");
if (body.includes("\\t")) evidence.push("literal-backslash-t");
if (body.includes("\\`")) evidence.push("escaped-backtick");
if (/\\x1b|\\u001b|\u001b\[/u.test(body)) evidence.push("ansi-escape");
if (/\r(?!\n)/u.test(body)) evidence.push("bare-carriage-return");
return evidence;
}
export function bodySafetySignals(body: string): Record<string, unknown> {
const evidence = shellPollutionEvidence(body);
return {
bodyChars: body.length,
bodyTrimmedChars: body.trim().length,
bodyLines: body.length === 0 ? 0 : body.split(/\r?\n/).length,
bodySha: bodySha(body),
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body),
shellPollution: {
suspected: evidence.length > 0,
evidence,
},
};
}