fix(server): compare integrity paths independently of unicode normalization (#31102)
* fix(server): compare integrity paths independently of unicode normalization The untracked-file check compared on-disk paths against the database byte for byte. macOS writes decomposed (NFD) filenames while an upload stores the composed (NFC) form, so every asset with a non-ASCII name came back from getAssetPathsByPaths under a different byte sequence, was never removed from the candidate set, and was reported as an untracked file. One reporter saw 14k false positives on a 55k library. Look the batch up under both normal forms and key the comparison on NFC, so a path that differs only in normalization is recognised as tracked. * test(server): cover normalized integrity lookup
This commit is contained in:
@@ -13,6 +13,35 @@ describe(IntegrityService.name, () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleUntrackedFiles', () => {
|
||||
const decomposedPath = '/data/upload/upload/user-id/Cafe\u{301}.jpg';
|
||||
const composedPath = '/data/upload/upload/user-id/Caf\u{E9}.jpg';
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.integrityReport.getAssetPathsByPaths.mockResolvedValue([]);
|
||||
mocks.integrityReport.getPersonThumbnailPathsByPaths.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scannedPath: decomposedPath, storedPath: composedPath },
|
||||
{ scannedPath: composedPath, storedPath: decomposedPath },
|
||||
])(
|
||||
'should not report a tracked asset whose path differs only by unicode normalization',
|
||||
async ({ scannedPath, storedPath }) => {
|
||||
mocks.integrityReport.getAssetPathsByPaths.mockResolvedValue([
|
||||
{ originalPath: storedPath, encodedVideoPath: null },
|
||||
]);
|
||||
|
||||
await sut.handleUntrackedFiles({ type: 'asset', paths: [scannedPath] });
|
||||
|
||||
expect(mocks.integrityReport.getAssetPathsByPaths).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([decomposedPath, composedPath]),
|
||||
);
|
||||
expect(mocks.integrityReport.create).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('handleUntrackedRefresh', () => {
|
||||
beforeEach(() => {
|
||||
mocks.integrityReport.getTrackedPaths.mockResolvedValue([]);
|
||||
|
||||
@@ -275,38 +275,42 @@ export class IntegrityService extends BaseService {
|
||||
async handleUntrackedFiles({ type, paths }: IIntegrityUntrackedFilesJob): Promise<JobStatus> {
|
||||
this.logger.log(`Processing batch of ${paths.length} files to check if they are untracked.`);
|
||||
|
||||
const untrackedFiles = new Set<string>(paths);
|
||||
const lookupPaths = [...new Set(paths.flatMap((path) => [path, path.normalize('NFC'), path.normalize('NFD')]))];
|
||||
|
||||
const trackedPaths = new Set<string>();
|
||||
if (type === 'asset') {
|
||||
const assets = await this.integrityRepository.getAssetPathsByPaths(paths);
|
||||
const assets = await this.integrityRepository.getAssetPathsByPaths(lookupPaths);
|
||||
for (const { originalPath, encodedVideoPath } of assets) {
|
||||
untrackedFiles.delete(originalPath);
|
||||
trackedPaths.add(originalPath.normalize('NFC'));
|
||||
|
||||
if (encodedVideoPath) {
|
||||
untrackedFiles.delete(encodedVideoPath);
|
||||
trackedPaths.add(encodedVideoPath.normalize('NFC'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const assets = await this.integrityRepository.getAssetFilePathsByPaths(paths);
|
||||
const assets = await this.integrityRepository.getAssetFilePathsByPaths(lookupPaths);
|
||||
for (const { path } of assets) {
|
||||
untrackedFiles.delete(path);
|
||||
trackedPaths.add(path.normalize('NFC'));
|
||||
}
|
||||
}
|
||||
|
||||
const personThumbnailPaths = await this.integrityRepository.getPersonThumbnailPathsByPaths(paths);
|
||||
const personThumbnailPaths = await this.integrityRepository.getPersonThumbnailPathsByPaths(lookupPaths);
|
||||
for (const { thumbnailPath } of personThumbnailPaths) {
|
||||
untrackedFiles.delete(thumbnailPath);
|
||||
trackedPaths.add(thumbnailPath.normalize('NFC'));
|
||||
}
|
||||
|
||||
if (untrackedFiles.size > 0) {
|
||||
const untrackedFiles = [...new Set(paths)].filter((path) => !trackedPaths.has(path.normalize('NFC')));
|
||||
|
||||
if (untrackedFiles.length > 0) {
|
||||
await this.integrityRepository.create(
|
||||
[...untrackedFiles].map((path) => ({
|
||||
untrackedFiles.map((path) => ({
|
||||
type: IntegrityReport.UntrackedFile,
|
||||
path,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Processed ${paths.length} and found ${untrackedFiles.size} untracked file(s).`);
|
||||
this.logger.log(`Processed ${paths.length} and found ${untrackedFiles.length} untracked file(s).`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user