UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

364 lines 15.2 kB
"use strict"; 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