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 | null> { 86 // Backup code for faster hashing 87 /* 88 return limiter(async () => { 89 if (isIgnoredPath(filePath, options.ignorePaths)) { 90 return null; 91 } 92 93 const hasher = createHash(options.hashAlgorithm); 94 95 const stat = await fs.stat(filePath); 96 hasher.update(`${stat.size}`); 97 98 const buffer = Buffer.alloc(4096); 99 const fd = await fs.open(filePath, 'r'); 100 await fd.read(buffer, 0, buffer.length, 0); 101 await fd.close(); 102 hasher.update(buffer); 103 console.log('stat', filePath, stat.size); 104 return { id: path.relative(projectRoot, filePath), hex: hasher.digest('hex') }; 105 }); 106 */ 107 108 return limiter(() => { 109 return new Promise<HashResult | null>((resolve, reject) => { 110 if (isIgnoredPath(filePath, options.ignorePaths)) { 111 return resolve(null); 112 } 113 114 let resolved = false; 115 const hasher = createHash(options.hashAlgorithm); 116 const stream = createReadStream(path.join(projectRoot, filePath)); 117 stream.on('close', () => { 118 if (!resolved) { 119 const hex = hasher.digest('hex'); 120 resolve({ id: filePath, hex }); 121 resolved = true; 122 } 123 }); 124 stream.on('error', (e) => { 125 reject(e); 126 }); 127 stream.on('data', (chunk) => { 128 hasher.update(chunk); 129 }); 130 }); 131 }); 132} 133 134/** 135 * Indicate the given `filePath` should be excluded by `ignorePaths` 136 */ 137export function isIgnoredPath( 138 filePath: string, 139 ignorePaths: string[], 140 minimatchOptions: minimatch.IOptions = { dot: true } 141): boolean { 142 const minimatchObjs = ignorePaths.map( 143 (ignorePath) => new minimatch.Minimatch(ignorePath, minimatchOptions) 144 ); 145 146 let result = false; 147 for (const minimatchObj of minimatchObjs) { 148 const currMatch = minimatchObj.match(filePath); 149 if (minimatchObj.negate && result && !currMatch) { 150 // Special handler for negate (!pattern). 151 // As long as previous match result is true and not matched from the current negate pattern, we should early return. 152 return false; 153 } 154 result ||= currMatch; 155 } 156 return result; 157} 158 159/** 160 * Create `HashResult` for a dir. 161 * If the dir is excluded, returns null rather than a HashResult 162 */ 163export async function createDirHashResultsAsync( 164 dirPath: string, 165 limiter: pLimit.Limit, 166 projectRoot: string, 167 options: NormalizedOptions, 168 depth: number = 0 169): Promise<HashResult | null> { 170 if (isIgnoredPath(dirPath, options.ignorePaths)) { 171 return null; 172 } 173 const dirents = (await fs.readdir(path.join(projectRoot, dirPath), { withFileTypes: true })).sort( 174 (a, b) => a.name.localeCompare(b.name) 175 ); 176 const promises: Promise<HashResult | null>[] = []; 177 for (const dirent of dirents) { 178 if (dirent.isDirectory()) { 179 const filePath = path.join(dirPath, dirent.name); 180 promises.push(createDirHashResultsAsync(filePath, limiter, projectRoot, options, depth + 1)); 181 } else if (dirent.isFile()) { 182 const filePath = path.join(dirPath, dirent.name); 183 promises.push(createFileHashResultsAsync(filePath, limiter, projectRoot, options)); 184 } 185 } 186 187 const hasher = createHash(options.hashAlgorithm); 188 const results = (await Promise.all(promises)).filter( 189 (result): result is HashResult => result != null 190 ); 191 if (results.length === 0) { 192 return null; 193 } 194 for (const result of results) { 195 hasher.update(result.id); 196 hasher.update(result.hex); 197 } 198 const hex = hasher.digest('hex'); 199 200 return { id: dirPath, hex }; 201} 202 203/** 204 * Create `HashResult` for a `HashSourceContents` 205 */ 206export async function createContentsHashResultsAsync( 207 source: HashSourceContents, 208 options: NormalizedOptions 209): Promise<HashResult> { 210 const hex = createHash(options.hashAlgorithm).update(source.contents).digest('hex'); 211 return { id: source.id, hex }; 212} 213 214/** 215 * Create id from given source 216 */ 217export function createSourceId(source: HashSource): string { 218 switch (source.type) { 219 case 'contents': 220 return source.id; 221 case 'file': 222 return source.filePath; 223 case 'dir': 224 return source.filePath; 225 default: 226 throw new Error('Unsupported source type'); 227 } 228} 229