1import { createHash } from 'crypto'; 2import { createReadStream } from 'fs'; 3import fs from 'fs/promises'; 4import minimatch from 'minimatch'; 5import pLimit from 'p-limit'; 6import path from 'path'; 7 8import type { 9 Fingerprint, 10 FingerprintSource, 11 HashResult, 12 HashSource, 13 HashSourceContents, 14 NormalizedOptions, 15} from '../Fingerprint.types'; 16import { profile } from '../utils/Profile'; 17 18/** 19 * Create a `Fingerprint` from `HashSources` array 20 */ 21export async function createFingerprintFromSourcesAsync( 22 sources: HashSource[], 23 projectRoot: string, 24 options: NormalizedOptions 25): Promise<Fingerprint> { 26 const limiter = pLimit(options.concurrentIoLimit); 27 const fingerprintSources = await Promise.all( 28 sources.map((source) => createFingerprintSourceAsync(source, limiter, projectRoot, options)) 29 ); 30 31 const hasher = createHash(options.hashAlgorithm); 32 for (const source of fingerprintSources) { 33 if (source.hash != null) { 34 hasher.update(createSourceId(source)); 35 hasher.update(source.hash); 36 } 37 } 38 const hash = hasher.digest('hex'); 39 40 return { 41 sources: fingerprintSources, 42 hash, 43 }; 44} 45 46/** 47 * Create a `FingerprintSource` from a `HashSource` 48 * This function will get a hash value and merge back to original source 49 */ 50export async function createFingerprintSourceAsync( 51 source: HashSource, 52 limiter: pLimit.Limit, 53 projectRoot: string, 54 options: NormalizedOptions 55): Promise<FingerprintSource> { 56 let result: HashResult | null = null; 57 switch (source.type) { 58 case 'contents': 59 result = await createContentsHashResultsAsync(source, options); 60 break; 61 case 'file': 62 result = await createFileHashResultsAsync(source.filePath, limiter, projectRoot, options); 63 break; 64 case 'dir': 65 result = await profile( 66 createDirHashResultsAsync, 67 `createDirHashResultsAsync(${source.filePath})` 68 )(source.filePath, limiter, projectRoot, options); 69 break; 70 default: 71 throw new Error('Unsupported source type'); 72 } 73 74 return { ...source, hash: result?.hex ?? null }; 75} 76 77/** 78 * Create a `HashResult` from a file 79 */ 80export async function createFileHashResultsAsync( 81 filePath: string, 82 limiter: pLimit.Limit, 83 projectRoot: string, 84 options: NormalizedOptions 85): Promise<HashResult> { 86 // Backup code for faster hashing 87 /* 88 return limiter(async () => { 89 const hasher = createHash(options.hashAlgorithm); 90 91 const stat = await fs.stat(filePath); 92 hasher.update(`${stat.size}`); 93 94 const buffer = Buffer.alloc(4096); 95 const fd = await fs.open(filePath, 'r'); 96 await fd.read(buffer, 0, buffer.length, 0); 97 await fd.close(); 98 hasher.update(buffer); 99 console.log('stat', filePath, stat.size); 100 return { id: path.relative(projectRoot, filePath), hex: hasher.digest('hex') }; 101 }); 102 */ 103 104 return limiter(() => { 105 return new Promise<HashResult>((resolve, reject) => { 106 let resolved = false; 107 const hasher = createHash(options.hashAlgorithm); 108 const stream = createReadStream(path.join(projectRoot, filePath)); 109 stream.on('close', () => { 110 if (!resolved) { 111 const hex = hasher.digest('hex'); 112 resolve({ id: filePath, hex }); 113 resolved = true; 114 } 115 }); 116 stream.on('error', (e) => { 117 reject(e); 118 }); 119 stream.on('data', (chunk) => { 120 hasher.update(chunk); 121 }); 122 }); 123 }); 124} 125 126/** 127 * Indicate the given `dirPath` should be excluded by `dirExcludes` 128 */ 129function isExcludedDir(dirPath: string, dirExcludes: string[]): boolean { 130 for (const exclude of dirExcludes) { 131 if (minimatch(dirPath, exclude)) { 132 return true; 133 } 134 } 135 return false; 136} 137 138/** 139 * Create `HashResult` for a dir. 140 * If the dir is excluded, returns null rather than a HashResult 141 */ 142export async function createDirHashResultsAsync( 143 dirPath: string, 144 limiter: pLimit.Limit, 145 projectRoot: string, 146 options: NormalizedOptions, 147 depth: number = 0 148): Promise<HashResult | null> { 149 if (isExcludedDir(dirPath, options.dirExcludes)) { 150 return null; 151 } 152 const dirents = (await fs.readdir(path.join(projectRoot, dirPath), { withFileTypes: true })).sort( 153 (a, b) => a.name.localeCompare(b.name) 154 ); 155 const promises: Promise<HashResult | null>[] = []; 156 for (const dirent of dirents) { 157 if (dirent.isDirectory()) { 158 const filePath = path.join(dirPath, dirent.name); 159 promises.push(createDirHashResultsAsync(filePath, limiter, projectRoot, options, depth + 1)); 160 } else if (dirent.isFile()) { 161 const filePath = path.join(dirPath, dirent.name); 162 promises.push(createFileHashResultsAsync(filePath, limiter, projectRoot, options)); 163 } 164 } 165 166 const hasher = createHash(options.hashAlgorithm); 167 const results = await Promise.all(promises); 168 for (const result of results) { 169 if (result != null) { 170 hasher.update(result.id); 171 hasher.update(result.hex); 172 } 173 } 174 const hex = hasher.digest('hex'); 175 176 return { id: dirPath, hex }; 177} 178 179/** 180 * Create `HashResult` for a `HashSourceContents` 181 */ 182export async function createContentsHashResultsAsync( 183 source: HashSourceContents, 184 options: NormalizedOptions 185): Promise<HashResult> { 186 const hex = createHash(options.hashAlgorithm).update(source.contents).digest('hex'); 187 return { id: source.id, hex }; 188} 189 190/** 191 * Create id from given source 192 */ 193export function createSourceId(source: HashSource): string { 194 switch (source.type) { 195 case 'contents': 196 return source.id; 197 case 'file': 198 return source.filePath; 199 case 'dir': 200 return source.filePath; 201 default: 202 throw new Error('Unsupported source type'); 203 } 204} 205