Files
Immich/server/test/medium/specs/repositories/storage.repository.spec.ts
T
Jonathan JogenforsandCodex 42dffaa0c8 fix(server): dont follow symlinks (#32614)
(cherry picked from commit 10e216b8385ac40e6aa150f75fc4730c4e8a7494)
2026-10-22 05:24:55 +03:00

185 lines
6.4 KiB
TypeScript

import { mkdir, mkdtemp, rename, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { vitest } from 'vitest';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { StorageRepository, type WatchOptions } from 'src/repositories/storage.repository.js';
import { automock } from 'test/utils.js';
describe(StorageRepository.name, () => {
const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '='];
let sut: StorageRepository;
let tempDir: string;
let includedFile: string;
let excludedFile: string;
beforeEach(async () => {
// eslint-disable-next-line no-sparse-arrays
sut = new StorageRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
tempDir = await mkdtemp(join(tmpdir(), 'immich-storage-watch-'));
includedFile = join(tempDir, 'included/photo.jpg');
excludedFile = join(tempDir, 'excluded/photo.jpg');
await Promise.all([
mkdir(join(tempDir, 'included'), { recursive: true }),
mkdir(join(tempDir, 'excluded'), { recursive: true }),
]);
await Promise.all([writeFile(includedFile, 'included'), writeFile(excludedFile, 'excluded')]);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
const startWatching = (options: WatchOptions) => {
const paths: string[] = [];
const changedPaths: string[] = [];
const unlinkedPaths: string[] = [];
let resolveReady!: () => void;
const ready = new Promise<void>((resolve) => (resolveReady = resolve));
const close = sut.watch([tempDir], options, {
onAdd: (path) => {
paths.push(path);
},
onChange: (path) => {
changedPaths.push(path);
},
onUnlink: (path) => {
unlinkedPaths.push(path);
},
onReady: resolveReady,
});
return { close, paths, changedPaths, unlinkedPaths, ready };
};
it('should emit add events for initial files', async () => {
const watcher = startWatching({ ignoreInitial: false });
try {
await watcher.ready;
expect(watcher.paths.toSorted()).toEqual([includedFile, excludedFile].toSorted());
} finally {
await watcher.close();
}
});
it('should not emit add events for ignored initial files', async () => {
const watcher = startWatching({ ignoreInitial: false, ignored: ['**/excluded/**'] });
try {
await watcher.ready;
expect(watcher.paths).toEqual([includedFile]);
} finally {
await watcher.close();
}
});
it('should ignore symbolic links to files and directories', async () => {
const outsideDir = await mkdtemp(join(tmpdir(), 'immich-storage-outside-'));
try {
await writeFile(join(outsideDir, 'secret.txt'), 'secret');
await writeFile(join(outsideDir, 'photo.jpg'), 'outside');
await symlink(join(outsideDir, 'secret.txt'), join(tempDir, 'file-link.jpg'));
await symlink(outsideDir, join(tempDir, 'directory-link'));
await symlink(includedFile, join(tempDir, 'internal-file-link.jpg'));
await symlink(join(tempDir, 'included'), join(tempDir, 'internal-directory-link'));
const watcher = startWatching({ ignoreInitial: false });
try {
await watcher.ready;
expect(watcher.paths.toSorted()).toEqual([includedFile, excludedFile].toSorted());
} finally {
await watcher.close();
}
} finally {
await rm(outsideDir, { recursive: true, force: true });
}
});
it.each([
{ type: 'file', action: 'created' },
{ type: 'directory', action: 'created' },
{ type: 'file', action: 'retargeted' },
{ type: 'directory', action: 'retargeted' },
])('should ignore $type symlinks $action after the watcher is ready', async ({ type, action }) => {
const outsideDir = await mkdtemp(join(tmpdir(), 'immich-storage-outside-'));
try {
const targetDir = join(outsideDir, 'target');
const targetFile = join(targetDir, 'photo.jpg');
await mkdir(targetDir);
await writeFile(targetFile, 'outside');
const link = join(tempDir, type === 'file' ? 'link.jpg' : 'link');
const target = type === 'file' ? targetFile : targetDir;
if (action === 'retargeted') {
await symlink(type === 'file' ? includedFile : join(tempDir, 'included'), link);
}
// Use real Chokidar events without relying on platform-specific native watcher limits.
const watcher = startWatching({ ignoreInitial: true, usePolling: true, interval: 50 });
try {
await watcher.ready;
if (action === 'retargeted') {
const replacementLink = join(outsideDir, 'replacement-link');
await symlink(target, replacementLink);
await rename(replacementLink, link);
} else {
await symlink(target, link);
}
// Confirm the watcher processes real file events before checking for unwanted symlink events.
const newFile = join(tempDir, 'new-photo.jpg');
await writeFile(newFile, 'new');
await writeFile(includedFile, 'changed inside');
await vitest.waitFor(
() => {
expect(watcher.paths).toContain(newFile);
expect(watcher.changedPaths).toContain(includedFile);
},
{ timeout: 3000 },
);
await writeFile(targetFile, 'changed outside');
// Allow deferred filesystem events and Chokidar's directory rescan throttle to settle.
await setTimeout(1500);
expect(watcher.paths).toEqual([newFile]);
expect(watcher.changedPaths).toEqual([includedFile]);
expect(watcher.unlinkedPaths).toEqual([]);
} finally {
await watcher.close();
}
} finally {
await rm(outsideDir, { recursive: true, force: true });
}
});
it.each(annoyingExclusionPatterns)('should ignore folders with %s in their name', async (char) => {
const ignoredFolder = `${char}folder`;
const ignoredFile = join(tempDir, ignoredFolder, 'photo.jpg');
await mkdir(join(tempDir, ignoredFolder), { recursive: true });
await writeFile(ignoredFile, 'ignored');
const watcher = startWatching({ ignoreInitial: false, ignored: [`**/${ignoredFolder}/**`] });
try {
await watcher.ready;
expect(watcher.paths.toSorted()).toEqual([includedFile, excludedFile].toSorted());
} finally {
await watcher.close();
}
});
});