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:
Aditya Raj Singh
2026-09-23 06:42:31 -04:00
committed by GitHub
parent 5689b0c8f6
commit 351ddff320
2 changed files with 44 additions and 11 deletions
@@ -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([]);
+15 -11
View File
@@ -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;
}