Skip to content

Commit

Permalink
fix: attachment fs operations (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
noomorph committed May 23, 2024
1 parent 3c0ac20 commit 44a9efa
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 6 deletions.
7 changes: 2 additions & 5 deletions src/runtime/attachment-handlers/moveHandler.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import path from 'node:path';
import fs from 'node:fs/promises';

import type { FileAttachmentHandler } from '../types';
import { fastMove } from '../../utils';

import { placeAttachment } from './placeAttachment';

export const moveHandler: FileAttachmentHandler = async (context) => {
const destination = placeAttachment(context);
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.rename(context.sourcePath, destination);
await fastMove(context.sourcePath, destination);
return destination;
};
3 changes: 2 additions & 1 deletion src/runtime/attachment-handlers/placeAttachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import path from 'node:path';
import { randomUUID } from 'node:crypto';

import type { AttachmentContext, ContentAttachmentContext, FileAttachmentContext } from '../types';
import { getFullExtension } from '../../utils';

export function placeAttachment(context: AttachmentContext): string {
const { outDir, name, sourcePath } = context as FileAttachmentContext & ContentAttachmentContext;
const fileName = name || sourcePath || '';
return path.join(outDir, randomUUID() + path.extname(fileName));
return path.join(outDir, randomUUID() + getFullExtension(fileName));
}
55 changes: 55 additions & 0 deletions src/utils/fastMove.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';

import { fastMove } from './fastMove';

describe('fastMove', () => {
let rootDirectory: string;
let source: string;
let destination: string;

beforeEach(() => {
jest.spyOn(fs, 'copyFile');
jest.spyOn(fs, 'rm');
});

afterEach(() => {
jest.restoreAllMocks();
});

beforeEach(async () => {
rootDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'fastmove-'));
source = path.join(rootDirectory, 'source.txt');
destination = path.join(rootDirectory, 'destination.txt');
await fs.writeFile(source, 'Test content');
});

afterEach(async () => {
await fs.rm(rootDirectory, { recursive: true });
});

it('should move the file to the destination', async () => {
await fastMove(source, destination);

const movedFile = await fs.readFile(destination, 'utf8');
expect(movedFile).toBe('Test content');
await expect(fs.access(source)).rejects.toThrow();
expect(fs.copyFile).not.toHaveBeenCalled();
expect(fs.rm).not.toHaveBeenCalled();
});

it('should handle cross-device file movement', async () => {
jest
.spyOn(fs, 'rename')
.mockRejectedValueOnce({ code: 'EXDEV', message: 'cross-device link not permitted' });

await fastMove(source, destination);

const movedFile = await fs.readFile(destination, 'utf8');
expect(movedFile).toBe('Test content');
await expect(fs.access(source)).rejects.toThrow();
expect(fs.copyFile).toHaveBeenCalled();
expect(fs.rm).toHaveBeenCalled();
});
});
13 changes: 13 additions & 0 deletions src/utils/fastMove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import path from 'node:path';
import fs from 'node:fs/promises';

export async function fastMove(source: string, destination: string) {
await fs.mkdir(path.dirname(destination), { recursive: true });

try {
await fs.rename(source, destination);
} catch {
await fs.copyFile(source, destination);
await fs.rm(source, { force: true });
}
}
33 changes: 33 additions & 0 deletions src/utils/getFullExtension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getFullExtension } from './getFullExtension';

describe('getFullExtension', () => {
it('should return the full extension for a file with multiple dots', () => {
const filePath = 'example.viewhierarchy.zip';
const extension = getFullExtension(filePath);
expect(extension).toBe('.viewhierarchy.zip');
});

it('should return the extension for a file with a single dot', () => {
const filePath = 'example.txt';
const extension = getFullExtension(filePath);
expect(extension).toBe('.txt');
});

it('should return the extension for a file starting with a dot', () => {
const filePath = '.gitignore';
const extension = getFullExtension(filePath);
expect(extension).toBe('.gitignore');
});

it('should return an empty string for a file without an extension', () => {
const filePath = 'example';
const extension = getFullExtension(filePath);
expect(extension).toBe('');
});

it('should return an empty string for empty or dot paths', () => {
expect(getFullExtension('')).toBe('');
expect(getFullExtension('.')).toBe('');
expect(getFullExtension('..')).toBe('');
});
});
10 changes: 10 additions & 0 deletions src/utils/getFullExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import path from 'node:path';

export function getFullExtension(filePath: string) {
if (!filePath || filePath === '.' || filePath === '..') return '';

const fileName = path.basename(filePath);
const lastDotIndex = fileName.indexOf('.');

return lastDotIndex === -1 ? '' : fileName.slice(lastDotIndex);
}
2 changes: 2 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ export * from './asArray';
export * from './asMaybeArray';
export * from './autoIndent';
export * from './compactObject';
export * from './fastMove';
export * from './FileNavigator';
export * from './getFullExtension';
export * from './getStatusDetails';
export * from './hijackFunction';
export * from './importFrom';
Expand Down

0 comments on commit 44a9efa

Please sign in to comment.