Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
// Pure utility functions
formatSize,
normalizeLineEndings,
stripTrailingWhitespace,
createUnifiedDiff,
// Security & validation functions
validatePath,
Expand Down Expand Up @@ -308,6 +309,55 @@ describe('Lib Functions', () => {

expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' });
});

it('strips trailing whitespace from each line before writing', async () => {
mockFs.writeFile.mockResolvedValueOnce(undefined);

await writeFileContent('/test/file.txt', 'line1 \nline2\t\nline3');

expect(mockFs.writeFile).toHaveBeenCalledWith(
'/test/file.txt',
'line1\nline2\nline3',
{ encoding: "utf-8", flag: 'wx' }
);
});

it('strips trailing whitespace on the EEXIST/atomic-rename fallback path too', async () => {
const eexistError = Object.assign(new Error('exists'), { code: 'EEXIST' });
mockFs.writeFile.mockRejectedValueOnce(eexistError);
mockFs.writeFile.mockResolvedValueOnce(undefined);
mockFs.rename.mockResolvedValueOnce(undefined);

await writeFileContent('/test/file.txt', 'line1 \nline2 ');

expect(mockFs.writeFile).toHaveBeenLastCalledWith(
expect.stringContaining('/test/file.txt.'),
'line1\nline2',
'utf-8'
);
});
});

describe('stripTrailingWhitespace', () => {
it('removes trailing spaces and tabs from each line', () => {
const input = 'line1 \nline2\t\t\nline3';
expect(stripTrailingWhitespace(input)).toBe('line1\nline2\nline3');
});

it('leaves leading whitespace untouched', () => {
const input = ' indented line \n\tmixed indent\t';
expect(stripTrailingWhitespace(input)).toBe(' indented line\n\tmixed indent');
});

it('does not change content with no trailing whitespace', () => {
const input = 'line1\nline2\nline3';
expect(stripTrailingWhitespace(input)).toBe(input);
});

it('preserves blank lines and trailing newline', () => {
const input = 'line1 \n\nline3 \n';
expect(stripTrailingWhitespace(input)).toBe('line1\n\nline3\n');
});
});

});
Expand Down Expand Up @@ -510,6 +560,60 @@ describe('Lib Functions', () => {
);
});

it('strips trailing whitespace from the new text of an edit', async () => {
const edits = [
{ oldText: 'line2', newText: 'modified line2 \t' }
];

mockFs.rename.mockResolvedValueOnce(undefined);

await applyFileEdits('/test/file.txt', edits, false);

expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'line1\nmodified line2\nline3\n',
'utf-8'
);
});

it('does not touch trailing whitespace on lines outside the edit', async () => {
// line3 has trailing spaces but is never targeted by an edit
mockFs.readFile.mockResolvedValue('line1\nline2\nline3 \n');

const edits = [
{ oldText: 'line1', newText: 'first line' }
];

mockFs.rename.mockResolvedValueOnce(undefined);

await applyFileEdits('/test/file.txt', edits, false);

expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'first line\nline2\nline3 \n',
'utf-8'
);
});

it('strips trailing whitespace from multi-line replacement text', async () => {
const edits = [
{
oldText: 'line1\nline2',
newText: 'first line \nsecond line\t'
}
];

mockFs.rename.mockResolvedValueOnce(undefined);

await applyFileEdits('/test/file.txt', edits, false);

expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'first line\nsecond line\nline3\n',
'utf-8'
);
});

it('throws error for non-matching edits', async () => {
const edits = [
{ oldText: 'nonexistent line', newText: 'replacement' }
Expand Down
24 changes: 21 additions & 3 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n');
}

// Strips trailing horizontal whitespace (spaces/tabs) from every line.
// Applied only to content that is actually being written, so callers
// that pass already-clean content see no behavioral change, and edits
// never touch whitespace on lines outside the edit itself.
export function stripTrailingWhitespace(text: string): string {
return text
.split('\n')
.map(line => line.replace(/[ \t]+$/, ''))
.join('\n');
}

export function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
// Ensure consistent line endings for diff
const normalizedOriginal = normalizeLineEndings(originalContent);
Expand Down Expand Up @@ -159,18 +170,22 @@ export async function readFileContent(filePath: string, encoding: string = 'utf-
}

export async function writeFileContent(filePath: string, content: string): Promise<void> {
// Strip trailing whitespace from every line before writing. This is the
// content the caller is asking us to write, so cleaning it here avoids
// the trailing-whitespace/lint churn reported in #1590.
const cleanedContent = stripTrailingWhitespace(content);
try {
// Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists,
// preventing writes through pre-existing symlinks
await fs.writeFile(filePath, content, { encoding: "utf-8", flag: 'wx' });
await fs.writeFile(filePath, cleanedContent, { encoding: "utf-8", flag: 'wx' });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
// Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks.
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try {
await fs.writeFile(tempPath, content, 'utf-8');
await fs.writeFile(tempPath, cleanedContent, 'utf-8');
await fs.rename(tempPath, filePath);
} catch (renameError) {
try {
Expand Down Expand Up @@ -203,7 +218,10 @@ export async function applyFileEdits(
let modifiedContent = content;
for (const edit of edits) {
const normalizedOld = normalizeLineEndings(edit.oldText);
const normalizedNew = normalizeLineEndings(edit.newText);
// Strip trailing whitespace from the incoming replacement text only -
// this is the content the edit is introducing, so it's safe to clean
// without touching whitespace on unrelated lines elsewhere in the file.
const normalizedNew = stripTrailingWhitespace(normalizeLineEndings(edit.newText));

// If exact match exists, use it
if (modifiedContent.includes(normalizedOld)) {
Expand Down