const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); const crypto = require('crypto'); /** * 工具函数:安全执行异步操作,忽略错误 * * @template T * @param {() => Promise} fn - 要执行的异步函数 * @param {T} defaultValue - 出错时的默认值 * @returns {Promise} 函数执行结果或默认值 */ async function safeExecute(fn, defaultValue = undefined) { try { return await fn(); } catch (error) { return defaultValue; } } /** * 工具函数:安全执行同步操作,忽略错误 * * @template T * @param {() => T} fn - 要执行的同步函数 * @param {T} defaultValue - 出错时的默认值 * @returns {T} 函数执行结果或默认值 */ /** * 错误类型常量. * * @readonly * @enum {string} */ const ErrorType = { /** * 文件未找到错误. */ FILE_NOT_FOUND: 'FILE_NOT_FOUND', /** * 权限被拒绝错误. */ PERMISSION_DENIED: 'PERMISSION_DENIED', /** * 无效的哈希算法错误. */ INVALID_ALGORITHM: 'INVALID_ALGORITHM', /** * 哈希计算失败错误. */ HASH_CALCULATION_FAILED: 'HASH_CALCULATION_FAILED', }; /** * 自定义错误类,用于处理 GitHub Action 执行过程中的各种错误. * * @augments Error */ class ActionError extends Error { /** * 创建一个新的 ActionError 实例 * * @param {string} type - 错误类型,应该是 ErrorType 中的一个值 * @param {string} message - 错误消息 * @param {string|null} [file] - 相关的文件路径(可选) * @param {string|null} [details] - 额外的错误详情(可选) */ constructor(type, message, file = null, details = null) { super(message); this.type = type; this.file = file; this.details = details; this.name = 'ActionError'; } } /** * GitHub Actions 输入处理类 * 提供从环境变量中读取 GitHub Actions 输入参数的静态方法. */ class ActionInputs { /** * 获取字符串类型的输入参数 * * @param {string} name - 输入参数名称 * @param {boolean} [required] - 是否为必需参数 * @returns {string} 输入参数的值 * @throws {ActionError} 当必需参数未提供时抛出错误 */ static getInput(name, required = false) { const envName = `INPUT_${name.toUpperCase().replace(/-/g, '_')}`; const value = process.env[envName] || ''; if (required && !value) { throw new ActionError(ErrorType.INVALID_INPUT, `Input required and not supplied: ${name}`); } return value; } /** * 获取布尔类型的输入参数 * * @param {string} name - 输入参数名称 * @param {boolean} [required] - 是否为必需参数 * @returns {boolean} 转换后的布尔值 * @throws {ActionError} 当必需参数未提供时抛出错误 */ static getBooleanInput(name, required = false) { const value = this.getInput(name, required).toLowerCase(); return value === 'true' || value === '1'; } /** * 获取多行字符串类型的输入参数 * * @param {string} name - 输入参数名称 * @param {boolean} [required] - 是否为必需参数 * @returns {string[]} 按行分割并过滤空行后的字符串数组 * @throws {ActionError} 当必需参数未提供时抛出错误 */ static getMultilineInput(name, required = false) { const value = this.getInput(name, required); return value.split('\n').filter(line => line.trim()); } } /** * GitHub Actions 输出处理类 * 提供设置 GitHub Actions 输出和日志记录的静态方法. */ class ActionOutputs { /** * 设置 GitHub Actions 输出变量 * * @param {string} name - 输出变量名称 * @param {string} value - 输出变量值 */ static setOutput(name, value) { const outputFile = process.env.GITHUB_OUTPUT; if (outputFile) { const fs = require('fs'); fs.appendFileSync(outputFile, `${name}=${value}\n`); } else { console.log(`::set-output name=${name}::${value}`); } } /** * 设置 Action 失败状态并退出 * * @param {string} message - 失败消息 */ static setFailed(message) { console.log(`::error::${message}`); process.exit(1); } /** * 输出信息日志 * * @param {string} message - 信息内容 */ static info(message) { console.log(message); } /** * 输出警告日志 * * @param {string} message - 警告内容 */ static warning(message) { console.log(`::warning::${message}`); } /** * 输出错误日志 * * @param {string} message - 错误内容 */ static error(message) { console.log(`::error::${message}`); } } /** * 文件发现引擎:提供文件模式解析、文件查找和验证功能,支持 glob 模式匹配 */ class FileDiscovery { /** * 解析输入的文件路径模式 * * @param {string|string[]} input - 文件路径模式,可以是字符串或字符串数组 * @returns {string[]} 解析后的文件路径模式数组 */ parseFilePatterns(input) { if (Array.isArray(input)) { return input.map(line => line.trim()).filter(line => line); } return input .split('\n') .map(line => line.trim()) .filter(line => line); } /** * 查找匹配的文件 * * @param {string[]} patterns - 文件路径模式数组 * @returns {Promise} 匹配的文件路径数组(已排序且去重) */ async findFiles(patterns) { const files = new Set(); for (const pattern of patterns) { await safeExecute(async () => { const matchedFiles = await this.expandGlob(pattern.trim()); matchedFiles.forEach(file => files.add(file)); }); } return Array.from(files).sort(); } /** * 验证文件存在性和可读性 * * @param {string[]} files - 文件路径数组 * @returns {Promise} 验证结果数组,包含文件状态信息 */ async validateFiles(files) { const results = []; for (const file of files) { try { const stats = await fs.stat(file); if (stats.isFile()) { results.push({ file, exists: true, readable: true, size: stats.size, }); } } catch (error) { results.push({ file, exists: false, readable: false, error: error.message, }); } } return results; } /** * 自实现的 glob 模式匹配 * * @param {string} pattern - Glob 模式字符串 * @returns {Promise} 匹配的文件路径数组 */ async expandGlob(pattern) { // 如果是普通文件路径,直接返回 if (!pattern.includes('*') && !pattern.includes('?')) { const stats = await safeExecute(() => fs.stat(pattern)); if (stats) { if (stats.isFile()) { return [pattern]; } else if (stats.isDirectory()) { // 如果是目录,返回目录下所有文件 return await this.getAllFilesInDirectory(pattern); } } return []; } // 处理 glob 模式 return await this.matchGlobPattern(pattern); } /** * 获取目录下所有文件(递归) * * @param {string} dirPath - 目录路径 * @param {boolean} [includeHidden] - 是否包含隐藏文件 * @returns {Promise} 目录下所有文件的路径数组 */ async getAllFilesInDirectory(dirPath, includeHidden = false) { const files = []; const entries = await safeExecute(() => fs.readdir(dirPath, { withFileTypes: true }), []); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); // 默认忽略隐藏文件(以.开头的文件和目录) if (!includeHidden && entry.name.startsWith('.')) { continue; } if (entry.isFile()) { files.push(fullPath); } else if (entry.isDirectory()) { const subFiles = await this.getAllFilesInDirectory(fullPath, includeHidden); files.push(...subFiles); } } return files; } /** * 匹配 glob 模式 * * @param {string} pattern - Glob 模式字符串 * @returns {Promise} 匹配的文件路径数组 * @private */ async matchGlobPattern(pattern) { const files = []; // 分离目录部分和文件名部分 const dir = path.dirname(pattern); const filename = path.basename(pattern); // 如果目录部分包含通配符,需要递归处理 if (dir.includes('*')) { return await this.matchGlobPatternRecursive(pattern); } // 处理简单的文件名通配符 const entries = await safeExecute(() => fs.readdir(dir, { withFileTypes: true }), []); for (const entry of entries) { // 默认忽略隐藏文件(以.开头的文件) if (entry.name.startsWith('.')) { continue; } if (entry.isFile() && this.matchPattern(entry.name, filename)) { files.push(path.join(dir, entry.name)); } } return files; } /** * 递归匹配 glob 模式 * * @param {string} pattern - Glob 模式字符串 * @returns {Promise} 匹配的文件路径数组 * @private */ async matchGlobPatternRecursive(pattern) { const files = []; // 处理 ** 通配符 if (pattern.includes('**')) { const parts = pattern.split('**'); const basePath = parts[0].replace(/\*+$/, '').replace(/\/$/, '') || '.'; const remainingPattern = parts[1].replace(/^\//, ''); const allFiles = await safeExecute(() => this.getAllFilesInDirectory(basePath), []); for (const file of allFiles) { const relativePath = path.relative(basePath, file); // 对于 **/pattern 模式,匹配相对路径的末尾部分 if (remainingPattern && (relativePath === remainingPattern || relativePath.endsWith('/' + remainingPattern))) { files.push(file); } else if (this.matchPattern(relativePath, remainingPattern)) { // 对于包含通配符的模式,使用 matchPattern files.push(file); } } } else { // 处理单层通配符 const parts = pattern.split('/'); files.push(...(await this.matchPatternParts(parts, '.'))); } return files; } /** * 匹配模式部分 * * @param {string[]} parts - 模式部分数组 * @param {string} currentPath - 当前路径 * @returns {Promise} 匹配的文件路径数组 * @private */ async matchPatternParts(parts, currentPath) { if (parts.length === 0) { return []; } const [currentPart, ...remainingParts] = parts; const files = []; const entries = await safeExecute(() => fs.readdir(currentPath, { withFileTypes: true }), []); for (const entry of entries) { // 默认忽略隐藏文件和目录(以.开头的) if (entry.name.startsWith('.')) { continue; } const fullPath = path.join(currentPath, entry.name); if (this.matchPattern(entry.name, currentPart)) { if (remainingParts.length === 0) { // 最后一部分,检查是否为文件 if (entry.isFile()) { files.push(fullPath); } } else { // 还有更多部分,递归处理 if (entry.isDirectory()) { const subFiles = await this.matchPatternParts(remainingParts, fullPath); files.push(...subFiles); } } } } return files; } /** * 简单的模式匹配函数 * * @param {string} text - 要匹配的文本 * @param {string} pattern - Glob 模式 * @returns {boolean} 是否匹配 * @private */ matchPattern(text, pattern) { // 将 glob 模式转换为正则表达式 const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); const regex = new RegExp(`^${regexPattern}$`); return regex.test(text); } } /** * 哈希计算器 * 提供文件哈希计算功能,支持多种哈希算法 */ class HashCalculator { /** * 构造函数 * 初始化支持的哈希算法列表 */ constructor() { this.supportedAlgorithms = ['md5', 'sha1', 'sha256', 'sha512']; } /** * 验证算法是否支持 * * @param {string} algorithm - 哈希算法名称 * @throws {ActionError} 当算法不支持时抛出错误 */ validateAlgorithm(algorithm) { if (!this.supportedAlgorithms.includes(algorithm.toLowerCase())) { throw new ActionError( ErrorType.INVALID_ALGORITHM, `Unsupported algorithm: ${algorithm}. Supported algorithms: ${this.supportedAlgorithms.join(', ')}` ); } } /** * 计算单个文件的哈希 * * @param {string} filePath - 文件路径 * @param {string} algorithm - 哈希算法 * @returns {Promise} 文件的哈希值(十六进制字符串) * @throws {ActionError} 当文件读取或哈希计算失败时抛出错误 */ async calculateFileHash(filePath, algorithm) { return new Promise((resolve, reject) => { const hash = crypto.createHash(algorithm); const stream = fsSync.createReadStream(filePath); stream.on('data', data => hash.update(data)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', error => reject( new ActionError( ErrorType.HASH_CALCULATION_FAILED, `Failed to calculate hash for ${filePath}: ${error.message}`, filePath, error ) ) ); }); } /** * 计算多个文件的组合哈希 * * @param {string[]} files - 文件路径数组 * @param {string} algorithm - 哈希算法 * @returns {Promise} 组合哈希值(十六进制字符串) * @throws {ActionError} 当算法不支持或文件哈希计算失败时抛出错误 */ async calculateCombinedHash(files, algorithm) { this.validateAlgorithm(algorithm); const hash = crypto.createHash(algorithm); // 按文件路径排序确保一致性 const sortedFiles = [...files].sort(); ActionOutputs.info(`Processing ${sortedFiles.length} files with ${algorithm.toUpperCase()} algorithm...`); for (const file of sortedFiles) { try { const fileHash = await this.calculateFileHash(file, algorithm); hash.update(fileHash); ActionOutputs.info(`✓ ${file}`); } catch (error) { ActionOutputs.warning(`✗ ${file}: ${error.message}`); throw error; } } const combinedHash = hash.digest('hex'); ActionOutputs.info(`Combined hash: ${combinedHash}`); return combinedHash; } /** * 获取支持的算法列表 * * @returns {string[]} 支持的哈希算法数组的副本 */ getSupportedAlgorithms() { return [...this.supportedAlgorithms]; } } /** * 输出格式化器 * 提供结果格式化和 GitHub Actions 输出设置功能 */ class OutputFormatter { /** * 格式化输出 * * @param {string|string[]} files - 文件列表或组合哈希值 * @param {string} algorithm - 哈希算法 * @param {string} combinedHash - 组合哈希值 * @returns {string|object} 格式化后的输出字符串或对象 */ formatOutput(files, algorithm, combinedHash) { // 如果第一个参数是数组,生成格式化的字符串输出 if (Array.isArray(files)) { const fileCount = files.length; let output = ''; if (fileCount === 0) { output += 'No files found\n'; } else if (fileCount === 1) { output += `Processing 1 file\n`; output += `File: ${files[0]}\n`; } else { output += `Total files: ${fileCount}\n`; files.forEach(file => { output += ` ${file}\n`; }); } output += `Algorithm: ${algorithm.toLowerCase()}\n`; output += `Combined Hash: ${combinedHash}`; return output; } // 否则使用原来的对象格式(兼容模式) return { hash: files, fileCount: algorithm, algorithm: combinedHash.toUpperCase(), }; } /** * 设置 GitHub Actions 输出 * * @param {string} hash - 哈希值 * @param {number} fileCount - 文件数量 */ setGitHubOutput(hash, fileCount) { ActionOutputs.setOutput('hash', hash); ActionOutputs.setOutput('file-count', fileCount); } } /** * 主要的 Action 类;协调文件发现、哈希计算和输出格式化的完整工作流程 */ class FilesHashAction { /** * 构造函数。初始化所需的组件实例 */ constructor() { this.fileDiscovery = new FileDiscovery(); this.hashCalculator = new HashCalculator(); this.outputFormatter = new OutputFormatter(); } /** * 运行 Action 的主要方法。执行完整的文件哈希计算工作流程 * * @throws {ActionError} 当输入无效、文件不存在或哈希计算失败时抛出错误 */ async run() { try { // 读取输入参数 const filesInput = ActionInputs.getMultilineInput('files', true); const algorithm = ActionInputs.getInput('algorithm') || 'sha256'; ActionOutputs.info(`Files Hash Action started`); ActionOutputs.info(`Algorithm: ${algorithm.toUpperCase()}`); // 解析文件模式 const patterns = this.fileDiscovery.parseFilePatterns(filesInput); ActionOutputs.info(`File patterns: ${patterns.join(', ')}`); // 查找文件 const foundFiles = await this.fileDiscovery.findFiles(patterns); ActionOutputs.info(`Found ${foundFiles.length} potential files`); // 验证文件 const validationResults = await this.fileDiscovery.validateFiles(foundFiles); const validFiles = validationResults .filter(result => result.exists && result.readable) .map(result => result.file); const missingFiles = validationResults.filter(result => !result.exists).map(result => result.file); // 处理缺失文件 - 直接抛出错误 if (missingFiles.length > 0) { const message = `Missing files: ${missingFiles.join(', ')}`; throw new ActionError(ErrorType.FILE_NOT_FOUND, message); } // 检查是否有有效文件 - 直接抛出错误 if (validFiles.length === 0) { throw new ActionError(ErrorType.FILE_NOT_FOUND, 'No valid files found to process'); } ActionOutputs.info(`Processing ${validFiles.length} valid files`); // 计算组合哈希 const combinedHash = await this.hashCalculator.calculateCombinedHash(validFiles, algorithm); // 格式化输出 const result = this.outputFormatter.formatOutput(combinedHash, validFiles.length, algorithm); // 设置 GitHub Actions 输出 this.outputFormatter.setGitHubOutput(result.hash, result.fileCount); ActionOutputs.info(`Action completed successfully`); ActionOutputs.info(`Hash: ${result.hash}`); ActionOutputs.info(`File count: ${result.fileCount}`); } catch (error) { if (error instanceof ActionError) { ActionOutputs.setFailed(`${error.type}: ${error.message}`); } else { ActionOutputs.setFailed(`Unexpected error: ${error.message}`); } } } } // 运行 Action if (require.main === module) { const action = new FilesHashAction(); action.run(); } module.exports = { FilesHashAction, FileDiscovery, HashCalculator, OutputFormatter, ActionInputs, ActionOutputs, ActionError, ErrorType, safeExecute, };