Merge pull request #266 from pikasTech/fix/issue212-trans-download-metadata

fix: harden trans download stat diagnostics
This commit is contained in:
Lyon
2026-06-11 17:18:05 +08:00
committed by GitHub
3 changed files with 82 additions and 12 deletions
+14 -1
View File
@@ -67,11 +67,24 @@ function normalizeErrorPayload(command: string, error: unknown): Record<string,
}
if (error instanceof Error) {
const debug = process.env.UNIDESK_CLI_DEBUG === "1" || process.env.UNIDESK_CLI_FULL_ERROR === "1" || process.env.UNIDESK_CLI_RAW_ERROR === "1";
return { name: error.name, message, stack: error.stack ?? null, ...(debug ? { debug: true } : {}) };
return {
name: error.name,
message,
...(sshFileTransferErrorDetails(error) ?? {}),
stack: error.stack ?? null,
...(debug ? { debug: true } : {}),
};
}
return { message };
}
function sshFileTransferErrorDetails(error: Error): Record<string, unknown> | null {
if (error.name !== "SshFileTransferError") return null;
const details = (error as Error & { details?: unknown }).details;
if (typeof details !== "object" || details === null || Array.isArray(details)) return null;
return { details };
}
function parseStructuredErrorMessage(command: string, message: string): Record<string, unknown> | null {
if (!shouldSuppressStack(command) && !shouldSuppressStack(commandPrefixFromMessage(message))) return null;
const jsonStart = message.indexOf("{");
+28 -9
View File
@@ -354,7 +354,7 @@ async function statRemoteFile(
remotePath: string,
): Promise<SshFileTransferStat> {
const stat = await checkedFileTransfer(invocation, executor, builders, "stat", [remotePath]);
return parseFileTransferStat(stat.stdout, remotePath);
return parseFileTransferStat(stat.stdout, stat.stderr, remotePath);
}
async function checkedFileTransfer(
@@ -373,8 +373,8 @@ async function checkedFileTransfer(
operation,
args: args.slice(0, 4),
exitCode: result.exitCode,
stdout: result.stdout.slice(-2000),
stderr: result.stderr.slice(-4000),
stdout: transferTextSnapshot(result.stdout, { headChars: operation === "stat" ? 500 : 120, tailChars: 500 }),
stderr: transferTextSnapshot(result.stderr, { headChars: 120, tailChars: 1000 }),
});
}
@@ -389,16 +389,35 @@ function buildFileTransferRemoteCommand(
return builders.buildRouteCommand(route, ["sh", "-c", posixFileTransferScript(), "unidesk-file-transfer", operation, ...args], { stdin: hasInput });
}
function parseFileTransferStat(stdout: string, remotePath: string): SshFileTransferStat {
const [bytesText, sha256] = stdout.trim().split(/\s+/u);
const bytes = Number(bytesText);
if (!Number.isSafeInteger(bytes) || bytes < 0 || !/^[0-9a-f]{64}$/u.test(sha256 ?? "")) {
function parseFileTransferStat(stdout: string, stderr: string, remotePath: string): SshFileTransferStat {
const lines = stdout.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0);
for (const line of lines) {
const [bytesText, sha256, extra] = line.split(/\s+/u);
const bytes = Number(bytesText);
if (extra === undefined && Number.isSafeInteger(bytes) && bytes >= 0 && /^[0-9a-f]{64}$/u.test(sha256 ?? "")) {
return { bytes, sha256: sha256! };
}
}
{
throw new SshFileTransferError("remote file transfer stat returned invalid metadata", {
remotePath,
stdout: stdout.slice(0, 500),
expected: "<bytes> <sha256>",
stdout: transferTextSnapshot(stdout, { headChars: 500, tailChars: 500 }),
stderr: transferTextSnapshot(stderr, { headChars: 120, tailChars: 1000 }),
candidateLines: lines.slice(0, 8).map((line) => line.slice(0, 200)),
candidateLineCount: lines.length,
});
}
return { bytes, sha256: sha256! };
}
function transferTextSnapshot(value: string, limits: { headChars: number; tailChars: number }): Record<string, unknown> {
return {
bytes: Buffer.byteLength(value, "utf8"),
chars: value.length,
head: value.slice(0, limits.headChars),
tail: value.slice(Math.max(0, value.length - limits.tailChars)),
truncated: value.length > limits.headChars + limits.tailChars,
};
}
function assertTransferStat(label: string, pathName: string, expected: SshFileTransferStat, actual: SshFileTransferStat): void {
+40 -2
View File
@@ -8,6 +8,7 @@ import { sshHelp } from "./src/help";
import { runApplyPatchV2, type ApplyPatchV2TimingSummary, type ApplyPatchV2BulkReplacementWritePlan } from "./src/apply-patch-v2";
import { providerTriageRecommendedCrossChecks } from "./src/provider-triage";
import { extractRemoteCliOptions, remoteSshFrontendPlanForTest } from "./src/remote";
import { emitError } from "./src/output";
import { runSshFileTransferOperation, type SshFileTransferCommandBuilders, type SshRemoteCommandExecutor } from "./src/ssh-file-transfer";
import {
formatSshFailureHint,
@@ -430,7 +431,7 @@ async function applyPatchV2FsBulkFixtureAttempt(patch: string, files: Record<str
return { stdout, stderr, exitCode, files: Object.fromEntries(state), operations, error };
}
function fileTransferFixture(initial: Record<string, Buffer> = {}, options: { emptyReadOnce?: Record<string, number[]>; shortReadOnce?: Record<string, Record<string, number>> } = {}): {
function fileTransferFixture(initial: Record<string, Buffer> = {}, options: { emptyReadOnce?: Record<string, number[]>; shortReadOnce?: Record<string, Record<string, number>>; statStdoutPrefix?: string; statStdoutSuffix?: string } = {}): {
state: Map<string, Buffer>;
commands: Array<{ operation: string; stdin: boolean }>;
executor: SshRemoteCommandExecutor;
@@ -459,7 +460,7 @@ function fileTransferFixture(initial: Record<string, Buffer> = {}, options: { em
if (operation === "stat") {
const content = state.get(target);
if (content === undefined) return { exitCode: 1, stdout: "", stderr: "missing" };
return { exitCode: 0, stdout: `${content.length} ${sha256BufferHex(content)}\n`, stderr: "" };
return { exitCode: 0, stdout: `${options.statStdoutPrefix ?? ""}${content.length} ${sha256BufferHex(content)}\n${options.statStdoutSuffix ?? ""}`, stderr: "" };
}
if (operation === "read-b64-block") {
const content = state.get(target);
@@ -642,6 +643,43 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
assertCondition(readFileSync(localDownload).equals(payload), "download must preserve binary and UTF-8 bytes locally", { commands: transfer.commands });
assertCondition(transfer.commands.some((item) => item.operation === "stat") && transfer.commands.some((item) => item.operation === "read-b64-block"), "file transfer should use stat plus chunked verified reads", transfer.commands);
const noisyStatDownload = path.join(transferRoot, "downloaded", "noisy-stat-copy.bin");
const noisyStatTransfer = fileTransferFixture({ "/tmp/noisy-stat.bin": payload }, {
statStdoutPrefix: "provider note before stat\n",
statStdoutSuffix: "provider note after stat\n",
});
const noisyStatResult = await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "/tmp/noisy-stat.bin", noisyStatDownload]), ["download", "/tmp/noisy-stat.bin", noisyStatDownload], noisyStatTransfer.executor, noisyStatTransfer.builders));
const noisyStatJson = JSON.parse(noisyStatResult.stdout) as JsonRecord;
assertCondition(noisyStatResult.exitCode === 0 && noisyStatJson.sha256 === sha256BufferHex(payload), "download stat parser should ignore unrelated stdout lines around the metadata record", noisyStatResult);
let missingStatError: unknown = null;
const missingStatTransfer = fileTransferFixture();
const missingStatDownload = path.join(transferRoot, "missing.bin");
try {
await captureStdout(() => runSshFileTransferOperation(parseSshInvocation("D601", ["download", "/tmp/missing-stat.bin", missingStatDownload]), ["download", "/tmp/missing-stat.bin", missingStatDownload], missingStatTransfer.executor, missingStatTransfer.builders));
} catch (error) {
missingStatError = error;
}
const missingDetails = (missingStatError as { details?: JsonRecord }).details ?? {};
const missingStderr = missingDetails.stderr as JsonRecord | undefined;
assertCondition(
missingStatError instanceof Error
&& missingStatError.name === "SshFileTransferError"
&& missingDetails.operation === "stat"
&& missingDetails.exitCode === 1
&& missingStderr?.tail === "missing",
"stat failures should preserve bounded diagnostic details on the thrown file-transfer error",
{ name: (missingStatError as Error | null)?.name, details: missingDetails },
);
const emittedMissingStat = await captureStdout(async () => {
emitError("ssh D601 download", missingStatError);
return 0;
});
const emittedMissingJson = JSON.parse(emittedMissingStat.stdout) as JsonRecord;
const emittedError = emittedMissingJson.error as JsonRecord;
const emittedDetails = emittedError.details as JsonRecord;
assertCondition(emittedDetails.operation === "stat" && emittedDetails.exitCode === 1 && (emittedDetails.stderr as JsonRecord).tail === "missing", "CLI JSON error should include bounded file-transfer details by default", emittedMissingJson);
const retryDownload = path.join(transferRoot, "downloaded", "retry-copy.bin");
const retryPayload = Buffer.from("0123456789abcdef".repeat(4096), "utf8");
const retryTransfer = fileTransferFixture({ "/tmp/retry-remote.bin": retryPayload }, { emptyReadOnce: { "/tmp/retry-remote.bin": [1] } });