Skip to content

Commit 2531df0

Browse files
committed
fix(filesystem): use StringDecoder for UTF-8 safe headFile/tailFile
headFile() and tailFile() read files in 1024-byte chunks and called .toString('utf-8') on each chunk independently. When a multi-byte UTF-8 character straddled a chunk boundary, the split decoding produced mojibake (U+FFFD replacement characters). Fix headFile by using StringDecoder across sequential reads so that incomplete trailing byte sequences are buffered and completed on the next write() call. Fix tailFile by collecting raw byte buffers from backwards reads, then decoding the concatenated buffer (in forward order) as a single UTF-8 stream with StringDecoder. Newline counting is done on raw bytes (0x0A is single-byte in UTF-8) so we stop reading at the right point. Fixes #4666
1 parent 599dafc commit 2531df0

1 file changed

Lines changed: 37 additions & 29 deletions

File tree

src/filesystem/lib.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from 'os';
44
import { randomBytes } from 'crypto';
55
import { diffLines, createTwoFilesPatch } from 'diff';
66
import { minimatch } from 'minimatch';
7+
import { StringDecoder } from 'string_decoder';
78
import { normalizePath, expandHome } from './path-utils.js';
89
import { isPathWithinAllowedDirectories } from './path-validation.js';
910

@@ -281,9 +282,9 @@ export async function applyFileEdits(
281282
return formattedDiff;
282283
}
283284

284-
// Memory-efficient implementation to get the last N lines of a file
285+
// Read the last N lines of a file (UTF-8 safe)
285286
export async function tailFile(filePath: string, numLines: number): Promise<string> {
286-
const CHUNK_SIZE = 1024; // Read 1KB at a time
287+
const CHUNK_SIZE = 1024;
287288
const stats = await fs.stat(filePath);
288289
const fileSize = stats.size;
289290

@@ -292,62 +293,68 @@ export async function tailFile(filePath: string, numLines: number): Promise<stri
292293
// Open file for reading
293294
const fileHandle = await fs.open(filePath, 'r');
294295
try {
295-
const lines: string[] = [];
296+
// Collect raw byte buffers (read backwards from the end)
297+
const rawBuffers: Buffer[] = [];
296298
let position = fileSize;
297-
let chunk = Buffer.alloc(CHUNK_SIZE);
298-
let linesFound = 0;
299-
let remainingText = '';
299+
const chunk = Buffer.alloc(CHUNK_SIZE);
300+
let newlineCount = 0;
300301

301-
// Read chunks from the end of the file until we have enough lines
302-
while (position > 0 && linesFound < numLines) {
302+
// Read from the end until we have enough lines or reach start
303+
while (position > 0 && newlineCount <= numLines) {
303304
const size = Math.min(CHUNK_SIZE, position);
304305
position -= size;
305306

306307
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
307308
if (!bytesRead) break;
308309

309-
// Get the chunk as a string and prepend any remaining text from previous iteration
310-
const readData = chunk.slice(0, bytesRead).toString('utf-8');
311-
const chunkText = readData + remainingText;
312-
313-
// Split by newlines and count
314-
const chunkLines = normalizeLineEndings(chunkText).split('\n');
310+
const data = chunk.slice(0, bytesRead);
311+
rawBuffers.push(data);
315312

316-
// If this isn't the end of the file, the first line is likely incomplete
317-
// Save it to prepend to the next chunk
318-
if (position > 0) {
319-
remainingText = chunkLines[0];
320-
chunkLines.shift(); // Remove the first (incomplete) line
321-
}
322-
323-
// Add lines to our result (up to the number we need)
324-
for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) {
325-
lines.unshift(chunkLines[i]);
326-
linesFound++;
313+
// Count newlines in raw bytes (0x0A is single-byte even in UTF-8)
314+
for (let i = 0; i < data.length; i++) {
315+
if (data[i] === 0x0A) newlineCount++;
327316
}
328317
}
329318

330-
return lines.join('\n');
319+
// Reverse to get forward chronological order
320+
rawBuffers.reverse();
321+
322+
// Decode the concatenated buffer with StringDecoder (handles cross-chunk UTF-8)
323+
const decoder = new StringDecoder('utf-8');
324+
const fullText = rawBuffers.map(b => decoder.write(b)).join('');
325+
const finalChunk = decoder.end();
326+
327+
// Split into lines and take the last numLines
328+
const allLines = normalizeLineEndings(fullText + finalChunk).split('\n');
329+
330+
// Filter out the last empty element if the file ends with newline
331+
const relevantLines = allLines.filter(Boolean).length > numLines
332+
? allLines.slice(-numLines - 1) // include empty trailing line
333+
: allLines;
334+
335+
return allLines.slice(-numLines).join('\n');
331336
} finally {
332337
await fileHandle.close();
333338
}
339+
} }
334340
}
335341

336-
// New function to get the first N lines of a file
342+
// Read the first N lines of a file (UTF-8 safe)
337343
export async function headFile(filePath: string, numLines: number): Promise<string> {
338344
const fileHandle = await fs.open(filePath, 'r');
339345
try {
340346
const lines: string[] = [];
341347
let buffer = '';
342348
let bytesRead = 0;
343349
const chunk = Buffer.alloc(1024); // 1KB buffer
350+
const decoder = new StringDecoder('utf-8');
344351

345352
// Read chunks and count lines until we have enough or reach EOF
346353
while (lines.length < numLines) {
347354
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
348355
if (result.bytesRead === 0) break; // End of file
349356
bytesRead += result.bytesRead;
350-
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
357+
buffer += decoder.write(chunk.slice(0, result.bytesRead));
351358

352359
const newLineIndex = buffer.lastIndexOf('\n');
353360
if (newLineIndex !== -1) {
@@ -360,7 +367,8 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
360367
}
361368
}
362369

363-
// If there is leftover content and we still need lines, add it
370+
// Flush any remaining buffered bytes, and add leftover content if needed
371+
buffer += decoder.end();
364372
if (buffer.length > 0 && lines.length < numLines) {
365373
lines.push(buffer);
366374
}

0 commit comments

Comments
 (0)