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