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