diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts index 98e9867794..9efde8d3bd 100644 --- a/src/storage/accessors/FileDataAccessor.ts +++ b/src/storage/accessors/FileDataAccessor.ts @@ -142,7 +142,8 @@ export class FileDataAccessor implements DataAccessor { } /** - * Gets the Stats object corresponding to the given file path. + * Gets the Stats object corresponding to the given file path, + * resolving symbolic links. * @param path - File path to get info from. * * @throws NotFoundHttpError @@ -150,7 +151,7 @@ export class FileDataAccessor implements DataAccessor { */ private async getStats(path: string): Promise { try { - return await fsPromises.lstat(path); + return await fsPromises.stat(path); } catch (error: unknown) { if (isSystemError(error) && error.code === 'ENOENT') { throw new NotFoundHttpError('', { cause: error }); @@ -273,16 +274,23 @@ export class FileDataAccessor implements DataAccessor { // For every child in the container we want to generate specific metadata for await (const entry of dir) { - const childName = entry.name; + // Obtain details of the entry, resolving any symbolic links + const childPath = joinFilePath(link.filePath, entry.name); + let childStats; + try { + childStats = await this.getStats(childPath); + } catch { + // Skip this entry if details could not be retrieved (e.g., bad symbolic link) + continue; + } // Ignore non-file/directory entries in the folder - if (!entry.isFile() && !entry.isDirectory()) { + if (!childStats.isFile() && !childStats.isDirectory()) { continue; } // Generate the URI corresponding to the child resource - const childLink = await this.resourceMapper - .mapFilePathToUrl(joinFilePath(link.filePath, childName), entry.isDirectory()); + const childLink = await this.resourceMapper.mapFilePathToUrl(childPath, childStats.isDirectory()); // Hide metadata files if (childLink.isMetadata) { @@ -290,7 +298,6 @@ export class FileDataAccessor implements DataAccessor { } // Generate metadata of this specific child - const childStats = await fsPromises.lstat(joinFilePath(link.filePath, childName)); const metadata = new RepresentationMetadata(childLink.identifier); addResourceMetadata(metadata, childStats.isDirectory()); this.addPosixMetadata(metadata, childStats); diff --git a/test/unit/storage/accessors/FileDataAccessor.test.ts b/test/unit/storage/accessors/FileDataAccessor.test.ts index 016b8d0e0c..94cf76225e 100644 --- a/test/unit/storage/accessors/FileDataAccessor.test.ts +++ b/test/unit/storage/accessors/FileDataAccessor.test.ts @@ -117,7 +117,14 @@ describe('A FileDataAccessor', (): void => { }); it('generates the metadata for a container.', async(): Promise => { - cache.data = { container: { resource: 'data', 'resource.meta': 'metadata', notAFile: 5, container2: {}}}; + cache.data = { + container: { + resource: 'data', + 'resource.meta': 'metadata', + notAFile: 5, + container2: {}, + }, + }; metadata = await accessor.getMetadata({ path: `${base}container/` }); expect(metadata.identifier.value).toBe(`${base}container/`); expect(metadata.getAll(RDF.type)).toEqualRdfTermArray( @@ -131,15 +138,50 @@ describe('A FileDataAccessor', (): void => { }); it('generates metadata for container child resources.', async(): Promise => { - cache.data = { container: { resource: 'data', 'resource.meta': 'metadata', notAFile: 5, container2: {}}}; + cache.data = { + container: { + resource: 'data', + 'resource.meta': 'metadata', + symlink: Symbol(`${rootFilePath}/container/resource`), + symlinkContainer: Symbol(`${rootFilePath}/container/container2`), + symlinkInvalid: Symbol(`${rootFilePath}/invalid`), + notAFile: 5, + container2: {}, + }, + }; + const children = []; for await (const child of accessor.getChildren({ path: `${base}container/` })) { children.push(child); } - expect(children).toHaveLength(2); + + // Identifiers + expect(children).toHaveLength(4); + expect(new Set(children.map((child): string => child.identifier.value))).toEqual(new Set([ + `${base}container/container2/`, + `${base}container/resource`, + `${base}container/symlink`, + `${base}container/symlinkContainer/`, + ])); + + // Containers + for (const child of children.filter(({ identifier }): boolean => identifier.value.endsWith('/'))) { + const types = child.getAll(RDF.type).map((term): string => term.value); + expect(types).toContain(LDP.Resource); + expect(types).toContain(LDP.Container); + expect(types).toContain(LDP.BasicContainer); + } + + // Documents + for (const child of children.filter(({ identifier }): boolean => !identifier.value.endsWith('/'))) { + const types = child.getAll(RDF.type).map((term): string => term.value); + expect(types).toContain(LDP.Resource); + expect(types).not.toContain(LDP.Container); + expect(types).not.toContain(LDP.BasicContainer); + } + + // All resources for (const child of children) { - expect([ `${base}container/resource`, `${base}container/container2/` ]).toContain(child.identifier.value); - expect(child.getAll(RDF.type)!.some((type): boolean => type.equals(LDP.terms.Resource))).toBe(true); expect(child.get(DC.modified)).toEqualRdfTerm(toLiteral(now.toISOString(), XSD.terms.dateTime)); expect(child.get(POSIX.mtime)).toEqualRdfTerm(toLiteral(Math.floor(now.getTime() / 1000), XSD.terms.integer)); diff --git a/test/util/Util.ts b/test/util/Util.ts index 4440e690f3..c87b934a44 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -109,6 +109,9 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { return stream; }, promises: { + async stat(path: string): Promise { + return this.lstat(await this.realpath(path)); + }, async lstat(path: string): Promise { const { folder, name } = getFolder(path); if (!folder[name]) { @@ -117,6 +120,7 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { return { isFile: (): boolean => typeof folder[name] === 'string', isDirectory: (): boolean => typeof folder[name] === 'object', + isSymbolicLink: (): boolean => typeof folder[name] === 'symbol', size: typeof folder[name] === 'string' ? folder[name].length : 0, mtime: time, } as Stats; @@ -132,6 +136,15 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete folder[name]; }, + async symlink(target: string, path: string): Promise { + const { folder, name } = getFolder(path); + folder[name] = Symbol(target); + }, + async realpath(path: string): Promise { + const { folder, name } = getFolder(path); + const entry = folder[name]; + return typeof entry === 'symbol' ? entry.description ?? 'invalid' : path; + }, async rmdir(path: string): Promise { const { folder, name } = getFolder(path); if (!folder[name]) { @@ -158,11 +171,12 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { if (!folder[name]) { throwSystemError('ENOENT'); } - for (const child of Object.keys(folder[name])) { + for (const [ child, entry ] of Object.entries(folder[name])) { yield { name: child, - isFile: (): boolean => typeof folder[name][child] === 'string', - isDirectory: (): boolean => typeof folder[name][child] === 'object', + isFile: (): boolean => typeof entry === 'string', + isDirectory: (): boolean => typeof entry === 'object', + isSymbolicLink: (): boolean => typeof entry === 'symbol', } as Dirent; } },