Files
files-hash/index.js
2025-10-14 16:17:48 +08:00

705 lines
19 KiB
JavaScript

const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const crypto = require('crypto');
/**
* 工具函数:安全执行异步操作,忽略错误
*
* @template T
* @param {() => Promise<T>} fn - 要执行的异步函数
* @param {T} defaultValue - 出错时的默认值
* @returns {Promise<T>} 函数执行结果或默认值
*/
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<string[]>} 匹配的文件路径数组(已排序且去重)
*/
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<object[]>} 验证结果数组,包含文件状态信息
*/
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<string[]>} 匹配的文件路径数组
*/
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<string[]>} 目录下所有文件的路径数组
*/
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<string[]>} 匹配的文件路径数组
* @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<string[]>} 匹配的文件路径数组
* @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<string[]>} 匹配的文件路径数组
* @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<string>} 文件的哈希值(十六进制字符串)
* @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<string>} 组合哈希值(十六进制字符串)
* @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,
};