fix: preserve legacy text across trans helpers
This commit is contained in:
@@ -167,6 +167,50 @@ describe("apply-patch v2 fs bulk update path", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves legacy single-byte content during ASCII localized replacement", async () => {
|
||||
const original = Buffer.concat([
|
||||
Buffer.from("static int old_value = 1;\r\n// legacy ", "ascii"),
|
||||
Buffer.from([0xca]),
|
||||
Buffer.from(" comment\r\n", "ascii"),
|
||||
]);
|
||||
let captured: 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, {
|
||||
content: original.toString("latin1"),
|
||||
bytes: original,
|
||||
encodingStyle: "legacy-single-byte" as const,
|
||||
}]));
|
||||
},
|
||||
async applyReplacementsBulk(_paths, plans) {
|
||||
captured = plans.get("src/legacy.c");
|
||||
},
|
||||
};
|
||||
const exitCode = await runApplyPatchV2({
|
||||
executor: { fs },
|
||||
stdin: Readable.from([[
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/legacy.c",
|
||||
"@@",
|
||||
"-static int old_value = 1;",
|
||||
"+static int new_value = 2;",
|
||||
"*** End Patch",
|
||||
].join("\n")]),
|
||||
stdout: new CaptureWritable(),
|
||||
stderr: new CaptureWritable(),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(captured?.encodingStyle).toBe("legacy-single-byte");
|
||||
expect(captured?.newlineStyle).toBe("crlf");
|
||||
expect(captured?.originalBytes).toBe(original.length);
|
||||
expect(captured?.originalSha256).toBe(createHash("sha256").update(original).digest("hex"));
|
||||
});
|
||||
|
||||
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"],
|
||||
|
||||
@@ -63,10 +63,16 @@ export interface ApplyPatchV2FileSystem {
|
||||
readBlock(path: string, blockIndex: number, blockBytes: number): Promise<Buffer>;
|
||||
writeFile(path: string, content: Buffer): Promise<void>;
|
||||
deleteFile(path: string): Promise<void>;
|
||||
readFiles?(paths: string[]): Promise<Map<string, string>>;
|
||||
readFiles?(paths: string[]): Promise<Map<string, ApplyPatchV2ReadFile | string>>;
|
||||
applyReplacementsBulk?(paths: Iterable<string>, plans: Map<string, ApplyPatchV2BulkReplacementWritePlan>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2ReadFile {
|
||||
content: string;
|
||||
bytes: Buffer;
|
||||
encodingStyle: "utf8" | "utf8-bom" | "legacy-single-byte";
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2FileStat {
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
@@ -114,7 +120,7 @@ export class ApplyPatchV2Error extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" };
|
||||
type PlannedFileState = { exists: true; content: string; bytes: Buffer; encodingStyle: ApplyPatchV2ReadFile["encodingStyle"] } | { exists: false; content: "" };
|
||||
type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string };
|
||||
export type ApplyPatchV2Replacement = [start: number, oldLength: number, newLines: string[]];
|
||||
type Replacement = ApplyPatchV2Replacement;
|
||||
@@ -125,7 +131,7 @@ export interface ApplyPatchV2BulkReplacementWritePlan {
|
||||
finalBytes: number;
|
||||
finalSha256: string;
|
||||
newlineStyle: "lf" | "crlf" | "mixed" | "none";
|
||||
encodingStyle: "utf8" | "utf8-bom";
|
||||
encodingStyle: ApplyPatchV2ReadFile["encodingStyle"];
|
||||
replacements: ApplyPatchV2Replacement[];
|
||||
}
|
||||
type BulkReplacementWritePlan = ApplyPatchV2BulkReplacementWritePlan;
|
||||
@@ -653,7 +659,7 @@ async function applyPatchV2UpdateHunksBulk(executor: ApplyPatchV2Executor, hunks
|
||||
const paths = uniqueStrings(hunks.map((hunk) => hunk.path));
|
||||
const originalFiles = await readRemoteTextsBulk(executor, paths);
|
||||
const states = new Map<string, PlannedFileState>();
|
||||
for (const [filePath, content] of originalFiles) states.set(filePath, { exists: true, content });
|
||||
for (const [filePath, file] of originalFiles) states.set(filePath, { exists: true, ...file });
|
||||
|
||||
const changed: string[] = [];
|
||||
const outcomes: ApplyPatchV2Outcome[] = [];
|
||||
@@ -667,12 +673,12 @@ 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 textFormat = detectApplyPatchTextFormat(state.content);
|
||||
const textFormat = detectApplyPatchTextFormat(state.content, state.encodingStyle);
|
||||
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");
|
||||
states.set(hunk.path, { exists: true, content: newContent });
|
||||
const originalBuffer = state.bytes;
|
||||
const finalBuffer = encodeApplyPatchText(newContent, state.encodingStyle, hunk.path);
|
||||
states.set(hunk.path, { exists: true, content: newContent, bytes: finalBuffer, encodingStyle: state.encodingStyle });
|
||||
replacementPlans.set(hunk.path, {
|
||||
path: hunk.path,
|
||||
originalBytes: originalBuffer.length,
|
||||
@@ -1033,7 +1039,7 @@ async function readRemoteText(executor: ApplyPatchV2Executor, target: string): P
|
||||
return contentBuffer.toString("utf8");
|
||||
}
|
||||
|
||||
async function readRemoteTextsBulk(executor: ApplyPatchV2Executor, targets: string[]): Promise<Map<string, string>> {
|
||||
async function readRemoteTextsBulk(executor: ApplyPatchV2Executor, targets: string[]): Promise<Map<string, ApplyPatchV2ReadFile>> {
|
||||
if (targets.length === 0) return new Map();
|
||||
if (executor.fs?.readFiles !== undefined) {
|
||||
try {
|
||||
@@ -1041,14 +1047,14 @@ async function readRemoteTextsBulk(executor: ApplyPatchV2Executor, targets: stri
|
||||
for (const target of targets) {
|
||||
if (!files.has(target)) throw new ApplyPatchV2Error("remote apply-patch v2 fs bulk read omitted target", { target });
|
||||
}
|
||||
return files;
|
||||
return new Map(Array.from(files, ([path, file]) => [path, normalizeApplyPatchV2ReadFile(file)]));
|
||||
} catch (error) {
|
||||
if (!isBulkReadFailure(error)) throw error;
|
||||
}
|
||||
}
|
||||
if (executor.fs || targets.length === 1) {
|
||||
const files = new Map<string, string>();
|
||||
for (const target of targets) files.set(target, await readRemoteText(executor, target));
|
||||
const files = new Map<string, ApplyPatchV2ReadFile>();
|
||||
for (const target of targets) files.set(target, decodeApplyPatchV2ReadFile(Buffer.from(await readRemoteText(executor, target), "utf8")));
|
||||
return files;
|
||||
}
|
||||
const result = await checkedRemoteV2(executor, "read-bulk-b64", targets);
|
||||
@@ -1099,7 +1105,7 @@ function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: numbe
|
||||
return decoded.length > expectedChunkBytes ? decoded.subarray(0, expectedChunkBytes) : decoded;
|
||||
}
|
||||
|
||||
export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): Map<string, string> {
|
||||
export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): Map<string, ApplyPatchV2ReadFile> {
|
||||
const lines = stdout.split(/\r?\n/u);
|
||||
const markerIndex = lines.findIndex((line) => line.startsWith(`${remoteBulkReadMarker} `));
|
||||
if (markerIndex < 0) {
|
||||
@@ -1113,7 +1119,7 @@ export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): M
|
||||
if (records.length !== declaredCount) {
|
||||
throw new ApplyPatchV2Error("remote apply-patch v2 bulk read record count mismatch", { expectedTargets: targets.length, records: records.length });
|
||||
}
|
||||
const files = new Map<string, string>();
|
||||
const files = new Map<string, ApplyPatchV2ReadFile>();
|
||||
for (const record of records) {
|
||||
const [pathB64, bytesText, sha256, contentB64] = record.split(/\s+/u);
|
||||
const target = Buffer.from(pathB64, "base64").toString("utf8");
|
||||
@@ -1132,7 +1138,7 @@ export function decodeApplyPatchV2BulkRead(stdout: string, targets: string[]): M
|
||||
if (actualSha256 !== sha256) {
|
||||
throw new ApplyPatchV2Error(`remote apply-patch v2 bulk read sha256 mismatch for ${target}`, { target, expectedSha256: sha256, actualSha256 });
|
||||
}
|
||||
files.set(target, contentBuffer.toString("utf8"));
|
||||
files.set(target, decodeApplyPatchV2ReadFile(contentBuffer));
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (!files.has(target)) throw new ApplyPatchV2Error("remote apply-patch v2 bulk read omitted target", { target });
|
||||
@@ -1188,7 +1194,7 @@ async function enrichBulkReplacementIntegrityFailure(error: unknown, fs: ApplyPa
|
||||
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>;
|
||||
let currentFiles: Map<string, ApplyPatchV2ReadFile | string>;
|
||||
try {
|
||||
currentFiles = await fs.readFiles(targets);
|
||||
} catch (readError) {
|
||||
@@ -1202,10 +1208,11 @@ async function enrichBulkReplacementIntegrityFailure(error: unknown, fs: ApplyPa
|
||||
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);
|
||||
const currentValue = currentFiles.get(target);
|
||||
if (plan === undefined || currentValue === undefined) return [];
|
||||
const current = normalizeApplyPatchV2ReadFile(currentValue);
|
||||
const currentBuffer = current.bytes;
|
||||
const currentFormat = detectApplyPatchTextFormat(current.content, current.encodingStyle);
|
||||
return [{
|
||||
adapter,
|
||||
stage,
|
||||
@@ -1245,6 +1252,7 @@ export function formatApplyPatchV2BulkReplacementPayload(paths: Iterable<string>
|
||||
plan.originalSha256,
|
||||
String(plan.finalBytes),
|
||||
plan.finalSha256,
|
||||
plan.encodingStyle,
|
||||
replacementFields.join(";"),
|
||||
].join(" "));
|
||||
}
|
||||
@@ -1364,11 +1372,14 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
|
||||
" 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",
|
||||
" try:",
|
||||
" source.decode('utf-8')",
|
||||
" encoding = 'utf8-bom' if source.startswith(b'\\xef\\xbb\\xbf') else 'utf8'",
|
||||
" except UnicodeDecodeError:",
|
||||
" encoding = 'legacy-single-byte'",
|
||||
" crlf = source.count(b'\\r\\n')",
|
||||
" bare_lf = source.count(b'\\n') - crlf",
|
||||
" lone_cr = source.count(b'\\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):",
|
||||
@@ -1384,16 +1395,17 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
|
||||
"plans = []",
|
||||
"for index, record in enumerate(records, start=1):",
|
||||
" fields = record.split()",
|
||||
" if len(fields) != 6:",
|
||||
" if len(fields) != 7:",
|
||||
" raise SystemExit('bulk replacement malformed record')",
|
||||
" path_b64, original_bytes_text, original_sha256, final_bytes_text, final_sha256, replacements_text = fields",
|
||||
" path_b64, original_bytes_text, original_sha256, final_bytes_text, final_sha256, encoding_style, replacements_text = fields",
|
||||
" target = base64.b64decode(path_b64).decode('utf-8')",
|
||||
" original_bytes = int(original_bytes_text)",
|
||||
" final_bytes = int(final_bytes_text)",
|
||||
" source = pathlib.Path(target).read_bytes()",
|
||||
" if len(source) != original_bytes or hashlib.sha256(source).hexdigest() != original_sha256:",
|
||||
" integrity_failure('posix', 'original', target, original_bytes, original_sha256, source)",
|
||||
" lines = source.decode('utf-8').split('\\n')",
|
||||
" source_encoding = 'latin-1' if encoding_style == 'legacy-single-byte' else 'utf-8'",
|
||||
" lines = source.decode(source_encoding).split('\\n')",
|
||||
" if lines and lines[-1] == '':",
|
||||
" lines.pop()",
|
||||
" replacements = []",
|
||||
@@ -1410,7 +1422,7 @@ export function applyPatchV2RemoteCommand(operation: ApplyPatchV2RemoteOperation
|
||||
" if start < 0 or old_len < 0 or start + old_len > len(lines):",
|
||||
" raise SystemExit(f'bulk replacement out of bounds for {target}')",
|
||||
" lines[start:start + old_len] = new_lines",
|
||||
" output = (('\\n'.join(lines) + '\\n') if lines else '').encode('utf-8')",
|
||||
" output = (('\\n'.join(lines) + '\\n') if lines else '').encode(source_encoding)",
|
||||
" if len(output) != final_bytes or hashlib.sha256(output).hexdigest() != final_sha256:",
|
||||
" integrity_failure('posix', 'final', target, final_bytes, final_sha256, output)",
|
||||
" tmp_path = pathlib.Path(tmp_dir) / f'file-{index}.tmp'",
|
||||
@@ -1636,7 +1648,7 @@ function splitContentLines(content: string): string[] {
|
||||
return lines;
|
||||
}
|
||||
|
||||
function detectApplyPatchTextFormat(content: string): Pick<ApplyPatchV2BulkReplacementWritePlan, "newlineStyle" | "encodingStyle"> {
|
||||
function detectApplyPatchTextFormat(content: string, encodingStyle: ApplyPatchV2ReadFile["encodingStyle"] = content.startsWith("\uFEFF") ? "utf8-bom" : "utf8"): 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;
|
||||
@@ -1649,10 +1661,45 @@ function detectApplyPatchTextFormat(content: string): Pick<ApplyPatchV2BulkRepla
|
||||
: "mixed";
|
||||
return {
|
||||
newlineStyle,
|
||||
encodingStyle: content.startsWith("\uFEFF") ? "utf8-bom" : "utf8",
|
||||
encodingStyle,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeApplyPatchV2ReadFile(contentBuffer: Buffer): ApplyPatchV2ReadFile {
|
||||
try {
|
||||
const content = new TextDecoder("utf-8", { fatal: true }).decode(contentBuffer);
|
||||
return {
|
||||
content,
|
||||
bytes: contentBuffer,
|
||||
encodingStyle: content.startsWith("\uFEFF") ? "utf8-bom" : "utf8",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
content: contentBuffer.toString("latin1"),
|
||||
bytes: contentBuffer,
|
||||
encodingStyle: "legacy-single-byte",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApplyPatchV2ReadFile(file: ApplyPatchV2ReadFile | string): ApplyPatchV2ReadFile {
|
||||
return typeof file === "string" ? decodeApplyPatchV2ReadFile(Buffer.from(file, "utf8")) : file;
|
||||
}
|
||||
|
||||
function encodeApplyPatchText(content: string, encodingStyle: ApplyPatchV2ReadFile["encodingStyle"], path: string): Buffer {
|
||||
if (encodingStyle !== "legacy-single-byte") return Buffer.from(content, "utf8");
|
||||
for (const character of content) {
|
||||
if ((character.codePointAt(0) ?? 0) > 0xff) {
|
||||
throw new ApplyPatchV2Error("legacy single-byte localized replacement only supports byte-preserving text", {
|
||||
path,
|
||||
encodingStyle,
|
||||
replacementRequirement: "ASCII or existing single-byte characters",
|
||||
});
|
||||
}
|
||||
}
|
||||
return Buffer.from(content, "latin1");
|
||||
}
|
||||
|
||||
function preserveReplacementNewlines(replacements: Replacement[], newlineStyle: ApplyPatchV2BulkReplacementWritePlan["newlineStyle"]): Replacement[] {
|
||||
if (newlineStyle !== "crlf") return replacements;
|
||||
return replacements.map(([start, oldLength, newLines]) => [
|
||||
|
||||
@@ -860,7 +860,7 @@ function windowsApplyPatchFsScript(basePath: string | null, operation: WindowsAp
|
||||
const arg1 = args[1] ?? "";
|
||||
const arg2 = args[2] ?? "";
|
||||
const arg3 = args[3] ?? "";
|
||||
return [
|
||||
const scriptParts = [
|
||||
"$ErrorActionPreference = 'Stop';",
|
||||
"$ProgressPreference = 'SilentlyContinue';",
|
||||
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
||||
@@ -895,6 +895,41 @@ function windowsApplyPatchFsScript(basePath: string | null, operation: WindowsAp
|
||||
" 'delete' { Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue; break }",
|
||||
" default { Fail ('unsupported apply-patch fs op: ' + $operation) 2 }",
|
||||
"}",
|
||||
];
|
||||
return scriptParts
|
||||
.map((line) => line.startsWith(" 'apply-replacements-bulk-stdin' ") ? windowsBulkReplacementScript() : line)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function windowsBulkReplacementScript(): string {
|
||||
return [
|
||||
" 'apply-replacements-bulk-stdin' {",
|
||||
" $expectedCount = [Int32]$targetArg;",
|
||||
" $records = @([Console]::In.ReadToEnd() -split \"`r?`n\" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) });",
|
||||
" if ($records.Count -ne $expectedCount) { Fail ('bulk replacement record count mismatch: expected=' + $expectedCount + ' actual=' + $records.Count) 23 };",
|
||||
" $plans = New-Object System.Collections.Generic.List[object];",
|
||||
" foreach ($record in $records) {",
|
||||
" $fields = $record -split '\\s+'; if ($fields.Count -ne 7) { Fail 'bulk replacement malformed record' 23 };",
|
||||
" $targetRelative = Decode-Utf8 $fields[0]; $resolved = Resolve-UnideskPath $targetRelative;",
|
||||
" $originalBytes = [Int64]$fields[1]; $originalSha256 = $fields[2]; $finalBytes = [Int64]$fields[3]; $finalSha256 = $fields[4]; $encodingStyle = $fields[5]; $replacementsText = $fields[6];",
|
||||
" if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { Fail ('file not found: ' + $resolved) 1 };",
|
||||
" $source = [System.IO.File]::ReadAllBytes($resolved); $actualOriginalBytes = [Int64]$source.Length; $actualOriginalSha256 = Get-Sha256Bytes $source;",
|
||||
" if ($actualOriginalBytes -ne $originalBytes) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 };",
|
||||
" if (-not [string]::Equals($actualOriginalSha256, $originalSha256, [StringComparison]::OrdinalIgnoreCase)) { Fail ('bulk replacement original integrity mismatch for ' + $resolved) 23 };",
|
||||
" $textEncoding = if ($encodingStyle -eq 'legacy-single-byte') { [System.Text.Encoding]::GetEncoding(28591) } else { [System.Text.UTF8Encoding]::new($false, $true) };",
|
||||
" try { $sourceText = $textEncoding.GetString($source) } catch { Fail ('bulk replacement source decode failed encoding=' + $encodingStyle + ' for ' + $resolved) 25 };",
|
||||
" $lines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($sourceText -split \"`n\", -1)) { $lines.Add($line) | Out-Null }; if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) };",
|
||||
" $replacements = New-Object System.Collections.Generic.List[object];",
|
||||
" if (-not [string]::IsNullOrEmpty($replacementsText)) { foreach ($item in ($replacementsText -split ';')) { if ([string]::IsNullOrWhiteSpace($item)) { continue }; $parts = $item -split ',', 3; if ($parts.Count -ne 3) { Fail 'bulk replacement malformed replacement' 23 }; $newText = Decode-Utf8 $parts[2]; $newLines = New-Object System.Collections.Generic.List[string]; foreach ($line in ($newText -split \"`n\", -1)) { $newLines.Add($line) | Out-Null }; if ($newLines.Count -gt 0 -and $newLines[$newLines.Count - 1] -eq '') { $newLines.RemoveAt($newLines.Count - 1) }; $replacements.Add([pscustomobject]@{ start = [Int32]$parts[0]; oldLength = [Int32]$parts[1]; newLines = [string[]]$newLines }) | Out-Null } };",
|
||||
" foreach ($replacement in @($replacements | Sort-Object -Property start -Descending)) { if ($replacement.start -lt 0 -or $replacement.oldLength -lt 0 -or ($replacement.start + $replacement.oldLength) -gt $lines.Count) { Fail ('bulk replacement out of bounds for ' + $resolved) 23 }; if ($replacement.oldLength -gt 0) { $lines.RemoveRange($replacement.start, $replacement.oldLength) }; if ($replacement.newLines.Count -gt 0) { $lines.InsertRange($replacement.start, [string[]]$replacement.newLines) } };",
|
||||
" $outputText = if ($lines.Count -eq 0) { '' } else { ([string]::Join(\"`n\", [string[]]$lines) + \"`n\") };",
|
||||
" try { $outputBytes = $textEncoding.GetBytes($outputText) } catch { Fail ('bulk replacement final encode failed encoding=' + $encodingStyle + ' for ' + $resolved) 25 };",
|
||||
" if ($outputBytes.Length -ne $finalBytes -or (Get-Sha256Bytes $outputBytes) -ne $finalSha256) { Fail ('bulk replacement final integrity mismatch for ' + $resolved) 23 };",
|
||||
" $tmp = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($resolved), ('.' + [System.IO.Path]::GetFileName($resolved) + '.unidesk-v2-bulk-' + [guid]::NewGuid().ToString('N') + '.tmp')); [System.IO.File]::WriteAllBytes($tmp, $outputBytes); $plans.Add([pscustomobject]@{ target = $resolved; tmp = $tmp; sha256 = $finalSha256 }) | Out-Null;",
|
||||
" };",
|
||||
" foreach ($plan in $plans) { Ensure-Parent $plan.target; Move-Item -LiteralPath $plan.tmp -Destination $plan.target -Force; if ((Get-Sha256 $plan.target) -ne $plan.sha256) { Fail ('bulk replacement final sha256 mismatch for ' + $plan.target) 25 } };",
|
||||
" break",
|
||||
" }",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,26 @@ if ($operation -in @('head', 'tail')) {
|
||||
$paths.Add($arg)
|
||||
}
|
||||
if ($paths.Count -ne 1) { Fail ($operation + ' requires exactly one file path on Windows routes') 2 }
|
||||
$lines = Text-Lines (Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576)
|
||||
if ($operation -eq 'head') { Print-Lines ([string[]]($lines | Select-Object -First $linesWanted)) } else { Print-Lines ([string[]]($lines | Select-Object -Last $linesWanted)) }
|
||||
$resolved = Resolve-UnideskPath $paths[0]
|
||||
Need-File $resolved
|
||||
$selected = [Collections.Generic.List[string]]::new()
|
||||
$reader = [IO.StreamReader]::new($resolved, [Text.UTF8Encoding]::new($false, $true), $true)
|
||||
try {
|
||||
if ($operation -eq 'head') {
|
||||
while ($selected.Count -lt $linesWanted -and -not $reader.EndOfStream) { $selected.Add($reader.ReadLine()) }
|
||||
} else {
|
||||
$queue = [Collections.Generic.Queue[string]]::new()
|
||||
while (-not $reader.EndOfStream) {
|
||||
$queue.Enqueue($reader.ReadLine())
|
||||
if ($queue.Count -gt $linesWanted) { [void]$queue.Dequeue() }
|
||||
}
|
||||
foreach ($line in $queue) { $selected.Add($line) }
|
||||
}
|
||||
} catch {
|
||||
Fail ('file is not valid UTF-8: ' + $resolved + ': ' + $_.Exception.Message) 25
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
Print-Lines ([string[]]$selected)
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
if ($operation -ne 'rg') { Fail ('unsupported Windows fs operation: ' + $operation) 2 }
|
||||
|
||||
$ignoreCase = $false; $fixed = $false; $filesOnly = $false; $pattern = $null; $endOptions = $false
|
||||
$ignoreCase = $false; $fixed = $false; $filesOnly = $false; $endOptions = $false
|
||||
$maxCount = [int]$rgPolicy.maxMatches; $maxFiles = [int]$rgPolicy.maxFiles; $maxBytes = [int]$rgPolicy.maxBytesPerFile; $timeoutMs = [int]$rgPolicy.timeoutMs
|
||||
$paths = [Collections.Generic.List[string]]::new(); $globs = [Collections.Generic.List[string]]::new()
|
||||
$paths = [Collections.Generic.List[string]]::new(); $globs = [Collections.Generic.List[string]]::new(); $patterns = [Collections.Generic.List[string]]::new()
|
||||
for ($i = 0; $i -lt $toolArgs.Count; $i++) {
|
||||
$arg = $toolArgs[$i]
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--') { $endOptions = $true; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-i', '--ignore-case')) { $ignoreCase = $true; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-F', '--fixed-strings')) { $fixed = $true; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-n', '--line-number')) { continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--files') { $filesOnly = $true; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-g', '--glob')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $globs.Add($toolArgs[$i]); continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--glob=')) { $globs.Add($arg.Substring(7)); continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -in @('-m', '--max-count')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $maxCount = Parse-NonNegativeInt $arg $toolArgs[$i] 10000; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-m') -and $arg.Length -gt 2) { $maxCount = Parse-NonNegativeInt '-m' $arg.Substring(2) 10000; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-count=')) { $maxCount = Parse-NonNegativeInt '--max-count' $arg.Substring(12) 10000; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-files') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-files requires a value' 2 }; $maxFiles = Parse-NonNegativeInt '--max-files' $toolArgs[$i] 50000; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-files=')) { $maxFiles = Parse-NonNegativeInt '--max-files' $arg.Substring(12) 50000; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-NonNegativeInt '--max-bytes' $toolArgs[$i] 16777216; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-NonNegativeInt '--max-bytes' $arg.Substring(12) 16777216; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg -eq '--timeout-ms') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--timeout-ms requires a value' 2 }; $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $toolArgs[$i] 60000; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('--timeout-ms=')) { $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $arg.Substring(13) 60000; continue }
|
||||
if ($null -eq $pattern -and -not $endOptions -and $arg.StartsWith('-')) { Fail ('unsupported rg option on Windows route: ' + $arg + '; supported: --files -g/--glob -i -F -n -m/--max-count --max-files --max-bytes --timeout-ms') 2 }
|
||||
if ($filesOnly) { $paths.Add($arg) } elseif ($null -eq $pattern) { $pattern = $arg } else { $paths.Add($arg) }
|
||||
if (-not $endOptions -and $arg -eq '--') { $endOptions = $true; continue }
|
||||
if (-not $endOptions -and $arg -in @('-i', '--ignore-case')) { $ignoreCase = $true; continue }
|
||||
if (-not $endOptions -and $arg -in @('-F', '--fixed-strings')) { $fixed = $true; continue }
|
||||
if (-not $endOptions -and $arg -in @('-n', '--line-number')) { continue }
|
||||
if (-not $endOptions -and $arg -eq '--files') { $filesOnly = $true; continue }
|
||||
if (-not $endOptions -and $arg -in @('-e', '--regexp')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $patterns.Add($toolArgs[$i]); continue }
|
||||
if (-not $endOptions -and $arg.StartsWith('--regexp=')) { $patterns.Add($arg.Substring(9)); continue }
|
||||
if (-not $endOptions -and $arg -in @('-g', '--glob')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $globs.Add($toolArgs[$i]); continue }
|
||||
if (-not $endOptions -and $arg.StartsWith('--glob=')) { $globs.Add($arg.Substring(7)); continue }
|
||||
if (-not $endOptions -and $arg -in @('-m', '--max-count')) { $i += 1; if ($i -ge $toolArgs.Count) { Fail ($arg + ' requires a value') 2 }; $maxCount = Parse-NonNegativeInt $arg $toolArgs[$i] 10000; continue }
|
||||
if (-not $endOptions -and $arg.StartsWith('-m') -and $arg.Length -gt 2) { $maxCount = Parse-NonNegativeInt '-m' $arg.Substring(2) 10000; continue }
|
||||
if (-not $endOptions -and $arg.StartsWith('--max-count=')) { $maxCount = Parse-NonNegativeInt '--max-count' $arg.Substring(12) 10000; continue }
|
||||
if (-not $endOptions -and $arg -eq '--max-files') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-files requires a value' 2 }; $maxFiles = Parse-NonNegativeInt '--max-files' $toolArgs[$i] 50000; continue }
|
||||
if (-not $endOptions -and $arg.StartsWith('--max-files=')) { $maxFiles = Parse-NonNegativeInt '--max-files' $arg.Substring(12) 50000; continue }
|
||||
if (-not $endOptions -and $arg -eq '--max-bytes') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--max-bytes requires a value' 2 }; $maxBytes = Parse-NonNegativeInt '--max-bytes' $toolArgs[$i] 16777216; continue }
|
||||
if (-not $endOptions -and $arg.StartsWith('--max-bytes=')) { $maxBytes = Parse-NonNegativeInt '--max-bytes' $arg.Substring(12) 16777216; continue }
|
||||
if (-not $endOptions -and $arg -eq '--timeout-ms') { $i += 1; if ($i -ge $toolArgs.Count) { Fail '--timeout-ms requires a value' 2 }; $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $toolArgs[$i] 60000; continue }
|
||||
if (-not $endOptions -and $arg.StartsWith('--timeout-ms=')) { $timeoutMs = Parse-NonNegativeInt '--timeout-ms' $arg.Substring(13) 60000; continue }
|
||||
if (-not $endOptions -and $arg.StartsWith('-')) { Fail ('unsupported rg option on Windows route: ' + $arg + '; supported: --files -e/--regexp -g/--glob -i -F -n -m/--max-count --max-files --max-bytes --timeout-ms') 2 }
|
||||
if ($filesOnly) { $paths.Add($arg) } elseif ($patterns.Count -eq 0) { $patterns.Add($arg) } else { $paths.Add($arg) }
|
||||
}
|
||||
if (-not $filesOnly -and $null -eq $pattern) { Fail 'rg requires a pattern on Windows routes (or use --files)' 2 }
|
||||
if (-not $filesOnly -and $patterns.Count -eq 0) { Fail 'rg requires a pattern on Windows routes (or use --files)' 2 }
|
||||
if ($paths.Count -eq 0) { $paths.Add('.') }
|
||||
|
||||
$nativeRg = Get-Command rg.exe -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
@@ -36,12 +38,12 @@ if ($null -ne $nativeRg) {
|
||||
return $Value
|
||||
}
|
||||
$nativeArgs = [Collections.Generic.List[string]]::new()
|
||||
$nativeArgs.Add('--color=never'); $nativeArgs.Add('--no-heading'); $nativeArgs.Add('--line-number')
|
||||
$nativeArgs.Add('--color=never'); $nativeArgs.Add('--no-heading'); $nativeArgs.Add('--line-number'); $nativeArgs.Add('--with-filename')
|
||||
$nativeArgs.Add('--max-filesize'); $nativeArgs.Add([string]$maxBytes)
|
||||
if ($ignoreCase) { $nativeArgs.Add('--ignore-case') }
|
||||
if ($fixed) { $nativeArgs.Add('--fixed-strings') }
|
||||
foreach ($glob in $globs) { $nativeArgs.Add('--glob'); $nativeArgs.Add($glob) }
|
||||
if ($filesOnly) { $nativeArgs.Add('--files') } else { $nativeArgs.Add($pattern) }
|
||||
if ($filesOnly) { $nativeArgs.Add('--files') } else { foreach ($item in $patterns) { $nativeArgs.Add('--regexp'); $nativeArgs.Add($item) } }
|
||||
foreach ($raw in $paths) { $nativeArgs.Add((Resolve-UnideskPath $raw)) }
|
||||
$startInfo = [Diagnostics.ProcessStartInfo]::new()
|
||||
$startInfo.FileName = $nativeRg.Source
|
||||
@@ -59,9 +61,10 @@ if ($null -ne $nativeRg) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($stderrText)) { [Console]::Error.Write($stderrText) }
|
||||
$truncated = $lines.Count -gt $selected.Count
|
||||
$matchedFiles = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
if (-not $filesOnly) { foreach ($line in $selected) { if ($line -match '^([A-Za-z]:\\.*?):\d+:') { [void]$matchedFiles.Add($Matches[1]) } } }
|
||||
if (-not $filesOnly) { foreach ($line in $selected) { if ($line -match '^(.+?):\d+:') { [void]$matchedFiles.Add($Matches[1]) } } }
|
||||
$fileCount = if ($filesOnly) { $selected.Count } else { $matchedFiles.Count }
|
||||
[Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_SUMMARY matched=' + $selected.Count + ' fileCount=' + $fileCount + ' elapsedMs=' + $stopwatch.ElapsedMilliseconds + ' timeout=' + $timedOut.ToString().ToLowerInvariant() + ' skipped=0 fileLimit=' + ($filesOnly -and $truncated).ToString().ToLowerInvariant() + ' matchLimit=' + ((-not $filesOnly) -and $truncated).ToString().ToLowerInvariant() + ' engine=native-rg config=config/unidesk-cli.yaml#trans.windowsFs.rg')
|
||||
$encodingState = if ($stdoutText.Contains([char]0xfffd)) { 'legacy-or-invalid-utf8' } else { 'utf8-or-ascii' }
|
||||
[Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_SUMMARY matched=' + $selected.Count + ' fileCount=' + $fileCount + ' elapsedMs=' + $stopwatch.ElapsedMilliseconds + ' timeout=' + $timedOut.ToString().ToLowerInvariant() + ' encoding=' + $encodingState + ' skipped=0 fileLimit=' + ($filesOnly -and $truncated).ToString().ToLowerInvariant() + ' matchLimit=' + ((-not $filesOnly) -and $truncated).ToString().ToLowerInvariant() + ' engine=native-rg config=config/unidesk-cli.yaml#trans.windowsFs.rg')
|
||||
if ($timedOut) { [Console]::Error.WriteLine('UNIDESK_WINDOWS_RG_HINT code=timeout exitCode=124 action=narrow-scope-or-add-glob message=retry-with-a-narrower-path-or-g/--glob'); exit 124 }
|
||||
if ($process.ExitCode -ne 0) { exit $process.ExitCode }
|
||||
exit 0
|
||||
|
||||
+15
-1
@@ -103,6 +103,20 @@ describe("ssh windows fs read-only operations", () => {
|
||||
expect(rgScript).toContain("config/unidesk-cli.yaml#trans.windowsFs.rg");
|
||||
expect(rgScript).toContain("--files");
|
||||
expect(rgScript).toContain("--glob");
|
||||
expect(rgScript).toContain("--with-filename");
|
||||
expect(rgScript).toContain("encoding=");
|
||||
});
|
||||
|
||||
test("supports repeated rg patterns and streams large-file tail", () => {
|
||||
const rgScript = windowsFsReadOnlyScript("F:\\Work\\demo", "rg", ["-n", "-e", "alpha", "--regexp", "beta", "src/test.c"]);
|
||||
const tailScript = windowsFsReadOnlyScript("F:\\Work\\demo", "tail", ["-n", "40", ".state/request_history.jsonl"]);
|
||||
|
||||
expect(rgScript).toContain("$patterns.Add($toolArgs[$i])");
|
||||
expect(rgScript).toContain("$nativeArgs.Add('--regexp')");
|
||||
expect(rgScript).toContain("fileCount=");
|
||||
expect(tailScript).toContain("[IO.StreamReader]::new");
|
||||
expect(tailScript).toContain("[Collections.Generic.Queue[string]]::new()");
|
||||
expect(tailScript).not.toContain("Read-StrictText (Resolve-UnideskPath $paths[0]) 1048576");
|
||||
});
|
||||
|
||||
test("supports wc flags, exact skill lookup, and Windows cmd argv boundaries", () => {
|
||||
@@ -236,7 +250,7 @@ describe("ssh host apply-patch fs backend", () => {
|
||||
},
|
||||
]]));
|
||||
|
||||
expect(files.get("docs/test.md")).toBe(text);
|
||||
expect(files.get("docs/test.md")?.content).toBe(text);
|
||||
expect(calls.map((call) => call.operation)).toEqual(["read-bulk-b64", "apply-replacements-bulk-stdin"]);
|
||||
expect(calls[0]?.args).toEqual(["docs/test.md"]);
|
||||
expect(calls[1]?.args).toEqual(["1"]);
|
||||
|
||||
Reference in New Issue
Block a user