1import { createHash } from 'crypto'; 2import { createReadStream } from 'fs'; 3import fs from 'fs/promises'; 4import pLimit from 'p-limit'; 5import path from 'path'; 6 7import type { 8 Fingerprint, 9 FingerprintSource, 10 HashResult, 11 HashSource, 12 HashSourceContents, 13 NormalizedOptions, 14} from '../Fingerprint.types'; 15import { isIgnoredPath } from '../utils/Path'; 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 * Create `HashResult` for a dir. 136 * If the dir is excluded, returns null rather than a HashResult 137 */ 138export async function createDirHashResultsAsync( 139 dirPath: string, 140 limiter: pLimit.Limit, 141 projectRoot: string, 142 options: NormalizedOptions, 143 depth: number = 0 144): Promise<HashResult | null> { 145 if (isIgnoredPath(dirPath, options.ignorePaths)) { 146 return null; 147 } 148 const dirents = (await fs.readdir(path.join(projectRoot, dirPath), { withFileTypes: true })).sort( 149 (a, b) => a.name.localeCompare(b.name) 150 ); 151 const promises: Promise<HashResult | null>[] = []; 152 for (const dirent of dirents) { 153 if (dirent.isDirectory()) { 154 const filePath = path.join(dirPath, dirent.name); 155 promises.push(createDirHashResultsAsync(filePath, limiter, projectRoot, options, depth + 1)); 156 } else if (dirent.isFile()) { 157 const filePath = path.join(dirPath, dirent.name); 158 promises.push(createFileHashResultsAsync(filePath, limiter, projectRoot, options)); 159 } 160 } 161 162 const hasher = createHash(options.hashAlgorithm); 163 const results = (await Promise.all(promises)).filter( 164 (result): result is HashResult => result != null 165 ); 166 if (results.length === 0) { 167 return null; 168 } 169 for (const result of results) { 170 hasher.update(result.id); 171 hasher.update(result.hex); 172 } 173 const hex = hasher.digest('hex'); 174 175 return { id: dirPath, hex }; 176} 177 178/** 179 * Create `HashResult` for a `HashSourceContents` 180 */ 181export async function createContentsHashResultsAsync( 182 source: HashSourceContents, 183 options: NormalizedOptions 184): Promise<HashResult> { 185 const hex = createHash(options.hashAlgorithm).update(source.contents).digest('hex'); 186 return { id: source.id, hex }; 187} 188 189/** 190 * Create id from given source 191 */ 192export function createSourceId(source: HashSource): string { 193 switch (source.type) { 194 case 'contents': 195 return source.id; 196 case 'file': 197 return source.filePath; 198 case 'dir': 199 return source.filePath; 200 default: 201 throw new Error('Unsupported source type'); 202 } 203} 204