@controlplane/cli
Version:
Control Plane Corporation CLI
364 lines • 15.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RemoteFolderScanner = void 0;
exports.credentialLikeFiles = credentialLikeFiles;
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const format_1 = require("../../../util/format");
const config_1 = require("../config");
const errors_1 = require("../errors");
const ignore_1 = require("./ignore");
const symlink_1 = require("./symlink");
// ANCHOR - Constants
const ENV_FILE_RE = /^\.env(rc|\..+)?$/;
const ENV_TEMPLATE_RE = /\.(example|sample|template)$/; // placeholder files, safe to upload
// ANCHOR - RemoteFolderScanner
/**
* Walks a folder into a FolderIndex: a sha256 manifest of its non-excluded files,
* the symlinks preserved as links, and the directories that ship empty. Symlinks
* are never followed — a link is an entry pointing at a target, so cycles, shared
* subtrees, and link farms cost nothing. Exclusions come from RemoteIgnorePolicy;
* in git mode, nested .gitignore files are honored as they are discovered.
*/
class RemoteFolderScanner {
constructor(opts = {}) {
var _a, _b, _c, _d;
this.maxBytes = (_a = opts.maxBytes) !== null && _a !== void 0 ? _a : config_1.MAX_CONTEXT_BYTES;
this.maxFiles = (_b = opts.maxFiles) !== null && _b !== void 0 ? _b : config_1.MAX_CONTEXT_FILES;
this.maxDepth = (_c = opts.maxDepth) !== null && _c !== void 0 ? _c : config_1.MAX_SCAN_DEPTH;
this.now = (_d = opts.now) !== null && _d !== void 0 ? _d : Date.now;
}
// Public Methods //
/**
* Scans the folder into a FolderIndex, honoring the folder's ignore files and the
* size and entry limits.
*
* @param {string} dir - The folder to scan.
* @returns {Promise<FolderIndex>} The folder's context contribution.
*/
async scan(dir) {
const root = resolveRoot(dir);
const policy = ignore_1.RemoteIgnorePolicy.fromDir(root);
// Null-prototype: a file named __proto__ must land as a key, not as the prototype.
const manifest = Object.create(null);
const symlinks = [];
const state = { bytes: 0, entries: 0, deadline: this.now() + config_1.SCAN_BUDGET_MS, dirs: [] };
await this.walkDir(root, '', policy, manifest, symlinks, state);
if (Object.keys(manifest).length === 0 && symlinks.length === 0) {
throw new errors_1.RemoteBuildError('context', `nothing to build in ${dir}: it is empty or everything is excluded`, 'Check the folder and its ignore files, then re-run the build.');
}
return {
root,
ignoreMode: policy.mode,
manifest,
emptyDirs: emptyDirsOf(state.dirs, manifest, symlinks),
symlinks,
totalBytes: state.bytes,
entryCount: state.entries,
};
}
// Private Methods //
/**
* Walks one directory: registers nested .gitignore scopes, prunes excluded
* directories (unless a negation could re-include beneath them), records files
* and symlinks, and enforces the limits.
*
* @param {string} root - The folder's absolute physical root.
* @param {string} rel - The directory's POSIX-relative path, '' for the root.
* @param {RemoteIgnorePolicy} policy - The folder's ignore policy.
* @param {ContextManifest} manifest - The manifest being built.
* @param {SymlinkEntry[]} symlinks - The symlink entries being collected.
* @param {ScanState} state - The running totals.
* @returns {Promise<void>}
*/
async walkDir(root, rel, policy, manifest, symlinks, state) {
let entries;
const dirAbs = rel === '' ? root : path.join(root, ...rel.split('/'));
// A pathological tree would otherwise exhaust the call stack instead of failing cleanly.
if (rel !== '' && rel.split('/').length > this.maxDepth) {
throw new errors_1.RemoteBuildError('context', `the folder nests deeper than ${this.maxDepth} levels at ${rel}`, 'Exclude the deep path in your ignore file, then re-run the build.');
}
try {
entries = fs.readdirSync(dirAbs, { withFileTypes: true });
}
catch (e) {
throw readFailure(rel === '' ? '.' : rel, e);
}
// A nested .gitignore governs this subtree; the root file was loaded by fromDir.
if (policy.mode === 'git' && rel !== '') {
const nested = entries.find((entry) => entry.name === '.gitignore' && entry.isFile());
if (nested) {
policy.addGitScope(rel, readNestedGitignore(path.join(dirAbs, '.gitignore'), `${rel}/.gitignore`), `${rel}/.gitignore`);
}
}
for (const dirent of entries) {
const childRel = rel === '' ? dirent.name : `${rel}/${dirent.name}`;
const childAbs = path.join(dirAbs, dirent.name);
this.assertWithinBudget(state);
const kind = classifyEntry(dirent, childAbs, childRel);
if (kind === 'symlink') {
if (policy.excludes(childRel, false)) {
continue;
}
symlinks.push(this.readLink(root, childAbs, childRel));
state.entries += 1;
this.assertEntryLimit(state);
continue;
}
if (kind === 'dir') {
if (policy.excludes(childRel, true)) {
// Only a docker-mode negation can re-include beneath an excluded directory.
if (policy.mayReincludeBeneath()) {
await this.walkDir(root, childRel, policy, manifest, symlinks, state);
}
continue;
}
state.dirs.push(childRel);
this.assertDirLimit(state);
await this.walkDir(root, childRel, policy, manifest, symlinks, state);
continue;
}
if (kind === 'file') {
if (policy.excludes(childRel, false)) {
continue;
}
let stat;
try {
stat = fs.statSync(childAbs);
}
catch (e) {
throw readFailure(childRel, e);
}
state.bytes += stat.size;
if (state.bytes > this.maxBytes) {
throw new errors_1.RemoteBuildError('context', `the folder exceeds the ${(0, format_1.humanSize)(this.maxBytes)} limit`, 'Add large paths to .dockerignore and re-run the build.');
}
state.entries += 1;
this.assertEntryLimit(state);
let sha256;
try {
sha256 = await sha256File(childAbs);
}
catch (e) {
throw readFailure(childRel, e);
}
manifest[childRel] = { sha256, size: stat.size, mode: fileMode(stat) };
}
// Sockets, FIFOs, and devices contribute nothing to a build context.
}
}
/**
* Records one symlink, requiring its target to resolve inside the folder. The
* stored target is relative to the link's own directory, dangling targets
* included — exactly what `docker build` tolerates.
*
* @param {string} root - The folder's absolute physical root.
* @param {string} linkAbs - The link's absolute path.
* @param {string} linkRel - The link's POSIX-relative path.
* @returns {SymlinkEntry} The link entry.
*/
readLink(root, linkAbs, linkRel) {
let rawTarget;
try {
rawTarget = fs.readlinkSync(linkAbs);
}
catch (e) {
throw readFailure(linkRel, e);
}
// The boundary compares the physical path the kernel would read, not a lexical one.
const linkDirAbs = path.dirname(linkAbs);
const resolved = (0, symlink_1.resolvePhysicalTarget)(linkDirAbs, rawTarget, linkRel);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
throw new errors_1.RemoteBuildError('context', `${linkRel} is a symlink to ${rawTarget}, outside the build folder`, 'Remove the link or add it to the ignore file, then re-run the build.');
}
// Relative targets ship as written, like docker; absolute ones are rewritten relative.
if (!path.isAbsolute(rawTarget)) {
return { path: linkRel, target: rawTarget.split(path.sep).join('/') };
}
const target = path.relative(linkDirAbs, resolved).split(path.sep).join('/');
return { path: linkRel, target: target === '' ? '.' : target };
}
/**
* Enforces the context entry limit.
*
* @param {ScanState} state - The running totals.
* @returns {void}
*/
assertEntryLimit(state) {
if (state.entries > this.maxFiles) {
throw new errors_1.RemoteBuildError('context', `the folder exceeds the ${this.maxFiles} entry limit`, 'Add large paths to .dockerignore and re-run the build.');
}
}
/**
* Enforces the walked-directory limit, which no file or symlink count bounds.
*
* @param {ScanState} state - The running totals.
* @returns {void}
*/
assertDirLimit(state) {
if (state.dirs.length > config_1.MAX_CONTEXT_DIRS) {
throw new errors_1.RemoteBuildError('context', `the folder exceeds the ${config_1.MAX_CONTEXT_DIRS} directory limit`, 'Add large paths to .dockerignore and re-run the build.');
}
}
/**
* Enforces the wall-clock scan budget. Ignore matching runs once per entry, so
* only elapsed time bounds the cost of many rules over many entries.
*
* @param {ScanState} state - The running totals.
* @returns {void}
*/
assertWithinBudget(state) {
if (this.now() > state.deadline) {
throw new errors_1.RemoteBuildError('context', `scanning the folder exceeded ${config_1.SCAN_BUDGET_MS / 1000} seconds`, 'Narrow the build folder or simplify its ignore rules, then re-run the build.');
}
}
}
exports.RemoteFolderScanner = RemoteFolderScanner;
// ANCHOR - Exported Functions
/**
* Names the manifest paths that look like credential files (.npmrc and non-template
* .env variants).
*
* @param {FolderIndex} index - The scanned folder index.
* @returns {string[]} The matching POSIX-relative paths.
*/
function credentialLikeFiles(index) {
return Object.keys(index.manifest).filter((file) => {
const base = file.split('/').pop();
return base === '.npmrc' || (ENV_FILE_RE.test(base) && !ENV_TEMPLATE_RE.test(base));
});
}
// SECTION - Functions
/**
* Resolves and validates the folder root, following a symlinked root itself.
*
* @param {string} dir - The folder as the user passed it.
* @returns {string} The absolute physical root.
*/
function resolveRoot(dir) {
let stat;
const resolved = path.resolve(dir);
try {
stat = fs.statSync(resolved);
}
catch (_a) {
throw new errors_1.RemoteBuildError('context', `${dir} is not a directory`, 'Pass the folder to build with --dir.');
}
if (!stat.isDirectory()) {
throw new errors_1.RemoteBuildError('context', `${dir} is not a directory`, 'Pass the folder to build with --dir.');
}
return fs.realpathSync(resolved);
}
/**
* Classifies a directory entry, falling back to lstat when the filesystem reports
* no dirent type.
*
* @param {fs.Dirent} dirent - The directory entry.
* @param {string} childAbs - The entry's absolute path.
* @param {string} childRel - The entry's POSIX-relative path for error messages.
* @returns {EntryKind} The entry kind.
*/
function classifyEntry(dirent, childAbs, childRel) {
if (dirent.isSymbolicLink()) {
return 'symlink';
}
if (dirent.isDirectory()) {
return 'dir';
}
if (dirent.isFile()) {
return 'file';
}
let stat;
try {
stat = fs.lstatSync(childAbs);
}
catch (e) {
throw readFailure(childRel, e);
}
if (stat.isSymbolicLink()) {
return 'symlink';
}
if (stat.isDirectory()) {
return 'dir';
}
return stat.isFile() ? 'file' : 'other';
}
/**
* Reads a nested .gitignore, wrapping filesystem failures.
*
* @param {string} filePath - The absolute .gitignore path.
* @param {string} rel - The file's POSIX-relative path for error messages.
* @returns {string} The file content.
*/
function readNestedGitignore(filePath, rel) {
try {
return (0, ignore_1.readIgnoreFileText)(filePath);
}
catch (e) {
throw readFailure(rel, e);
}
}
/**
* Derives the directories that ship as empty: every walked directory with no
* manifest file or symlink beneath it, in one linear ancestor-marking pass.
*
* @param {string[]} dirs - Every non-excluded directory the walk entered.
* @param {ContextManifest} manifest - The completed manifest.
* @param {SymlinkEntry[]} symlinks - The collected symlink entries.
* @returns {string[]} The directories with no context entries beneath them.
*/
function emptyDirsOf(dirs, manifest, symlinks) {
const withContent = new Set();
const markAncestors = (entryPath) => {
const segments = entryPath.split('/');
for (let depth = 1; depth < segments.length; depth++) {
withContent.add(segments.slice(0, depth).join('/'));
}
};
for (const filePath of Object.keys(manifest)) {
markAncestors(filePath);
}
for (const link of symlinks) {
markAncestors(link.path);
}
return dirs.filter((dir) => !withContent.has(dir));
}
/**
* Builds the error for an entry that could not be read while scanning.
*
* @param {string} rel - The entry's POSIX-relative path.
* @param {unknown} e - The filesystem error.
* @returns {RemoteBuildError} The wrapped failure.
*/
function readFailure(rel, e) {
const message = e instanceof Error ? e.message : String(e);
return new errors_1.RemoteBuildError('context', `could not read ${rel} while scanning (${message})`, 'Fix the path or exclude it, then re-run the build.');
}
/**
* Returns the octal file mode for the manifest. Windows stats carry only the
* read-only bit, so files pack as 0755 there, matching docker build.
*
* @param {fs.Stats} stat - The file's stats.
* @returns {string} The octal mode.
*/
function fileMode(stat) {
return process.platform === 'win32' ? '755' : (stat.mode & 0o777).toString(8);
}
/**
* Computes a file's SHA-256 by streaming.
*
* @param {string} filePath - The absolute file path.
* @returns {Promise<string>} The lowercase hex SHA-256.
*/
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
fs.createReadStream(filePath)
.on('error', reject)
.on('data', (chunk) => hash.update(chunk))
.on('end', () => resolve(hash.digest('hex')));
});
}
// !SECTION
//# sourceMappingURL=scanner.js.map