xref: /expo/packages/@expo/fingerprint/src/hash/Hash.ts (revision 8f1dfafb)
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