fix: harden apply-patch v2 transport
This commit is contained in:
+122
-11
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
export type PatchHunk =
|
||||
@@ -261,9 +261,53 @@ async function executePlannedOperation(executor: ApplyPatchV2Executor, operation
|
||||
await checkedRemoteV2(executor, "delete", [operation.path]);
|
||||
}
|
||||
|
||||
const readBlockBytes = 45_000;
|
||||
const writeB64ArgvLimit = 48_000;
|
||||
const writeB64ChunkChars = 12_000;
|
||||
|
||||
async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise<string> {
|
||||
const read = await checkedRemoteV2(executor, "read", [target]);
|
||||
return read.stdout;
|
||||
const stat = await checkedRemoteV2(executor, "stat", [target]);
|
||||
const [bytesText, expectedSha256] = stat.stdout.trim().split(/\s+/u);
|
||||
const expectedBytes = Number(bytesText);
|
||||
if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || !/^[0-9a-f]{64}$/u.test(expectedSha256 ?? "")) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 stat returned invalid metadata", { path: target, stdout: stat.stdout.slice(0, 500) });
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let actualBytes = 0;
|
||||
for (let blockIndex = 0; actualBytes < expectedBytes; blockIndex += 1) {
|
||||
const read = await checkedRemoteV2(executor, "read-b64-block", [target, String(blockIndex), String(readBlockBytes)]);
|
||||
const encoded = read.stdout.replace(/\s+/gu, "");
|
||||
const chunk = encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64");
|
||||
if (chunk.length === 0) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 read returned an empty block before EOF", {
|
||||
path: target,
|
||||
blockIndex,
|
||||
expectedBytes,
|
||||
actualBytes,
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
actualBytes += chunk.length;
|
||||
}
|
||||
|
||||
const contentBuffer = Buffer.concat(chunks);
|
||||
if (contentBuffer.length !== expectedBytes) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 read byte count mismatch", {
|
||||
path: target,
|
||||
expectedBytes,
|
||||
actualBytes: contentBuffer.length,
|
||||
});
|
||||
}
|
||||
const actualSha256 = sha256Hex(contentBuffer);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 read sha256 mismatch", {
|
||||
path: target,
|
||||
expectedSha256,
|
||||
actualSha256,
|
||||
});
|
||||
}
|
||||
return contentBuffer.toString("utf8");
|
||||
}
|
||||
|
||||
async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise<void> {
|
||||
@@ -271,14 +315,38 @@ async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, c
|
||||
const encoded = contentBuffer.toString("base64");
|
||||
const expectedBytes = String(contentBuffer.length);
|
||||
const expectedSha256 = sha256Hex(contentBuffer);
|
||||
if (encoded.length <= 48_000) {
|
||||
await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, 12_000)]);
|
||||
if (encoded.length <= writeB64ArgvLimit) {
|
||||
await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, writeB64ChunkChars)]);
|
||||
return;
|
||||
}
|
||||
await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded);
|
||||
try {
|
||||
await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded);
|
||||
return;
|
||||
} catch {
|
||||
// Some SSH/websocket bridges cap stdin payloads without a stable public
|
||||
// contract. The stdin path is still the fast path; fall back to small argv
|
||||
// chunks only after the remote sha/byte guard proves no partial write moved.
|
||||
}
|
||||
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
|
||||
await checkedRemoteV2(executor, "write-b64-begin", [target, token]);
|
||||
for (const chunk of chunkString(encoded, writeB64ChunkChars)) {
|
||||
await checkedRemoteV2(executor, "write-b64-append", [target, token, chunk]);
|
||||
}
|
||||
await checkedRemoteV2(executor, "write-b64-commit", [target, token, expectedBytes, expectedSha256]);
|
||||
}
|
||||
|
||||
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[], input?: string): Promise<{ stdout: string }> {
|
||||
type RemoteV2Operation =
|
||||
| "stat"
|
||||
| "read-b64-block"
|
||||
| "write-b64-argv"
|
||||
| "write-b64-stdin"
|
||||
| "write-b64-begin"
|
||||
| "write-b64-append"
|
||||
| "write-b64-commit"
|
||||
| "delete"
|
||||
| "move";
|
||||
|
||||
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: RemoteV2Operation, args: string[], input?: string): Promise<{ stdout: string }> {
|
||||
const result = await executor.run(remoteV2Script(operation, args), input);
|
||||
if (result.exitCode === 0) return result;
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", {
|
||||
@@ -290,7 +358,7 @@ async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: "read"
|
||||
});
|
||||
}
|
||||
|
||||
function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[]): string[] {
|
||||
function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[] {
|
||||
const script = [
|
||||
"set -eu",
|
||||
"sha256_file() {",
|
||||
@@ -318,11 +386,31 @@ function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin"
|
||||
" exit 24",
|
||||
" fi",
|
||||
"}",
|
||||
"set_tmp_paths() {",
|
||||
" target=$1",
|
||||
" token=$2",
|
||||
" case \"$token\" in ''|*[!a-zA-Z0-9_.-]*) printf 'invalid v2 temp token\\n' >&2; exit 2;; esac",
|
||||
" base=${target##*/}",
|
||||
" dir=.",
|
||||
" case \"$target\" in */*) dir=${target%/*};; esac",
|
||||
" tmp=\"$dir/.${base}.unidesk-v2-${token}.tmp\"",
|
||||
" tmp_b64=\"$tmp.b64\"",
|
||||
"}",
|
||||
"op=$1",
|
||||
"shift",
|
||||
"case \"$op\" in",
|
||||
" read)",
|
||||
" cat -- \"$1\"",
|
||||
" stat)",
|
||||
" target=$1",
|
||||
" bytes=$(wc -c < \"$target\" | tr -d '[:space:]')",
|
||||
" digest=$(sha256_file \"$target\")",
|
||||
" printf '%s %s\\n' \"$bytes\" \"$digest\"",
|
||||
" ;;",
|
||||
" read-b64-block)",
|
||||
" target=$1",
|
||||
" block_index=$2",
|
||||
" block_size=$3",
|
||||
" case \"$block_index:$block_size\" in *[!0-9:]*|:*) printf 'invalid read block args\\n' >&2; exit 2;; esac",
|
||||
" dd if=\"$target\" bs=\"$block_size\" skip=\"$block_index\" count=1 2>/dev/null | base64 | tr -d '\\n'",
|
||||
" ;;",
|
||||
" write-b64-argv)",
|
||||
" target=$1",
|
||||
@@ -352,7 +440,30 @@ function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin"
|
||||
" dir=.",
|
||||
" case \"$target\" in */*) dir=${target%/*};; esac",
|
||||
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
|
||||
" base64 -d > \"$tmp\"",
|
||||
" if ! base64 -d > \"$tmp\"; then rm -f -- \"$tmp\"; printf 'v2 base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
||||
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
|
||||
" mv -f -- \"$tmp\" \"$target\"",
|
||||
" actual_sha256=$(sha256_file \"$target\")",
|
||||
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
||||
" ;;",
|
||||
" write-b64-begin)",
|
||||
" target=$1",
|
||||
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
||||
" set_tmp_paths \"$target\" \"$2\"",
|
||||
" : > \"$tmp_b64\"",
|
||||
" ;;",
|
||||
" write-b64-append)",
|
||||
" target=$1",
|
||||
" set_tmp_paths \"$target\" \"$2\"",
|
||||
" printf '%s' \"$3\" >> \"$tmp_b64\"",
|
||||
" ;;",
|
||||
" write-b64-commit)",
|
||||
" target=$1",
|
||||
" set_tmp_paths \"$target\" \"$2\"",
|
||||
" expected_bytes=$3",
|
||||
" expected_sha256=$4",
|
||||
" if ! base64 -d < \"$tmp_b64\" > \"$tmp\"; then rm -f -- \"$tmp\" \"$tmp_b64\"; printf 'v2 base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
||||
" rm -f -- \"$tmp_b64\"",
|
||||
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
|
||||
" mv -f -- \"$tmp\" \"$target\"",
|
||||
" actual_sha256=$(sha256_file \"$target\")",
|
||||
|
||||
Reference in New Issue
Block a user