Skip to content

Commit

Permalink
feat: Display symlinks in container listings.
Browse files Browse the repository at this point in the history
Closes #1015
  • Loading branch information
RubenVerborgh committed Oct 21, 2021
1 parent 7f682d7 commit 5356afe
Show file tree
Hide file tree
Showing 3 changed files with 78 additions and 15 deletions.
21 changes: 14 additions & 7 deletions src/storage/accessors/FileDataAccessor.ts
Expand Up @@ -142,15 +142,16 @@ 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
* If the file/folder doesn't exist.
*/
private async getStats(path: string): Promise<Stats> {
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 });
Expand Down Expand Up @@ -273,24 +274,30 @@ 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) {
continue;
}

// 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);
Expand Down
52 changes: 47 additions & 5 deletions test/unit/storage/accessors/FileDataAccessor.test.ts
Expand Up @@ -117,7 +117,14 @@ describe('A FileDataAccessor', (): void => {
});

it('generates the metadata for a container.', async(): Promise<void> => {
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(
Expand All @@ -131,15 +138,50 @@ describe('A FileDataAccessor', (): void => {
});

it('generates metadata for container child resources.', async(): Promise<void> => {
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));
Expand Down
20 changes: 17 additions & 3 deletions test/util/Util.ts
Expand Up @@ -109,6 +109,9 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } {
return stream;
},
promises: {
async stat(path: string): Promise<Stats> {
return this.lstat(await this.realpath(path));
},
async lstat(path: string): Promise<Stats> {
const { folder, name } = getFolder(path);
if (!folder[name]) {
Expand All @@ -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;
Expand All @@ -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<void> {
const { folder, name } = getFolder(path);
folder[name] = Symbol(target);
},
async realpath(path: string): Promise<string> {
const { folder, name } = getFolder(path);
const entry = folder[name];
return typeof entry === 'symbol' ? entry.description ?? 'invalid' : path;
},
async rmdir(path: string): Promise<void> {
const { folder, name } = getFolder(path);
if (!folder[name]) {
Expand All @@ -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;
}
},
Expand Down

0 comments on commit 5356afe

Please sign in to comment.