fix: 保留 apply-patch CRLF 完整性

This commit is contained in:
Codex
2026-07-10 15:03:56 +02:00
parent fe7b0bf290
commit 760a92ee11
5 changed files with 246 additions and 4 deletions
+115 -4
View File
@@ -124,6 +124,8 @@ export interface ApplyPatchV2BulkReplacementWritePlan {
originalSha256: string;
finalBytes: number;
finalSha256: string;
newlineStyle: "lf" | "crlf" | "mixed" | "none";
encodingStyle: "utf8" | "utf8-bom";
replacements: ApplyPatchV2Replacement[];
}
type BulkReplacementWritePlan = ApplyPatchV2BulkReplacementWritePlan;
@@ -665,7 +667,8 @@ async function applyPatchV2UpdateHunksBulk(executor: ApplyPatchV2Executor, hunks
const state = states.get(hunk.path);
if (state === undefined || !state.exists) throw new ApplyPatchV2Error("cannot update a missing file in bulk patch", { path: hunk.path });
const originalLines = splitContentLines(state.content);
const replacements = computeReplacements(hunk.path, originalLines, hunk.chunks);
const textFormat = detectApplyPatchTextFormat(state.content);
const replacements = preserveReplacementNewlines(computeReplacements(hunk.path, originalLines, hunk.chunks), textFormat.newlineStyle);
const newContent = joinLinesWithFinalNewline(applyReplacements(originalLines, replacements));
const originalBuffer = Buffer.from(state.content, "utf8");
const finalBuffer = Buffer.from(newContent, "utf8");
@@ -676,6 +679,8 @@ async function applyPatchV2UpdateHunksBulk(executor: ApplyPatchV2Executor, hunks
originalSha256: sha256Hex(originalBuffer),
finalBytes: finalBuffer.length,
finalSha256: sha256Hex(finalBuffer),
newlineStyle: textFormat.newlineStyle,
encodingStyle: textFormat.encodingStyle,
replacements,
});
pendingWrites.add(hunk.path);
@@ -777,6 +782,27 @@ function appendRemoteOperationFailureDetails(lines: string[], details: Record<st
if (summaryFields.length > 0) {
lines.push(`Remote operation: ${summaryFields.map(([key, value]) => `${key}=${value}`).join(" ")}`);
}
const integrityDiagnostics = Array.isArray(remote.integrityDiagnostics) ? remote.integrityDiagnostics : [];
for (const item of integrityDiagnostics) {
const diagnostic = recordValue(item);
if (diagnostic === null) continue;
const fields = [
["adapter", stringField(diagnostic, "adapter")],
["stage", stringField(diagnostic, "stage")],
["file", stringField(diagnostic, "path")],
["expectedOriginalBytes", numberOrStringField(diagnostic, "expectedOriginalBytes")],
["currentBytes", numberOrStringField(diagnostic, "currentBytes")],
["expectedOriginalSha256", stringField(diagnostic, "expectedOriginalSha256")],
["currentSha256", stringField(diagnostic, "currentSha256")],
["expectedFinalBytes", numberOrStringField(diagnostic, "expectedFinalBytes")],
["expectedFinalSha256", stringField(diagnostic, "expectedFinalSha256")],
["expectedNewline", stringField(diagnostic, "expectedNewline")],
["currentNewline", stringField(diagnostic, "currentNewline")],
["expectedEncoding", stringField(diagnostic, "expectedEncoding")],
["currentEncoding", stringField(diagnostic, "currentEncoding")],
].filter(([, value]) => value !== undefined && value !== null && value !== "");
lines.push(`Integrity: ${fields.map(([key, value]) => `${key}=${value}`).join(" ")}`);
}
const stderrTail = stringField(remote, "stderrTail") ?? stringField(remote, "stderr");
if (stderrTail !== undefined && stderrTail.trim().length > 0) {
lines.push("Remote stderr tail:");
@@ -1147,12 +1173,58 @@ async function writeRemoteReplacementsBulk(executor: ApplyPatchV2Executor, paths
const { targets, payload } = formatApplyPatchV2BulkReplacementPayload(paths, plans);
if (targets.length === 0) return;
if (executor.fs?.applyReplacementsBulk !== undefined) {
await executor.fs.applyReplacementsBulk(targets, plans);
try {
await executor.fs.applyReplacementsBulk(targets, plans);
} catch (error) {
throw await enrichBulkReplacementIntegrityFailure(error, executor.fs, targets, plans);
}
return;
}
await checkedRemoteV2(executor, "apply-replacements-bulk-stdin", [String(targets.length)], payload);
}
async function enrichBulkReplacementIntegrityFailure(error: unknown, fs: ApplyPatchV2FileSystem, targets: string[], plans: Map<string, BulkReplacementWritePlan>): Promise<ApplyPatchV2Error> {
const message = error instanceof Error ? error.message : String(error);
const details = error instanceof ApplyPatchV2Error ? error.details : {};
const serialized = `${message}\n${JSON.stringify(details)}`;
if (fs.readFiles === undefined || !serialized.includes("integrity mismatch")) return error instanceof ApplyPatchV2Error ? error : new ApplyPatchV2Error(message);
let currentFiles: Map<string, string>;
try {
currentFiles = await fs.readFiles(targets);
} catch (readError) {
return new ApplyPatchV2Error(message, {
...details,
integrityDiagnosticError: readError instanceof Error ? readError.message : String(readError),
});
}
const stage = serialized.includes("original integrity mismatch") ? "original" : serialized.includes("final integrity mismatch") ? "final" : "unknown";
const route = typeof details.route === "string" ? details.route : "";
const adapter = route.includes(":win") ? "windows" : "posix";
const integrityDiagnostics = targets.flatMap((target) => {
const plan = plans.get(target);
const current = currentFiles.get(target);
if (plan === undefined || current === undefined) return [];
const currentBuffer = Buffer.from(current, "utf8");
const currentFormat = detectApplyPatchTextFormat(current);
return [{
adapter,
stage,
path: target,
expectedOriginalBytes: plan.originalBytes,
expectedOriginalSha256: plan.originalSha256,
expectedFinalBytes: plan.finalBytes,
expectedFinalSha256: plan.finalSha256,
currentBytes: currentBuffer.length,
currentSha256: sha256Hex(currentBuffer),
expectedNewline: plan.newlineStyle,
currentNewline: currentFormat.newlineStyle,
expectedEncoding: plan.encodingStyle,
currentEncoding: currentFormat.encodingStyle,
}];
});
return new ApplyPatchV2Error(message, { ...details, integrityDiagnostics });
}
export function formatApplyPatchV2BulkReplacementPayload(paths: Iterable<string>, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>): { targets: string[]; payload: string } {
const targets = Array.from(paths);
const records: string[] = [];
@@ -1291,6 +1363,19 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
" cat > \"$tmp_payload\"",
" python3 - \"$tmp_payload\" \"$tmp_dir\" \"$expected_count\" <<'PY'",
"import base64, hashlib, pathlib, shutil, sys",
"def text_profile(source):",
" encoding = 'utf8-bom' if source.startswith(b'\\xef\\xbb\\xbf') else 'utf8'",
" text = source.decode('utf-8')",
" crlf = text.count('\\r\\n')",
" bare_lf = text.count('\\n') - crlf",
" lone_cr = text.count('\\r') - crlf",
" newline = 'crlf' if crlf > 0 and bare_lf == 0 and lone_cr == 0 else ('lf' if bare_lf > 0 and crlf == 0 and lone_cr == 0 else ('none' if crlf == 0 and bare_lf == 0 and lone_cr == 0 else 'mixed'))",
" return encoding, newline",
"def integrity_failure(adapter, stage, target, expected_bytes, expected_sha256, source):",
" actual_sha256 = hashlib.sha256(source).hexdigest()",
" actual_encoding, actual_newline = text_profile(source)",
" print(f'UNIDESK_APPLY_PATCH_V2_INTEGRITY adapter={adapter} stage={stage} expectedBytes={expected_bytes} actualBytes={len(source)} expectedSha256={expected_sha256} actualSha256={actual_sha256} actualEncoding={actual_encoding} actualNewline={actual_newline}', file=sys.stderr)",
" raise SystemExit(f'bulk replacement {stage} integrity mismatch for {target}')",
"payload_path, tmp_dir, expected_count_text = sys.argv[1:4]",
"expected_count = int(expected_count_text)",
"records = [line for line in pathlib.Path(payload_path).read_text(encoding='utf-8').splitlines() if line.strip()]",
@@ -1307,7 +1392,7 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
" final_bytes = int(final_bytes_text)",
" source = pathlib.Path(target).read_bytes()",
" if len(source) != original_bytes or hashlib.sha256(source).hexdigest() != original_sha256:",
" raise SystemExit(f'bulk replacement original integrity mismatch for {target}')",
" integrity_failure('posix', 'original', target, original_bytes, original_sha256, source)",
" lines = source.decode('utf-8').split('\\n')",
" if lines and lines[-1] == '':",
" lines.pop()",
@@ -1327,7 +1412,7 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
" lines[start:start + old_len] = new_lines",
" output = (('\\n'.join(lines) + '\\n') if lines else '').encode('utf-8')",
" if len(output) != final_bytes or hashlib.sha256(output).hexdigest() != final_sha256:",
" raise SystemExit(f'bulk replacement final integrity mismatch for {target}')",
" integrity_failure('posix', 'final', target, final_bytes, final_sha256, output)",
" tmp_path = pathlib.Path(tmp_dir) / f'file-{index}.tmp'",
" tmp_path.write_bytes(output)",
" plans.append((target, final_sha256, tmp_path))",
@@ -1551,6 +1636,32 @@ function splitContentLines(content: string): string[] {
return lines;
}
function detectApplyPatchTextFormat(content: string): Pick<ApplyPatchV2BulkReplacementWritePlan, "newlineStyle" | "encodingStyle"> {
const crlfCount = (content.match(/\r\n/gu) ?? []).length;
const bareLfCount = (content.match(/(?<!\r)\n/gu) ?? []).length;
const loneCrCount = (content.match(/\r(?!\n)/gu) ?? []).length;
const newlineStyle = crlfCount > 0 && bareLfCount === 0 && loneCrCount === 0
? "crlf"
: bareLfCount > 0 && crlfCount === 0 && loneCrCount === 0
? "lf"
: crlfCount === 0 && bareLfCount === 0 && loneCrCount === 0
? "none"
: "mixed";
return {
newlineStyle,
encodingStyle: content.startsWith("\uFEFF") ? "utf8-bom" : "utf8",
};
}
function preserveReplacementNewlines(replacements: Replacement[], newlineStyle: ApplyPatchV2BulkReplacementWritePlan["newlineStyle"]): Replacement[] {
if (newlineStyle !== "crlf") return replacements;
return replacements.map(([start, oldLength, newLines]) => [
start,
oldLength,
newLines.map((line) => line.endsWith("\r") ? line : `${line}\r`),
]);
}
function joinLinesWithFinalNewline(lines: string[]): string {
if (lines.length === 0) return "";
return `${lines.join("\n")}\n`;