diff --git a/packages/build/src/BuildManager.ts b/packages/build/src/BuildManager.ts index b74de6c7..6484b180 100644 --- a/packages/build/src/BuildManager.ts +++ b/packages/build/src/BuildManager.ts @@ -1,8 +1,7 @@ import type { RuntimeConfiguration } from '@jitar/configuration'; import { Logger, LogLevel } from '@jitar/logging'; -import type { FileManager } from '@jitar/sourcing'; -import { Files, LocalFileManager } from '@jitar/sourcing'; +import { Files, FileManager } from '@jitar/sourcing'; import { ApplicationReader } from './source'; import { ApplicationBuilder } from './target'; @@ -22,9 +21,9 @@ export default class BuildManager { this.#logger = new Logger(logLevel); - this.#projectFileManager = new LocalFileManager('./'); - this.#sourceFileManager = new LocalFileManager(configuration.source); - this.#targetFileManager = new LocalFileManager(configuration.target); + this.#projectFileManager = new FileManager('./'); + this.#sourceFileManager = new FileManager(configuration.source); + this.#targetFileManager = new FileManager(configuration.target); this.#applicationReader = new ApplicationReader(this.#sourceFileManager); this.#applicationBuilder = new ApplicationBuilder(this.#targetFileManager, this.#logger); diff --git a/packages/cli/src/commands/StartServer.ts b/packages/cli/src/commands/StartServer.ts index 2fa87564..2f124da1 100644 --- a/packages/cli/src/commands/StartServer.ts +++ b/packages/cli/src/commands/StartServer.ts @@ -3,7 +3,7 @@ import { ConfigurationManager, RuntimeConfiguration, ServerConfiguration } from import { HttpRemoteBuilder, HttpServer } from '@jitar/http'; import { LogLevel, LogLevelParser } from '@jitar/logging'; import { ServerBuilder } from '@jitar/runtime'; -import { LocalFileManager, SourcingManager } from '@jitar/sourcing'; +import { FileManager, SourcingManager } from '@jitar/sourcing'; import ArgumentProcessor from '../ArgumentProcessor'; import Command from '../Command'; @@ -49,7 +49,7 @@ export default class StartServer implements Command { const [, , port] = serverConfiguration.url.split(':'); - const fileManager = new LocalFileManager(runtimeConfiguration.target); + const fileManager = new FileManager(runtimeConfiguration.target); const sourcingManager = new SourcingManager(fileManager); const remoteBuilder = new HttpRemoteBuilder(); const serverBuilder = new ServerBuilder(sourcingManager, remoteBuilder); diff --git a/packages/configuration/src/ConfigurationManager.ts b/packages/configuration/src/ConfigurationManager.ts index bb7f41bf..97c0c39e 100644 --- a/packages/configuration/src/ConfigurationManager.ts +++ b/packages/configuration/src/ConfigurationManager.ts @@ -1,6 +1,6 @@ import { Validator } from '@jitar/validation'; -import { LocalFileManager } from '@jitar/sourcing'; +import { FileManager } from '@jitar/sourcing'; import { EnvironmentConfigurator } from './environment'; import { RuntimeConfiguration, RuntimeConfigurationBuilder } from './runtime'; @@ -18,7 +18,7 @@ export default class ConfigurationManager constructor(rootPath: string = DEFAULT_ROOT_PATH) { - const fileManager = new LocalFileManager(rootPath); + const fileManager = new FileManager(rootPath); const reader = new ConfigurationReader(fileManager); const validator = new Validator(); diff --git a/packages/sourcing/src/FileManager.ts b/packages/sourcing/src/FileManager.ts new file mode 100644 index 00000000..44026cff --- /dev/null +++ b/packages/sourcing/src/FileManager.ts @@ -0,0 +1,123 @@ + +import InvalidLocation from './errors/InvalidLocation'; +import FileNotFound from './errors/FileNotFound'; + +import FileSystem from './interfaces/FileSystem'; + +import File from './models/File'; + +import LocalFileSystem from './LocalFileSystem'; + +const DEFAULT_MIME_TYPE = 'application/octet-stream'; + +export default class FileManager +{ + readonly #location: string; + readonly #rootLocation: string; + readonly #fileSystem: FileSystem; + + constructor(location: string, fileSystem = new LocalFileSystem()) + { + this.#location = location; + this.#fileSystem = fileSystem; + this.#rootLocation = fileSystem.resolve(location); + } + + // This method must be used by every function that needs to access + // the file system. This ensures that the path is always validated + // and prevents access to files outside of the base location. + getAbsoluteLocation(filename: string): string + { + const location = filename.startsWith('/') ? filename : this.#fileSystem.join(this.#location, filename); + const absolutePath = this.#fileSystem.resolve(location); + + this.#validateLocation(absolutePath, filename); + + return absolutePath; + } + + getRelativeLocation(filename: string): string + { + return this.#fileSystem.relative(this.#location, filename); + } + + async getType(filename: string): Promise + { + const location = this.getAbsoluteLocation(filename); + const type = await this.#fileSystem.mimeType(location); + + return type ?? DEFAULT_MIME_TYPE; + } + + async getContent(filename: string): Promise + { + const location = this.getAbsoluteLocation(filename); + const exists = await this.#fileSystem.exists(location); + + if (exists === false) + { + // Do NOT use the location in the error message, + // as it may contain sensitive information. + throw new FileNotFound(filename); + } + + return this.#fileSystem.read(location); + } + + async exists(filename: string): Promise + { + const location = this.getAbsoluteLocation(filename); + + return this.#fileSystem.exists(location); + } + + async read(filename: string): Promise + { + const absoluteFilename = this.getAbsoluteLocation(filename); + + const type = await this.getType(absoluteFilename); + const content = await this.getContent(absoluteFilename); + + return new File(filename, type, content); + } + + async write(filename: string, content: string): Promise + { + const location = this.getAbsoluteLocation(filename); + + return this.#fileSystem.write(location, content); + } + + async copy(source: string, destination: string): Promise + { + const sourceLocation = this.getAbsoluteLocation(source); + const destinationLocation = this.getAbsoluteLocation(destination); + + return this.#fileSystem.copy(sourceLocation, destinationLocation); + } + + async delete(filename: string): Promise + { + const location = this.getAbsoluteLocation(filename); + + return this.#fileSystem.delete(location); + } + + async filter(pattern: string): Promise + { + const location = this.getAbsoluteLocation('./'); + + return this.#fileSystem.filter(location, pattern); + } + + #validateLocation(location: string, filename: string): void + { + if (location.startsWith(this.#rootLocation) === false) + { + // The filename is only needed for the error message. This + // ensures that the error message does not contain sensitive + // information. + throw new InvalidLocation(filename); + } + } +} diff --git a/packages/sourcing/src/LocalFileManager.ts b/packages/sourcing/src/LocalFileManager.ts deleted file mode 100644 index 2d2abca5..00000000 --- a/packages/sourcing/src/LocalFileManager.ts +++ /dev/null @@ -1,112 +0,0 @@ - -import fs from 'fs-extra'; -import { glob } from 'glob'; -import mime from 'mime-types'; -import path from 'path'; - -import FileNotFound from './errors/FileNotFound'; -import type FileManager from './interfaces/FileManager'; -import File from './models/File'; - -export default class LocalFileManager implements FileManager -{ - readonly #location: string; - - constructor(location: string) - { - this.#location = location; - } - - getRootLocation(): string - { - return path.resolve(this.#location); - } - - getAbsoluteLocation(filename: string): string - { - const location = filename.startsWith('/') ? filename : path.join(this.#location, filename); - - return path.resolve(location); - } - - getRelativeLocation(filename: string): string - { - return path.relative(this.#location, filename); - } - - async getType(filename: string): Promise - { - const location = this.getAbsoluteLocation(filename); - - return mime.lookup(location) || 'application/octet-stream'; - } - - async getContent(filename: string): Promise - { - const location = this.getAbsoluteLocation(filename); - - if (fs.existsSync(location) === false) - { - // Do NOT use the location in the error message, - // as it may contain sensitive information. - throw new FileNotFound(filename); - } - - return fs.readFile(location); - } - - async exists(filename: string): Promise - { - const location = this.getAbsoluteLocation(filename); - - return fs.exists(location); - } - - async read(filename: string): Promise - { - const rootPath = this.getRootLocation(); - const absoluteFilename = this.getAbsoluteLocation(filename); - - if (absoluteFilename.startsWith(rootPath) === false) - { - throw new FileNotFound(filename); - } - - const type = await this.getType(absoluteFilename); - const content = await this.getContent(absoluteFilename); - - return new File(filename, type, content); - } - - async write(filename: string, content: string): Promise - { - const location = this.getAbsoluteLocation(filename); - const directory = path.dirname(location); - - await fs.mkdir(directory, { recursive: true }); - - return fs.writeFile(location, content); - } - - async copy(source: string, destination: string): Promise - { - const sourceLocation = this.getAbsoluteLocation(source); - const destinationLocation = this.getAbsoluteLocation(destination); - - return fs.copy(sourceLocation, destinationLocation, { overwrite: true }); - } - - async delete(filename: string): Promise - { - const location = this.getAbsoluteLocation(filename); - - return fs.remove(location); - } - - async filter(pattern: string): Promise - { - const location = this.getAbsoluteLocation('./'); - - return glob(`${location}/${pattern}`); - } -} diff --git a/packages/sourcing/src/LocalFileSystem.ts b/packages/sourcing/src/LocalFileSystem.ts new file mode 100644 index 00000000..1db05712 --- /dev/null +++ b/packages/sourcing/src/LocalFileSystem.ts @@ -0,0 +1,64 @@ + +import fs from 'fs-extra'; +import { glob } from 'glob'; +import mime from 'mime-types'; +import path from 'path'; + +import FileSystem from './interfaces/FileSystem'; + +export default class LocalFileSystem implements FileSystem +{ + copy(source: string, destination: string): Promise + { + return fs.copy(source, destination, { overwrite: true }); + } + + delete(location: string): Promise + { + return fs.remove(location); + } + + exists(location: string): Promise + { + return fs.exists(location); + } + + filter(location: string, pattern: string): Promise + { + return glob(`${location}/${pattern}`); + } + + join(...paths: string[]): string + { + return path.join(...paths); + } + + read(location: string): Promise + { + return fs.readFile(location); + } + + resolve(location: string): string + { + return path.resolve(location); + } + + relative(from: string, to: string): string + { + return path.relative(from, to); + } + + async mimeType(location: string): Promise + { + return mime.lookup(location) || undefined; + } + + async write(location: string, content: string): Promise + { + const directory = path.dirname(location); + + fs.mkdirSync(directory, { recursive: true }); + + return fs.writeFile(location, content); + } +} diff --git a/packages/sourcing/src/SourcingManager.ts b/packages/sourcing/src/SourcingManager.ts index f57563b7..c642a1c6 100644 --- a/packages/sourcing/src/SourcingManager.ts +++ b/packages/sourcing/src/SourcingManager.ts @@ -1,10 +1,10 @@ import type File from './models/File'; -import type FileManager from './interfaces/FileManager'; import ModuleNotLoaded from './errors/ModuleNotLoaded'; import type Module from './types/Module'; +import type FileManager from './FileManager'; -export default class SourceManager +export default class SourcingManager { readonly #fileManager: FileManager; diff --git a/packages/sourcing/src/errors/InvalidLocation.ts b/packages/sourcing/src/errors/InvalidLocation.ts new file mode 100644 index 00000000..185428ca --- /dev/null +++ b/packages/sourcing/src/errors/InvalidLocation.ts @@ -0,0 +1,14 @@ + +export default class InvalidPath extends Error +{ + readonly #location: string; + + constructor(location: string) + { + super(`Invalid location: ${location}`); + + this.#location = location; + } + + get location() { return this.#location; } +} diff --git a/packages/sourcing/src/index.ts b/packages/sourcing/src/index.ts index ab9940b4..f624c05a 100644 --- a/packages/sourcing/src/index.ts +++ b/packages/sourcing/src/index.ts @@ -1,10 +1,10 @@ export { default as Files } from './definitions/Files'; +export { default as InvalidLocation } from './errors/InvalidLocation'; export { default as FileNotFound } from './errors/FileNotFound'; export { default as ModuleNotLoaded } from './errors/ModuleNotLoaded'; export { default as File } from './models/File'; -export { default as FileManager } from './interfaces/FileManager'; export { default as Module } from './types/Module'; -export { default as LocalFileManager } from './LocalFileManager'; +export { default as FileManager } from './FileManager'; export { default as SourcingManager } from './SourcingManager'; diff --git a/packages/sourcing/src/interfaces/FileManager.ts b/packages/sourcing/src/interfaces/FileManager.ts deleted file mode 100644 index d49ea258..00000000 --- a/packages/sourcing/src/interfaces/FileManager.ts +++ /dev/null @@ -1,27 +0,0 @@ - -import type File from '../models/File'; - -interface FileManager -{ - getRootLocation(): string; - - getAbsoluteLocation(filename: string): string; - - getRelativeLocation(filename: string): string; - - getType(filename: string): Promise; - - getContent(filename: string): Promise; - - exists(filename: string): Promise; - - read(filename: string): Promise; - - write(filename: string, content: string): Promise; - - delete(filename: string): Promise; - - filter(pattern: string): Promise; -} - -export default FileManager; diff --git a/packages/sourcing/src/interfaces/FileSystem.ts b/packages/sourcing/src/interfaces/FileSystem.ts new file mode 100644 index 00000000..bbaa7dcc --- /dev/null +++ b/packages/sourcing/src/interfaces/FileSystem.ts @@ -0,0 +1,25 @@ + +interface FileSystem +{ + copy(source: string, destination: string): Promise; + + delete(location: string): Promise; + + exists(location: string): Promise; + + filter(location: string, pattern: string): Promise; + + join(...paths: string[]): string; + + read(location: string): Promise; + + resolve(location: string): string; + + relative(from: string, to: string): string; + + mimeType(location: string): Promise; + + write(location: string, content: string): Promise; +} + +export default FileSystem; diff --git a/packages/sourcing/test/FileManager.spec.ts b/packages/sourcing/test/FileManager.spec.ts new file mode 100644 index 00000000..365b91cd --- /dev/null +++ b/packages/sourcing/test/FileManager.spec.ts @@ -0,0 +1,75 @@ + +import { describe, expect, it } from 'vitest'; + +import { InvalidLocation, FileNotFound } from '../src'; + +import { fileManager, PATHS } from './fixtures'; + +describe('FileManager', () => +{ + // All the functions that are simple wrappers around the local file system are not tested. + // This is due to the setup of the local file system, which is in such a way that each function + // directly calls a third-party library, and we don't want to test third-party libraries. + + describe('getAbsoluteLocation', () => + { + it('should return the absolute file location from a relative path', () => + { + const location = fileManager.getAbsoluteLocation(PATHS.INPUTS.RELATIVE_FILE_PATH); + + expect(location).toBe(PATHS.OUTPUTS.RELATIVE_FILE_PATH); + }); + + it('should return the absolute file location from an absolute path', () => + { + const location = fileManager.getAbsoluteLocation(PATHS.INPUTS.ABSOLUTE_FILE_PATH); + + expect(location).toBe(PATHS.OUTPUTS.ABSOLUTE_FILE_PATH); + }); + + it('should throw an error when path is outside the base location', () => + { + const result = () => fileManager.getAbsoluteLocation(PATHS.INPUTS.INVALID_FILE_PATH); + + expect(result).toThrow(new InvalidLocation(PATHS.OUTPUTS.INVALID_FILE_PATH)); + }); + }); + + describe('getContent', () => + { + it('should return the content of the file', () => + { + // Not implemented, as it would test reading a file from the file system. Which + // is simply a call to the file system and we don't want to test libraries. + + expect(true).toBe(true); + }); + + it('should throw an error when the file does not exist', async () => + { + const result = fileManager.getContent(PATHS.INPUTS.NON_EXISTING_FILE); + + await expect(result).rejects.toThrow(new FileNotFound(PATHS.OUTPUTS.NON_EXISTING_FILE)); + }); + }); + + describe('getType', () => + { + // Only the decision logic is tested here. The actual mime type implementation is not tested, + // as it's a single call to a third-party library. + + it('should return the mime type of the file', async () => + { + const mimeType = await fileManager.getType(PATHS.INPUTS.FILE_TYPE_TEXT); + + expect(mimeType).toBe(PATHS.OUTPUTS.FILE_TYPE_TEXT); + }); + + it('should return the default mime type when the file type is unknown', async () => + { + const mimeType = await fileManager.getType(PATHS.INPUTS.FILE_TYPE_UNKNOWN); + + expect(mimeType).toBe(PATHS.OUTPUTS.FILE_TYPE_UNKNOWN); + }); + }); +}); diff --git a/packages/sourcing/test/SourcingManager.spec.ts b/packages/sourcing/test/SourcingManager.spec.ts new file mode 100644 index 00000000..969891f2 --- /dev/null +++ b/packages/sourcing/test/SourcingManager.spec.ts @@ -0,0 +1,32 @@ + +import { describe, expect, it } from 'vitest'; + +import { sourcingManager } from './fixtures'; + +describe('SourcingManager', () => +{ + // The sourcing manager uses the file manager for reading files. Importing + // modules is not something we want to test in this context. + + describe('filter', () => + { + it('should return a list of files matching the specified patterns', async () => + { + const result = await sourcingManager.filter('**/*.txt'); + + expect(result).toEqual(['file1.txt', 'aaa/file2.txt', 'bbb/file3.txt']); + }); + }); + + describe('import', () => + { + it('should import the specified module', () => + { + // This method is not tested here, because it is pretty obvious when it does not work. + // Splitting the import into an `importer` implementation would allow us to test the + // implementation, but that is not necessary for now. + + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/sourcing/test/dummy.spec.ts b/packages/sourcing/test/dummy.spec.ts deleted file mode 100644 index 2489f1ee..00000000 --- a/packages/sourcing/test/dummy.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ - -import { describe, expect, it } from 'vitest'; - -describe('dummy', () => -{ - // TODO: Add real tests - - it('should not complain about missing tests', async () => - { - expect(true).toBeTruthy(); - }); -}); diff --git a/packages/sourcing/test/fixtures/TestFileSystem.ts b/packages/sourcing/test/fixtures/TestFileSystem.ts new file mode 100644 index 00000000..96244158 --- /dev/null +++ b/packages/sourcing/test/fixtures/TestFileSystem.ts @@ -0,0 +1,48 @@ + +import path from 'path'; + +import LocalFileSystem from '../../src/LocalFileSystem'; + +export default class TestFileSystem extends LocalFileSystem +{ + #root: string; + + constructor(root: string) + { + super(); + + this.#root = root; + } + + // eslint-disable-next-line no-unused-vars + async exists(location: string): Promise + { + return false; + } + + // eslint-disable-next-line no-unused-vars + async filter(location: string, pattern: string): Promise + { + return [ + path.join(this.#root, 'file1.txt'), + path.join(this.#root, './aaa/file2.txt'), + path.join(this.#root, './bbb/file3.txt'), + ] + } + + async mimeType(location: string): Promise + { + return location.endsWith('.txt') ? 'text/plain' : undefined; + } + + // eslint-disable-next-line no-unused-vars + relative(from: string, to: string): string + { + return path.relative(this.#root, to); + } + + resolve(location: string): string + { + return path.resolve(this.#root, location); + } +} diff --git a/packages/sourcing/test/fixtures/fileManager.fixture.ts b/packages/sourcing/test/fixtures/fileManager.fixture.ts new file mode 100644 index 00000000..e495867a --- /dev/null +++ b/packages/sourcing/test/fixtures/fileManager.fixture.ts @@ -0,0 +1,10 @@ + +import { FileManager } from '../../src'; + +import TestFileSystem from './TestFileSystem'; +import { PATHS } from './paths.fixture'; + +const fileSystem = new TestFileSystem(PATHS.CONFIGS.ROOT_PATH); +const fileManager = new FileManager(PATHS.CONFIGS.BASE_LOCATION, fileSystem); + +export { fileManager }; diff --git a/packages/sourcing/test/fixtures/index.ts b/packages/sourcing/test/fixtures/index.ts new file mode 100644 index 00000000..19e6510f --- /dev/null +++ b/packages/sourcing/test/fixtures/index.ts @@ -0,0 +1,4 @@ + +export * from './paths.fixture'; +export * from './fileManager.fixture'; +export * from './sourcingManager.fixture'; diff --git a/packages/sourcing/test/fixtures/paths.fixture.ts b/packages/sourcing/test/fixtures/paths.fixture.ts new file mode 100644 index 00000000..2435a491 --- /dev/null +++ b/packages/sourcing/test/fixtures/paths.fixture.ts @@ -0,0 +1,28 @@ + +const PATHS = +{ + INPUTS: { + ABSOLUTE_FILE_PATH: '/jitar/base/location/test.pdf', + RELATIVE_FILE_PATH: 'test.txt', + INVALID_FILE_PATH: '../test.txt', + NON_EXISTING_FILE: 'unknown.txt', + FILE_TYPE_TEXT: 'file.txt', + FILE_TYPE_UNKNOWN: 'file.test' + }, + + OUTPUTS: { + ABSOLUTE_FILE_PATH: '/jitar/base/location/test.pdf', + RELATIVE_FILE_PATH: '/jitar/base/location/test.txt', + INVALID_FILE_PATH: '../test.txt', + NON_EXISTING_FILE: 'unknown.txt', + FILE_TYPE_TEXT: 'text/plain', + FILE_TYPE_UNKNOWN: 'application/octet-stream' + }, + + CONFIGS: { + BASE_LOCATION: '.', + ROOT_PATH: '/jitar/base/location' + } +} as const; + +export { PATHS }; diff --git a/packages/sourcing/test/fixtures/sourcingManager.fixture.ts b/packages/sourcing/test/fixtures/sourcingManager.fixture.ts new file mode 100644 index 00000000..407bfb18 --- /dev/null +++ b/packages/sourcing/test/fixtures/sourcingManager.fixture.ts @@ -0,0 +1,8 @@ + +import { SourcingManager } from '../../src'; + +import { fileManager } from './fileManager.fixture'; + +const sourcingManager = new SourcingManager(fileManager); + +export { sourcingManager };