156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { executeFileOps } from '../file-ops.js';
|
|
import { createTempDir, cleanup } from './test-helpers.js';
|
|
|
|
function shouldSkipSymlinkTests(err: unknown): boolean {
|
|
return !!(
|
|
err &&
|
|
typeof err === 'object' &&
|
|
'code' in err &&
|
|
(
|
|
(err as { code?: string }).code === 'EPERM' ||
|
|
(err as { code?: string }).code === 'EACCES' ||
|
|
(err as { code?: string }).code === 'ENOSYS'
|
|
)
|
|
);
|
|
}
|
|
|
|
describe('file-ops', () => {
|
|
let tmpDir: string;
|
|
const originalCwd = process.cwd();
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir();
|
|
process.chdir(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
it('rename success', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'old.ts'), 'content');
|
|
const result = executeFileOps([
|
|
{ type: 'rename', from: 'old.ts', to: 'new.ts' },
|
|
], tmpDir);
|
|
expect(result.success).toBe(true);
|
|
expect(fs.existsSync(path.join(tmpDir, 'new.ts'))).toBe(true);
|
|
expect(fs.existsSync(path.join(tmpDir, 'old.ts'))).toBe(false);
|
|
});
|
|
|
|
it('move success', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'file.ts'), 'content');
|
|
const result = executeFileOps([
|
|
{ type: 'move', from: 'file.ts', to: 'sub/file.ts' },
|
|
], tmpDir);
|
|
expect(result.success).toBe(true);
|
|
expect(fs.existsSync(path.join(tmpDir, 'sub', 'file.ts'))).toBe(true);
|
|
expect(fs.existsSync(path.join(tmpDir, 'file.ts'))).toBe(false);
|
|
});
|
|
|
|
it('delete success', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'remove-me.ts'), 'content');
|
|
const result = executeFileOps([
|
|
{ type: 'delete', path: 'remove-me.ts' },
|
|
], tmpDir);
|
|
expect(result.success).toBe(true);
|
|
expect(fs.existsSync(path.join(tmpDir, 'remove-me.ts'))).toBe(false);
|
|
});
|
|
|
|
it('rename target exists produces error', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'a');
|
|
fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'b');
|
|
const result = executeFileOps([
|
|
{ type: 'rename', from: 'a.ts', to: 'b.ts' },
|
|
], tmpDir);
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('delete missing file produces warning not error', () => {
|
|
const result = executeFileOps([
|
|
{ type: 'delete', path: 'nonexistent.ts' },
|
|
], tmpDir);
|
|
expect(result.success).toBe(true);
|
|
expect(result.warnings.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('move creates destination directory', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'content');
|
|
const result = executeFileOps([
|
|
{ type: 'move', from: 'src.ts', to: 'deep/nested/dir/src.ts' },
|
|
], tmpDir);
|
|
expect(result.success).toBe(true);
|
|
expect(fs.existsSync(path.join(tmpDir, 'deep', 'nested', 'dir', 'src.ts'))).toBe(true);
|
|
});
|
|
|
|
it('path escape produces error', () => {
|
|
fs.writeFileSync(path.join(tmpDir, 'file.ts'), 'content');
|
|
const result = executeFileOps([
|
|
{ type: 'rename', from: 'file.ts', to: '../../escaped.ts' },
|
|
], tmpDir);
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('source missing produces error for rename', () => {
|
|
const result = executeFileOps([
|
|
{ type: 'rename', from: 'missing.ts', to: 'new.ts' },
|
|
], tmpDir);
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('move rejects symlink escape to outside project root', () => {
|
|
const outsideDir = createTempDir();
|
|
|
|
try {
|
|
fs.symlinkSync(outsideDir, path.join(tmpDir, 'linkdir'));
|
|
} catch (err) {
|
|
cleanup(outsideDir);
|
|
if (shouldSkipSymlinkTests(err)) return;
|
|
throw err;
|
|
}
|
|
|
|
fs.writeFileSync(path.join(tmpDir, 'source.ts'), 'content');
|
|
|
|
const result = executeFileOps([
|
|
{ type: 'move', from: 'source.ts', to: 'linkdir/pwned.ts' },
|
|
], tmpDir);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(true);
|
|
expect(fs.existsSync(path.join(tmpDir, 'source.ts'))).toBe(true);
|
|
expect(fs.existsSync(path.join(outsideDir, 'pwned.ts'))).toBe(false);
|
|
|
|
cleanup(outsideDir);
|
|
});
|
|
|
|
it('delete rejects symlink escape to outside project root', () => {
|
|
const outsideDir = createTempDir();
|
|
const outsideFile = path.join(outsideDir, 'victim.ts');
|
|
fs.writeFileSync(outsideFile, 'secret');
|
|
|
|
try {
|
|
fs.symlinkSync(outsideDir, path.join(tmpDir, 'linkdir'));
|
|
} catch (err) {
|
|
cleanup(outsideDir);
|
|
if (shouldSkipSymlinkTests(err)) return;
|
|
throw err;
|
|
}
|
|
|
|
const result = executeFileOps([
|
|
{ type: 'delete', path: 'linkdir/victim.ts' },
|
|
], tmpDir);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(true);
|
|
expect(fs.existsSync(outsideFile)).toBe(true);
|
|
|
|
cleanup(outsideDir);
|
|
});
|
|
});
|