import {getLogger} from "@ui5/logger";
const log = getLogger("resources:adapters:FileSystem");
import path from "node:path";
import {promisify} from "node:util";
import fs from "graceful-fs";
const copyFile = promisify(fs.copyFile);
const chmod = promisify(fs.chmod);
const mkdir = promisify(fs.mkdir);
const stat = promisify(fs.stat);
import {globby, isGitIgnored} from "globby";
import {PassThrough} from "node:stream";
import AbstractAdapter from "./AbstractAdapter.js";
const READ_ONLY_MODE = 0o444;
const ADAPTER_NAME = "FileSystem";
/**
* File system resource adapter
*
* @public
* @class
* @alias @ui5/fs/adapters/FileSystem
* @extends @ui5/fs/adapters/AbstractAdapter
*/
class FileSystem extends AbstractAdapter {
/**
* The Constructor.
*
* @param {object} parameters Parameters
* @param {string} parameters.virBasePath
* Virtual base path. Must be absolute, POSIX-style, and must end with a slash
* @param {string} parameters.fsBasePath
* File System base path. Must be absolute and must use platform-specific path segment separators
* @param {string[]} [parameters.excludes] List of glob patterns to exclude
* @param {object} [parameters.useGitignore=false]
* Whether to apply any excludes defined in an optional .gitignore in the given <code>fsBasePath</code> directory
* @param {@ui5/project/specifications/Project} [parameters.project] Project this adapter belongs to (if any)
*/
constructor({virBasePath, project, fsBasePath, excludes, useGitignore=false}) {
super({virBasePath, project, excludes});
if (!fsBasePath) {
throw new Error(`Unable to create adapter: Missing parameter 'fsBasePath'`);
}
// Ensure path is resolved to an absolute path, ending with a slash (or backslash on Windows)
// path.resolve will always remove any trailing segment separator
this._fsBasePath = path.join(path.resolve(fsBasePath), path.sep);
this._useGitignore = !!useGitignore;
}
/**
* Locate resources by glob.
*
* @private
* @param {Array} patterns Array of glob patterns
* @param {object} [options={}] glob options
* @param {boolean} [options.nodir=true] Do not match directories
* @param {@ui5/fs/tracing.Trace} trace Trace instance
* @returns {Promise<@ui5/fs/Resource[]>} Promise resolving to list of resources
*/
async _runGlob(patterns, options = {nodir: true}, trace) {
const opt = {
cwd: this._fsBasePath,
dot: true,
onlyFiles: options.nodir,
followSymbolicLinks: false,
gitignore: this._useGitignore,
};
trace.globCall();
const promises = [];
if (!opt.onlyFiles && patterns.includes("")) { // Match physical root directory
promises.push(new Promise((resolve, reject) => {
fs.stat(this._fsBasePath, (err, stat) => {
if (err) {
reject(err);
} else {
resolve(this._createResource({
project: this._project,
statInfo: stat,
path: this._virBaseDir,
sourceMetadata: {
adapter: ADAPTER_NAME,
fsPath: this._fsBasePath
},
createStream: () => {
return fs.createReadStream(this._fsBasePath);
}
}));
}
});
}));
}
// Remove empty string glob patterns
// Starting with globby v8 or v9 empty glob patterns "" act like "**"
// Micromatch throws on empty strings. We just ignore them since they are
// typically caused by our normalization in the AbstractAdapter
const globbyPatterns = patterns.filter((pattern) => {
return pattern !== "";
});
if (globbyPatterns.length > 0) {
const matches = await globby(globbyPatterns, opt);
for (let i = matches.length - 1; i >= 0; i--) {
promises.push(new Promise((resolve, reject) => {
const virPath = (this._virBasePath + matches[i]);
const relPath = this._resolveVirtualPathToBase(virPath);
if (relPath === null) {
// Match is likely outside adapter base path
log.verbose(
`Failed to resolve virtual path of glob match '${virPath}': Path must start with ` +
`the configured virtual base path of the adapter. Base path: '${this._virBasePath}'`);
resolve(null);
}
const fsPath = this._resolveToFileSystem(relPath);
// Workaround for not getting the stat from the glob
fs.stat(fsPath, (err, stat) => {
if (err) {
reject(err);
} else {
resolve(this._createResource({
project: this._project,
statInfo: stat,
path: virPath,
sourceMetadata: {
adapter: ADAPTER_NAME,
fsPath: fsPath
},
createStream: () => {
return fs.createReadStream(fsPath);
}
}));
}
});
}));
}
}
const results = await Promise.all(promises);
// Flatten results
return Array.prototype.concat.apply([], results).filter(($) => $);
}
/**
* Locate a resource by path.
*
* @private
* @param {string} virPath Absolute virtual path
* @param {object} options Options
* @param {@ui5/fs/tracing.Trace} trace Trace instance
* @returns {Promise<@ui5/fs/Resource>} Promise resolving to a single resource or null if not found
*/
async _byPath(virPath, options, trace) {
const relPath = this._resolveVirtualPathToBase(virPath);
if (relPath === null) {
// Neither starts with basePath, nor equals baseDirectory
if (!options.nodir && this._virBasePath.startsWith(virPath)) {
// Create virtual directories for the virtual base path (which has to exist)
// TODO: Maybe improve this by actually matching the base paths segments to the virPath
return this._createResource({
project: this._project,
statInfo: { // TODO: make closer to fs stat info
isDirectory: function() {
return true;
}
},
path: virPath
});
} else {
return null;
}
}
const fsPath = this._resolveToFileSystem(relPath);
trace.pathCall();
if (this._useGitignore) {
if (!this._isGitIgnored) {
this._isGitIgnored = await isGitIgnored({
cwd: this._fsBasePath
});
}
// Check whether path should be ignored
if (this._isGitIgnored(fsPath)) {
// Path is ignored by .gitignore
return null;
}
}
try {
const statInfo = await stat(fsPath);
if (options.nodir && statInfo.isDirectory()) {
return null;
}
const resourceOptions = {
project: this._project,
statInfo,
path: virPath,
sourceMetadata: {
adapter: ADAPTER_NAME,
fsPath
}
};
if (!statInfo.isDirectory()) {
// Add content as lazy stream
resourceOptions.createStream = function() {
return fs.createReadStream(fsPath);
};
}
return this._createResource(resourceOptions);
} catch (err) {
if (err.code === "ENOENT") { // "File or directory does not exist"
return null;
} else {
throw err;
}
}
}
/**
* Writes the content of a resource to a path.
*
* @private
* @param {@ui5/fs/Resource} resource Resource to write
* @param {object} [options]
* @param {boolean} [options.readOnly] Whether the resource content shall be written read-only
* Do not use in conjunction with the <code>drain</code> option.
* The written file will be used as the new source of this resources content.
* Therefore the written file should not be altered by any means.
* Activating this option might improve overall memory consumption.
* @param {boolean} [options.drain] Whether the resource content shall be emptied during the write process.
* Do not use in conjunction with the <code>readOnly</code> option.
* Activating this option might improve overall memory consumption.
* This should be used in cases where this is the last access to the resource.
* E.g. the final write of a resource after all processing is finished.
* @returns {Promise<undefined>} Promise resolving once data has been written
*/
async _write(resource, {drain, readOnly}) {
resource = this._migrateResource(resource);
if (resource instanceof Promise) {
// Only await if the migrate function returned a promise
// Otherwise await would automatically create a Promise, causing unwanted overhead
resource = await resource;
}
this._assignProjectToResource(resource);
if (drain && readOnly) {
throw new Error(`Error while writing resource ${resource.getPath()}: ` +
"Do not use options 'drain' and 'readOnly' at the same time.");
}
const relPath = this._resolveVirtualPathToBase(resource.getPath(), true);
const fsPath = this._resolveToFileSystem(relPath);
const dirPath = path.dirname(fsPath);
await mkdir(dirPath, {recursive: true});
const sourceMetadata = resource.getSourceMetadata();
if (sourceMetadata && sourceMetadata.adapter === ADAPTER_NAME && sourceMetadata.fsPath) {
// Resource has been created by FileSystem adapter. This means it might require special handling
/* The following code covers these four conditions:
1. FS-paths not equal + Resource not modified => Shortcut: Use fs.copyFile
2. FS-paths equal + Resource not modified => Shortcut: Skip write altogether
3. FS-paths equal + Resource modified => Drain stream into buffer. Later write from buffer as usual
4. FS-paths not equal + Resource modified => No special handling. Write from stream or buffer
*/
if (sourceMetadata.fsPath !== fsPath && !sourceMetadata.contentModified) {
// Shortcut: fs.copyFile can be used when the resource hasn't been modified
log.silly(`Resource hasn't been modified. Copying resource from ${sourceMetadata.fsPath} to ${fsPath}`);
await copyFile(sourceMetadata.fsPath, fsPath);
if (readOnly) {
await chmod(fsPath, READ_ONLY_MODE);
}
return;
} else if (sourceMetadata.fsPath === fsPath && !sourceMetadata.contentModified) {
log.silly(
`Resource hasn't been modified, target path equals source path. Skipping write to ${fsPath}`);
if (readOnly) {
await chmod(fsPath, READ_ONLY_MODE);
}
return;
} else if (sourceMetadata.fsPath === fsPath && sourceMetadata.contentModified) {
// Resource has been modified. Make sure all streams are drained to prevent
// issues caused by piping the original read-stream into a write-stream for the same path
await resource.getBuffer();
} else {/* Different paths + modifications require no special handling */}
}
log.silly(`Writing to ${fsPath}`);
await new Promise((resolve, reject) => {
let contentStream;
if (drain || readOnly) {
// Stream will be drained
contentStream = resource.getStream();
contentStream.on("error", (err) => {
reject(err);
});
} else {
// Transform stream into buffer before writing
contentStream = new PassThrough();
const buffers = [];
contentStream.on("error", (err) => {
reject(err);
});
contentStream.on("data", (data) => {
buffers.push(data);
});
contentStream.on("end", () => {
const buffer = Buffer.concat(buffers);
resource.setBuffer(buffer);
});
resource.getStream().pipe(contentStream);
}
const writeOptions = {};
if (readOnly) {
writeOptions.mode = READ_ONLY_MODE;
}
const write = fs.createWriteStream(fsPath, writeOptions);
write.on("error", (err) => {
reject(err);
});
write.on("close", (ex) => {
resolve();
});
contentStream.pipe(write);
});
if (readOnly) {
if (sourceMetadata?.fsPath === fsPath) {
// When streaming into the same file, permissions need to be changed explicitly
await chmod(fsPath, READ_ONLY_MODE);
}
// In case of readOnly, we drained the stream and can now set a new callback
// for creating a stream from written file
// This should be identical to buffering the resource content in memory, since the written file
// can not be modified.
// We chose this approach to be more memory efficient in scenarios where readOnly is used
resource.setStream(function() {
return fs.createReadStream(fsPath);
});
}
}
_resolveToFileSystem(relPath) {
const fsPath = path.join(this._fsBasePath, relPath);
if (!fsPath.startsWith(this._fsBasePath)) {
log.verbose(`Failed to resolve virtual path internally: ${relPath}`);
log.verbose(` Adapter base path: ${this._fsBasePath}`);
log.verbose(` Resulting path: ${fsPath}`);
throw new Error(
`Error while resolving internal virtual path: '${relPath}' resolves ` +
`to a directory not accessible by this File System adapter instance`);
}
return fsPath;
}
}
export default FileSystem;