Merge pull request #1706 from pikasTech/fix/issue-1693-crlf-integrity
fix: 保留 apply-patch v2 的 CRLF 完整性
This commit is contained in:
@@ -29,6 +29,7 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md
|
||||
|
||||
- 远端文本修改优先 `trans <route> apply-patch`;不要 download/upload/sed 拼临时 diff 代替 patch。
|
||||
- Host/WSL 与 Windows route 的 `apply-patch` 优先走 fs adapter bulk update path;不要为了规避旧的 `read-b64-block` / `write-b64-argv` 慢路径改用临时脚本写文件。
|
||||
- `apply-patch v2` 的 localized replacement 必须保留纯 CRLF 文件的 CRLF 风格;original/final integrity mismatch 会重新只读采集当前文件并输出 adapter、stage、expected/current bytes、SHA-256、newline 和 encoding 摘要,不披露文件正文,也不回退 `apply-patch-v1`。
|
||||
- 当前 HWLAB node/lane source workspace 的正式预检/同步入口是 `bun scripts/cli.ts hwlab nodes control-plane source-workspace sync --node <node> --lane <lane> --confirm` 和 `source-workspace status`;不要把裸 `trans <node>:<workspace> git fetch ...` 当成当前 HWLAB node/lane workspace 预检/修复入口。
|
||||
- `sh`/`bash` 必须显式声明 shell;单进程命令优先 direct argv 或已知 operation。
|
||||
- 普通 trans/ssh 短连接硬预算 60s;长 CI/CD、trace、logs、build、硬件流程必须 submit-and-poll。
|
||||
|
||||
@@ -12,6 +12,11 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式
|
||||
|
||||
Windows `ps` 的参数语义分为两种:单个参数是完整 PowerShell source;多个参数是边界明确的 argv。后者对 native application 使用 Windows 命令行引用规则,必须保留 `python -c` 代码中的路径、`encoding="utf-8"`、字典 key 和嵌套引号,并继承 route 对应的 Windows workspace。PowerShell prelude 将 `Get-Content` 默认编码固定为 UTF-8;Windows fs helper 的 `head` / `tail` 同时支持 `-n N`、`-nN`、`--lines=N` 和 GNU `-N`,`wc -l` 必须把选项与路径分开解析。
|
||||
|
||||
- Windows/WSL `apply-patch v2` 文本完整性合同:
|
||||
- localized replacement 必须保留纯 CRLF 文件的 CRLF 风格,不能把新增行写成 bare LF,也不能把整个文件静默改写为 LF。
|
||||
- original/final integrity mismatch 后必须重新只读采集当前文件,输出 adapter、stage、expected/current bytes、SHA-256、newline 和 encoding;摘要不得包含文件正文。
|
||||
- Windows 与 WSL route 对同一文件使用同一 replacement plan 和 payload 合同;失败不能自动或人工建议回退 Windows 不支持的 `apply-patch-v1`。
|
||||
|
||||
CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI/CD server 和生产运行面。CLI/server 能力协商、unsupported-version 失败语义和 release-line 边界由 `docs/reference/release-governance.md` 与 [GitHub issue #6](https://github.com/pikasTech/unidesk/issues/6) 约束。
|
||||
|
||||
## CI/CD Control Boundary
|
||||
|
||||
@@ -98,6 +98,75 @@ describe("apply-patch v2 fs bulk update path", () => {
|
||||
expect(stderr.text()).toContain("\"remoteOperationCounts\":{\"fs.readFiles\":1,\"fs.applyReplacementsBulk\":1}");
|
||||
});
|
||||
|
||||
test("preserves CRLF for single and multi-chunk localized replacements", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "single",
|
||||
patch: [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/test.c",
|
||||
"@@",
|
||||
" alpha",
|
||||
"-old-a",
|
||||
"+new-a",
|
||||
" middle",
|
||||
"*** End Patch",
|
||||
],
|
||||
expected: "alpha\r\nnew-a\r\nmiddle\r\nold-b\r\nomega\r\n",
|
||||
},
|
||||
{
|
||||
name: "multi",
|
||||
patch: [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/test.c",
|
||||
"@@",
|
||||
" alpha",
|
||||
"-old-a",
|
||||
"+new-a",
|
||||
" middle",
|
||||
"@@",
|
||||
"-old-b",
|
||||
"+new-b",
|
||||
" omega",
|
||||
"*** End Patch",
|
||||
],
|
||||
expected: "alpha\r\nnew-a\r\nmiddle\r\nnew-b\r\nomega\r\n",
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const files = new Map<string, string>([["src/test.c", "alpha\r\nold-a\r\nmiddle\r\nold-b\r\nomega\r\n"]]);
|
||||
let capturedPlan: ApplyPatchV2BulkReplacementWritePlan | undefined;
|
||||
const fs: ApplyPatchV2FileSystem = {
|
||||
async stat() { throw new Error("stat should not be used"); },
|
||||
async readBlock() { throw new Error("readBlock should not be used"); },
|
||||
async writeFile() { throw new Error("writeFile should not be used"); },
|
||||
async deleteFile() { throw new Error("deleteFile should not be used"); },
|
||||
async readFiles(paths) { return new Map(paths.map((path) => [path, files.get(path) ?? ""])); },
|
||||
async applyReplacementsBulk(paths, plans) {
|
||||
for (const target of paths) {
|
||||
const plan = plans.get(target);
|
||||
if (plan === undefined) throw new Error(`missing plan for ${target}`);
|
||||
capturedPlan = plan;
|
||||
files.set(target, applyReplacementPlan(files.get(target) ?? "", plan));
|
||||
}
|
||||
},
|
||||
};
|
||||
const exitCode = await runApplyPatchV2({
|
||||
executor: { fs },
|
||||
stdin: Readable.from([item.patch.join("\n")]),
|
||||
stdout: new CaptureWritable(),
|
||||
stderr: new CaptureWritable(),
|
||||
});
|
||||
|
||||
expect(exitCode, item.name).toBe(0);
|
||||
expect(files.get("src/test.c"), item.name).toBe(item.expected);
|
||||
expect(files.get("src/test.c")?.match(/(?<!\r)\n/u), item.name).toBeNull();
|
||||
expect(capturedPlan?.newlineStyle, item.name).toBe("crlf");
|
||||
expect(capturedPlan?.encodingStyle, item.name).toBe("utf8");
|
||||
}
|
||||
});
|
||||
|
||||
test("bulk read failure falls back to block reads but still writes through replacement bulk", async () => {
|
||||
const files = new Map<string, string>([
|
||||
["a.md", "first\nold-a\nlast\n"],
|
||||
@@ -244,4 +313,58 @@ describe("apply-patch v2 fs bulk update path", () => {
|
||||
expect(err).toContain("Remote stderr tail:");
|
||||
expect(err).toContain("UNIDESK_SSH_RUNTIME_TIMEOUT timeoutSeconds=60");
|
||||
});
|
||||
|
||||
test("integrity mismatch reports current fingerprint and text format without file content", async () => {
|
||||
let readCount = 0;
|
||||
const fs: ApplyPatchV2FileSystem = {
|
||||
async stat() { throw new Error("stat should not be used"); },
|
||||
async readBlock() { throw new Error("readBlock should not be used"); },
|
||||
async writeFile() { throw new Error("writeFile should not be used"); },
|
||||
async deleteFile() { throw new Error("deleteFile should not be used"); },
|
||||
async readFiles(paths) {
|
||||
readCount += 1;
|
||||
const text = readCount === 1 ? "alpha\r\nold\r\nomega\r\n" : "alpha\r\nchanged-elsewhere\r\nomega\r\n";
|
||||
return new Map(paths.map((path) => [path, text]));
|
||||
},
|
||||
async applyReplacementsBulk() {
|
||||
throw new ApplyPatchV2Error("windows apply-patch fs operation failed: apply-replacements-bulk-stdin", {
|
||||
operation: "apply-replacements-bulk-stdin",
|
||||
route: "D601:win/F/Work/ConStart",
|
||||
targetCount: 1,
|
||||
landed: false,
|
||||
exitCode: 23,
|
||||
stderrTail: "bulk replacement original integrity mismatch for F:\\Work\\ConStart\\src\\test.c",
|
||||
});
|
||||
},
|
||||
};
|
||||
const stdout = new CaptureWritable();
|
||||
const stderr = new CaptureWritable();
|
||||
const exitCode = await runApplyPatchV2({
|
||||
executor: { fs },
|
||||
stdin: Readable.from([[
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/test.c",
|
||||
"@@",
|
||||
" alpha",
|
||||
"-old",
|
||||
"+new",
|
||||
" omega",
|
||||
"*** End Patch",
|
||||
].join("\n")]),
|
||||
stdout,
|
||||
stderr,
|
||||
timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout.text()).toBe("");
|
||||
expect(readCount).toBe(2);
|
||||
const err = stderr.text();
|
||||
expect(err).toContain("Integrity: adapter=windows stage=original file=src/test.c");
|
||||
expect(err).toContain("expectedOriginalSha256=");
|
||||
expect(err).toContain("currentSha256=");
|
||||
expect(err).toContain("expectedNewline=crlf currentNewline=crlf");
|
||||
expect(err).toContain("expectedEncoding=utf8 currentEncoding=utf8");
|
||||
expect(err).not.toContain("changed-elsewhere");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -208,6 +208,8 @@ describe("ssh host apply-patch fs backend", () => {
|
||||
originalSha256: sha256Hex(text),
|
||||
finalBytes: Buffer.byteLength("alpha\nnew\nomega\n", "utf8"),
|
||||
finalSha256: sha256Hex("alpha\nnew\nomega\n"),
|
||||
newlineStyle: "lf",
|
||||
encodingStyle: "utf8",
|
||||
replacements: [[1, 1, ["new"]]],
|
||||
},
|
||||
]]));
|
||||
|
||||
Reference in New Issue
Block a user