Socket
Socket
Sign inDemoInstall

@expo/fingerprint

Package Overview
Dependencies
Maintainers
23
Versions
62
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@expo/fingerprint - npm Package Compare versions

Comparing version 0.1.0 to 0.2.0

src/sourcer/__tests__/Utils-test.ts

2

build/Fingerprint.js

@@ -13,3 +13,3 @@ "use strict";

async function createFingerprintAsync(projectRoot, options) {
const opts = (0, Options_1.normalizeOptions)(options);
const opts = await (0, Options_1.normalizeOptionsAsync)(projectRoot, options);
const sources = await (0, Sourcer_1.getHashSourcesAsync)(projectRoot, opts);

@@ -16,0 +16,0 @@ const normalizedSources = (0, Sort_1.sortSources)((0, Dedup_1.dedupSources)(sources, projectRoot));

@@ -36,5 +36,16 @@ /// <reference types="node" />

* Default is `['android/build', 'android/app/build', 'android/app/.cxx', 'ios/Pods']`.
* @deprecated Use `ignorePaths` instead.
*/
dirExcludes?: string[];
/**
* Ignore files and directories from hashing. This supported pattern is as `glob()`.
*
* Please note that the pattern matching is slightly different from gitignore. For example, we don't support partial matching where `build` does not match `android/build`. You should use `'**' + '/build'` instead.
* @see [minimatch implementations](https://github.com/isaacs/minimatch#comparisons-to-other-fnmatchglob-implementations) for more reference.
*
* Besides this `ignorePaths`, fingerprint comes with implicit default ignorePaths defined in `Options.DEFAULT_IGNORE_PATHS`.
* If you want to override the default ignorePaths, use `!` prefix.
*/
ignorePaths?: string[];
/**
* Additional sources for hashing.

@@ -48,3 +59,3 @@ */

hashAlgorithm: NonNullable<Options['hashAlgorithm']>;
dirExcludes: NonNullable<Options['dirExcludes']>;
ignorePaths: NonNullable<Options['ignorePaths']>;
}

@@ -51,0 +62,0 @@ export interface HashSourceFile {

@@ -0,1 +1,2 @@

import minimatch from 'minimatch';
import pLimit from 'p-limit';

@@ -15,4 +16,8 @@ import type { Fingerprint, FingerprintSource, HashResult, HashSource, HashSourceContents, NormalizedOptions } from '../Fingerprint.types';

*/
export declare function createFileHashResultsAsync(filePath: string, limiter: pLimit.Limit, projectRoot: string, options: NormalizedOptions): Promise<HashResult>;
export declare function createFileHashResultsAsync(filePath: string, limiter: pLimit.Limit, projectRoot: string, options: NormalizedOptions): Promise<HashResult | null>;
/**
* Indicate the given `filePath` should be excluded by `ignorePaths`
*/
export declare function isIgnoredPath(filePath: string, ignorePaths: string[], minimatchOptions?: minimatch.IOptions): boolean;
/**
* Create `HashResult` for a dir.

@@ -19,0 +24,0 @@ * If the dir is excluded, returns null rather than a HashResult

@@ -6,3 +6,3 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
exports.createSourceId = exports.createContentsHashResultsAsync = exports.createDirHashResultsAsync = exports.createFileHashResultsAsync = exports.createFingerprintSourceAsync = exports.createFingerprintFromSourcesAsync = void 0;
exports.createSourceId = exports.createContentsHashResultsAsync = exports.createDirHashResultsAsync = exports.isIgnoredPath = exports.createFileHashResultsAsync = exports.createFingerprintSourceAsync = exports.createFingerprintFromSourcesAsync = void 0;
const crypto_1 = require("crypto");

@@ -64,2 +64,6 @@ const fs_1 = require("fs");

return limiter(async () => {
if (isIgnoredPath(filePath, options.ignorePaths)) {
return null;
}
const hasher = createHash(options.hashAlgorithm);

@@ -81,2 +85,5 @@

return new Promise((resolve, reject) => {
if (isIgnoredPath(filePath, options.ignorePaths)) {
return resolve(null);
}
let resolved = false;

@@ -103,12 +110,19 @@ const hasher = (0, crypto_1.createHash)(options.hashAlgorithm);

/**
* Indicate the given `dirPath` should be excluded by `dirExcludes`
* Indicate the given `filePath` should be excluded by `ignorePaths`
*/
function isExcludedDir(dirPath, dirExcludes) {
for (const exclude of dirExcludes) {
if ((0, minimatch_1.default)(dirPath, exclude)) {
return true;
function isIgnoredPath(filePath, ignorePaths, minimatchOptions = { dot: true }) {
const minimatchObjs = ignorePaths.map((ignorePath) => new minimatch_1.default.Minimatch(ignorePath, minimatchOptions));
let result = false;
for (const minimatchObj of minimatchObjs) {
const currMatch = minimatchObj.match(filePath);
if (minimatchObj.negate && result && !currMatch) {
// Special handler for negate (!pattern).
// As long as previous match result is true and not matched from the current negate pattern, we should early return.
return false;
}
result || (result = currMatch);
}
return false;
return result;
}
exports.isIgnoredPath = isIgnoredPath;
/**

@@ -119,3 +133,3 @@ * Create `HashResult` for a dir.

async function createDirHashResultsAsync(dirPath, limiter, projectRoot, options, depth = 0) {
if (isExcludedDir(dirPath, options.dirExcludes)) {
if (isIgnoredPath(dirPath, options.ignorePaths)) {
return null;

@@ -136,8 +150,9 @@ }

const hasher = (0, crypto_1.createHash)(options.hashAlgorithm);
const results = await Promise.all(promises);
const results = (await Promise.all(promises)).filter((result) => result != null);
if (results.length === 0) {
return null;
}
for (const result of results) {
if (result != null) {
hasher.update(result.id);
hasher.update(result.hex);
}
hasher.update(result.id);
hasher.update(result.hex);
}

@@ -144,0 +159,0 @@ const hex = hasher.digest('hex');

import type { NormalizedOptions, Options } from './Fingerprint.types';
export declare function normalizeOptions(options?: Options): NormalizedOptions;
export declare const FINGERPRINT_IGNORE_FILENAME = ".fingerprintignore";
export declare const DEFAULT_IGNORE_PATHS: string[];
export declare function normalizeOptionsAsync(projectRoot: string, options?: Options): Promise<NormalizedOptions>;

@@ -6,5 +6,20 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeOptions = void 0;
exports.normalizeOptionsAsync = exports.DEFAULT_IGNORE_PATHS = exports.FINGERPRINT_IGNORE_FILENAME = void 0;
const promises_1 = __importDefault(require("fs/promises"));
const os_1 = __importDefault(require("os"));
function normalizeOptions(options) {
const path_1 = __importDefault(require("path"));
exports.FINGERPRINT_IGNORE_FILENAME = '.fingerprintignore';
exports.DEFAULT_IGNORE_PATHS = [
exports.FINGERPRINT_IGNORE_FILENAME,
'**/android/build/**/*',
'**/android/app/build/**/*',
'**/android/app/.cxx/**/*',
'**/ios/Pods/**/*',
// Ignore all expo configs because we will read expo config in a HashSourceContents already
'app.config.ts',
'app.config.js',
'app.config.json',
'app.json',
];
async function normalizeOptionsAsync(projectRoot, options) {
return {

@@ -15,11 +30,26 @@ ...options,

hashAlgorithm: options?.hashAlgorithm ?? 'sha1',
dirExcludes: options?.dirExcludes ?? [
'**/android/build',
'**/android/app/build',
'**/android/app/.cxx',
'ios/Pods',
],
ignorePaths: await collectIgnorePathsAsync(projectRoot, options),
};
}
exports.normalizeOptions = normalizeOptions;
exports.normalizeOptionsAsync = normalizeOptionsAsync;
async function collectIgnorePathsAsync(projectRoot, options) {
const ignorePaths = [
...exports.DEFAULT_IGNORE_PATHS,
...(options?.ignorePaths ?? []),
...(options?.dirExcludes?.map((dirExclude) => `${dirExclude}/**/*`) ?? []),
];
const fingerprintIgnorePath = path_1.default.join(projectRoot, exports.FINGERPRINT_IGNORE_FILENAME);
try {
const fingerprintIgnore = await promises_1.default.readFile(fingerprintIgnorePath, 'utf8');
const fingerprintIgnoreLines = fingerprintIgnore.split('\n');
for (const line of fingerprintIgnoreLines) {
const trimmedLine = line.trim();
if (trimmedLine) {
ignorePaths.push(trimmedLine);
}
}
}
catch { }
return ignorePaths;
}
//# sourceMappingURL=Options.js.map

@@ -16,2 +16,3 @@ "use strict";

async function getExpoConfigSourcesAsync(projectRoot, options) {
const results = [];
let config;

@@ -21,2 +22,8 @@ try {

config = await getConfig(projectRoot, { skipSDKVersionRequirement: true });
results.push({
type: 'contents',
id: 'expoConfig',
contents: normalizeExpoConfig(config.exp),
reasons: ['expoConfig'],
});
}

@@ -27,13 +34,2 @@ catch (e) {

}
const results = [];
// app config files
const configFiles = ['app.config.ts', 'app.config.js', 'app.config.json', 'app.json'];
const configFileSources = (await Promise.all(configFiles.map(async (file) => {
const result = await (0, Utils_1.getFileBasedHashSourceAsync)(projectRoot, file, 'expoConfig');
if (result != null) {
debug(`Adding config file - ${chalk_1.default.dim(file)}`);
}
return result;
}))).filter(Boolean);
results.push(...configFileSources);
// external files in config

@@ -84,2 +80,9 @@ const isAndroid = options.platforms.includes('android');

}
function normalizeExpoConfig(config) {
// Deep clone by JSON.parse/stringify that assumes the config is serializable.
const normalizedConfig = JSON.parse(JSON.stringify(config));
delete normalizedConfig.runtimeVersion;
delete normalizedConfig._internal;
return (0, Utils_1.stringifyJsonSorted)(normalizedConfig);
}
function getConfigPluginSourcesAsync(projectRoot, plugins) {

@@ -86,0 +89,0 @@ if (plugins == null) {

import type { HashSource } from '../Fingerprint.types';
export declare function getFileBasedHashSourceAsync(projectRoot: string, filePath: string, reason: string): Promise<HashSource | null>;
/**
* A version of `JSON.stringify` that keeps the keys sorted
*/
export declare function stringifyJsonSorted(target: any, space?: string | number | undefined): string;

@@ -6,3 +6,3 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
exports.getFileBasedHashSourceAsync = void 0;
exports.stringifyJsonSorted = exports.getFileBasedHashSourceAsync = void 0;
const promises_1 = __importDefault(require("fs/promises"));

@@ -26,2 +26,39 @@ const path_1 = __importDefault(require("path"));

exports.getFileBasedHashSourceAsync = getFileBasedHashSourceAsync;
/**
* A version of `JSON.stringify` that keeps the keys sorted
*/
function stringifyJsonSorted(target, space) {
return JSON.stringify(target, (_, value) => sortJson(value), space);
}
exports.stringifyJsonSorted = stringifyJsonSorted;
function sortJson(json) {
if (Array.isArray(json)) {
return json.sort((a, b) => {
// Sort array items by their stringified value.
// We don't need the array to be sorted in meaningful way, just to be sorted in deterministic.
// E.g. `[{ b: '2' }, {}, { a: '3' }, null]` -> `[null, { a : '3' }, { b: '2' }, {}]`
// This result is not a perfect solution, but it's good enough for our use case.
const stringifiedA = stringifyJsonSorted(a);
const stringifiedB = stringifyJsonSorted(b);
if (stringifiedA < stringifiedB) {
return -1;
}
else if (stringifiedA > stringifiedB) {
return 1;
}
return 0;
});
}
if (json != null && typeof json === 'object') {
// Sort object items by keys
return Object.keys(json)
.sort()
.reduce((acc, key) => {
acc[key] = json[key];
return acc;
}, {});
}
// Return primitives
return json;
}
//# sourceMappingURL=Utils.js.map

@@ -13,2 +13,12 @@ # Changelog

## 0.2.0 — 2023-09-08
### 🛠 Breaking changes
- Normalize Expo config and remove `runtimeVersion` from fingerprint. Note that the fingerprint result will be changed from this version. ([#24290](https://github.com/expo/expo/pull/24290) by [@Kudo](https://github.com/kudo))
### 🎉 New features
- Added `options.ignorePaths` and **.fingerprintignore** to support. ([#24265](https://github.com/expo/expo/pull/24265) by [@Kudo](https://github.com/kudo))
## 0.1.0 — 2023-08-29

@@ -15,0 +25,0 @@

@@ -11,3 +11,3 @@ import spawnAsync from '@expo/spawn-async';

} from '../../src/Fingerprint';
import { normalizeOptions } from '../../src/Options';
import { normalizeOptionsAsync } from '../../src/Options';
import { getHashSourcesAsync } from '../../src/sourcer/Sourcer';

@@ -69,3 +69,3 @@

const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
config.jsEngine = 'hermes';
config.expo.jsEngine = 'hermes';
await fs.writeFile(configPath, JSON.stringify(config, null, 2));

@@ -152,5 +152,8 @@

it('should match snapshot', async () => {
const sources = await getHashSourcesAsync(projectRoot, normalizeOptions());
const sources = await getHashSourcesAsync(
projectRoot,
await normalizeOptionsAsync(projectRoot)
);
expect(sources).toMatchSnapshot();
});
});
{
"name": "@expo/fingerprint",
"version": "0.1.0",
"version": "0.2.0",
"description": "A library to generate a fingerprint from a React Native project",

@@ -51,3 +51,3 @@ "main": "build/index.js",

},
"gitHead": "1f5e9f77f22e5d6a464d34194236684a45fcf322"
"gitHead": "431682b5adf82c17d11da7dc9b4648801299f520"
}

@@ -5,3 +5,3 @@ import { vol } from 'memfs';

import type { Fingerprint } from '../Fingerprint.types';
import { normalizeOptions } from '../Options';
import { normalizeOptionsAsync } from '../Options';

@@ -19,4 +19,8 @@ jest.mock('fs');

vol.fromJSON(require('../sourcer/__tests__/fixtures/ExpoManaged47Project.json'));
const fingerprint = await createFingerprintAsync('/app', normalizeOptions());
const diff = await diffFingerprintChangesAsync(fingerprint, '/app', normalizeOptions());
const fingerprint = await createFingerprintAsync('/app', await normalizeOptionsAsync('/app'));
const diff = await diffFingerprintChangesAsync(
fingerprint,
'/app',
await normalizeOptionsAsync('/app')
);
expect(diff.length).toBe(0);

@@ -32,12 +36,17 @@ });

const diff = await diffFingerprintChangesAsync(fingerprint, '/app', normalizeOptions());
const diff = await diffFingerprintChangesAsync(
fingerprint,
'/app',
await normalizeOptionsAsync('/app')
);
expect(diff).toMatchInlineSnapshot(`
[
{
"filePath": "app.json",
"hash": "1fd2d92d50dc1da96b41795046b9ea4e30dd2b48",
"contents": "{"android":{"adaptiveIcon":{"backgroundColor":"#FFFFFF","foregroundImage":"./assets/adaptive-icon.png"}},"assetBundlePatterns":["**/*"],"icon":"./assets/icon.png","ios":{"supportsTablet":true},"name":"sdk47","orientation":"portrait","platforms":["android","ios","web"],"slug":"sdk47","splash":{"backgroundColor":"#ffffff","image":"./assets/splash.png","resizeMode":"contain"},"updates":{"fallbackToCacheTimeout":0},"userInterfaceStyle":"light","version":"1.0.0","web":{"favicon":"./assets/favicon.png"}}",
"hash": "33b2b95de3b0b474810630e51527a2c0a6e5de9c",
"id": "expoConfig",
"reasons": [
"expoConfig",
],
"type": "file",
"type": "contents",
},

@@ -52,3 +61,3 @@ ]

jest.doMock('/app/package.json', () => packageJson, { virtual: true });
const fingerprint = await createFingerprintAsync('/app', normalizeOptions());
const fingerprint = await createFingerprintAsync('/app', await normalizeOptionsAsync('/app'));

@@ -58,3 +67,7 @@ // first round for bumping package version which should not cause changes

jest.doMock('/app/package.json', () => packageJson, { virtual: true });
let diff = await diffFingerprintChangesAsync(fingerprint, '/app', normalizeOptions());
let diff = await diffFingerprintChangesAsync(
fingerprint,
'/app',
await normalizeOptionsAsync('/app')
);
expect(diff.length).toBe(0);

@@ -66,3 +79,7 @@

jest.doMock('/app/package.json', () => packageJson, { virtual: true });
diff = await diffFingerprintChangesAsync(fingerprint, '/app', normalizeOptions());
diff = await diffFingerprintChangesAsync(
fingerprint,
'/app',
await normalizeOptionsAsync('/app')
);
jest.dontMock('/app/package.json');

@@ -86,16 +103,21 @@ expect(diff).toMatchInlineSnapshot(`

vol.fromJSON(require('../sourcer/__tests__/fixtures/ExpoManaged47Project.json'));
const fingerprint = await createFingerprintAsync('/app', normalizeOptions());
const fingerprint = await createFingerprintAsync('/app', await normalizeOptionsAsync('/app'));
const config = JSON.parse(vol.readFileSync('/app/app.json', 'utf8').toString());
config.expo.jsEngine = 'jsc';
vol.writeFileSync('/app/app.json', JSON.stringify(config, null, 2));
const diff = await diffFingerprintChangesAsync(fingerprint, '/app', normalizeOptions());
const diff = await diffFingerprintChangesAsync(
fingerprint,
'/app',
await normalizeOptionsAsync('/app')
);
expect(diff).toMatchInlineSnapshot(`
[
{
"filePath": "app.json",
"hash": "9ff1b51ca9b9435e8b849bcc82e3900d70f0feee",
"contents": "{"android":{"adaptiveIcon":{"backgroundColor":"#FFFFFF","foregroundImage":"./assets/adaptive-icon.png"}},"assetBundlePatterns":["**/*"],"icon":"./assets/icon.png","ios":{"supportsTablet":true},"jsEngine":"jsc","name":"sdk47","orientation":"portrait","platforms":["android","ios","web"],"slug":"sdk47","splash":{"backgroundColor":"#ffffff","image":"./assets/splash.png","resizeMode":"contain"},"updates":{"fallbackToCacheTimeout":0},"userInterfaceStyle":"light","version":"1.0.0","web":{"favicon":"./assets/favicon.png"}}",
"hash": "7068a4234e7312c6ac54b776ea4dfad0ac789b2a",
"id": "expoConfig",
"reasons": [
"expoConfig",
],
"type": "file",
"type": "contents",
},

@@ -108,5 +130,9 @@ ]

vol.fromJSON(require('../sourcer/__tests__/fixtures/BareReactNative70Project.json'));
const fingerprint = await createFingerprintAsync('/app', normalizeOptions());
const fingerprint = await createFingerprintAsync('/app', await normalizeOptionsAsync('/app'));
vol.writeFileSync('/app/ios/README.md', '# Adding new file in ios dir');
const diff = await diffFingerprintChangesAsync(fingerprint, '/app', normalizeOptions());
const diff = await diffFingerprintChangesAsync(
fingerprint,
'/app',
await normalizeOptionsAsync('/app')
);
expect(diff).toMatchInlineSnapshot(`

@@ -113,0 +139,0 @@ [

import { dedupSources } from './Dedup';
import type { Fingerprint, FingerprintSource, Options } from './Fingerprint.types';
import { normalizeOptions } from './Options';
import { normalizeOptionsAsync } from './Options';
import { sortSources } from './Sort';

@@ -15,3 +15,3 @@ import { createFingerprintFromSourcesAsync } from './hash/Hash';

): Promise<Fingerprint> {
const opts = normalizeOptions(options);
const opts = await normalizeOptionsAsync(projectRoot, options);
const sources = await getHashSourcesAsync(projectRoot, opts);

@@ -18,0 +18,0 @@ const normalizedSources = sortSources(dedupSources(sources, projectRoot));

@@ -42,2 +42,3 @@ export type FingerprintSource = HashSource & {

* Default is `['android/build', 'android/app/build', 'android/app/.cxx', 'ios/Pods']`.
* @deprecated Use `ignorePaths` instead.
*/

@@ -47,2 +48,13 @@ dirExcludes?: string[];

/**
* Ignore files and directories from hashing. This supported pattern is as `glob()`.
*
* Please note that the pattern matching is slightly different from gitignore. For example, we don't support partial matching where `build` does not match `android/build`. You should use `'**' + '/build'` instead.
* @see [minimatch implementations](https://github.com/isaacs/minimatch#comparisons-to-other-fnmatchglob-implementations) for more reference.
*
* Besides this `ignorePaths`, fingerprint comes with implicit default ignorePaths defined in `Options.DEFAULT_IGNORE_PATHS`.
* If you want to override the default ignorePaths, use `!` prefix.
*/
ignorePaths?: string[];
/**
* Additional sources for hashing.

@@ -59,3 +71,3 @@ */

hashAlgorithm: NonNullable<Options['hashAlgorithm']>;
dirExcludes: NonNullable<Options['dirExcludes']>;
ignorePaths: NonNullable<Options['ignorePaths']>;
}

@@ -62,0 +74,0 @@

@@ -7,3 +7,3 @@ import { createHash } from 'crypto';

import { HashSource } from '../../Fingerprint.types';
import { normalizeOptions } from '../../Options';
import { normalizeOptionsAsync } from '../../Options';
import {

@@ -16,2 +16,3 @@ createContentsHashResultsAsync,

createSourceId,
isIgnoredPath,
} from '../Hash';

@@ -28,14 +29,17 @@

it('snapshot', async () => {
const filePath = 'assets/icon.png';
vol.mkdirSync('/app');
vol.writeFileSync(path.join('/app', 'app.json'), '{}');
vol.mkdirSync('/app/assets');
vol.writeFileSync(path.join('/app', filePath), '{}');
const sources: HashSource[] = [
{ type: 'contents', id: 'foo', contents: 'HelloWorld', reasons: ['foo'] },
{ type: 'file', filePath: 'app.json', reasons: ['expoConfig'] },
{ type: 'file', filePath, reasons: ['icon'] },
];
expect(await createFingerprintFromSourcesAsync(sources, '/app', normalizeOptions()))
.toMatchInlineSnapshot(`
expect(
await createFingerprintFromSourcesAsync(sources, '/app', await normalizeOptionsAsync('/app'))
).toMatchInlineSnapshot(`
{
"hash": "ec7d81780f735d5e289b27cdcc04a6c99d2621dc",
"hash": "ca7d58cd60289daa5cddcf99fcaa1d339bfc2c1a",
"sources": [

@@ -52,6 +56,6 @@ {

{
"filePath": "app.json",
"filePath": "assets/icon.png",
"hash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f",
"reasons": [
"expoConfig",
"icon",
],

@@ -79,3 +83,8 @@ "type": "file",

expect(
await createFingerprintSourceAsync(source, pLimit(1), '/app', normalizeOptions())
await createFingerprintSourceAsync(
source,
pLimit(1),
'/app',
await normalizeOptionsAsync('/app')
)
).toEqual(expectedResult);

@@ -89,3 +98,3 @@ });

const contents = '{}';
const options = normalizeOptions();
const options = await normalizeOptionsAsync('/app');
const result = await createContentsHashResultsAsync(

@@ -113,7 +122,8 @@ {

it('should return {id, hex} result', async () => {
const filePath = 'app.json';
const filePath = 'assets/icon.png';
const contents = '{}';
const limiter = pLimit(1);
const options = normalizeOptions();
const options = await normalizeOptionsAsync('/app');
vol.mkdirSync('/app');
vol.mkdirSync('/app/assets');
vol.writeFileSync(path.join('/app', filePath), contents);

@@ -124,5 +134,18 @@

const expectHex = createHash(options.hashAlgorithm).update(contents).digest('hex');
expect(result.id).toEqual(filePath);
expect(result.hex).toEqual(expectHex);
expect(result?.id).toEqual(filePath);
expect(result?.hex).toEqual(expectHex);
});
it('should ignore file if it is in options.ignorePaths', async () => {
const filePath = 'app.json';
const contents = '{}';
const limiter = pLimit(1);
const options = await normalizeOptionsAsync('/app');
options.ignorePaths = ['*.json'];
vol.mkdirSync('/app');
vol.writeFileSync(path.join('/app', filePath), contents);
const result = await createFileHashResultsAsync(filePath, limiter, '/app', options);
expect(result).toBe(null);
});
});

@@ -137,3 +160,3 @@

const limiter = pLimit(3);
const options = normalizeOptions();
const options = await normalizeOptionsAsync('/app');
const volJSON = {

@@ -152,5 +175,29 @@ '/app/ios/Podfile': '...',

it('should ignore dir if it is in options.ignorePaths', async () => {
const limiter = pLimit(3);
const options = await normalizeOptionsAsync('/app');
options.ignorePaths = ['ios/**/*', 'android/**/*'];
const volJSON = {
'/app/ios/Podfile': '...',
'/app/eas.json': '{}',
'/app/app.json': '{}',
'/app/android/build.gradle': '...',
};
vol.fromJSON(volJSON);
const fingerprint1 = await createDirHashResultsAsync('.', limiter, '/app', options);
vol.reset();
const volJSONIgnoreNativeProjects = {
'/app/eas.json': '{}',
'/app/app.json': '{}',
};
vol.fromJSON(volJSONIgnoreNativeProjects);
const fingerprint2 = await createDirHashResultsAsync('.', limiter, '/app', options);
expect(fingerprint1).toEqual(fingerprint2);
});
it('should return stable result from sorted files', async () => {
const limiter = pLimit(3);
const options = normalizeOptions();
const options = await normalizeOptionsAsync('/app');
const volJSON = {

@@ -203,1 +250,36 @@ '/app/ios/Podfile': '...',

});
describe(isIgnoredPath, () => {
it('should support file pattern', () => {
expect(isIgnoredPath('app.json', ['app.json'])).toBe(true);
expect(isIgnoredPath('app.ts', ['*.{js,ts}'])).toBe(true);
expect(isIgnoredPath('/dir/app.json', ['/dir/*.json'])).toBe(true);
});
it('should support directory pattern', () => {
expect(isIgnoredPath('/app/ios/Podfile', ['**/ios/**/*'])).toBe(true);
});
it('case sensitive by design', () => {
expect(isIgnoredPath('app.json', ['APP.JSON'])).toBe(false);
});
it('should include dot files from wildcard pattern', () => {
expect(isIgnoredPath('.bashrc', ['*'])).toBe(true);
});
it('no `matchBase` and `partial` by design', () => {
expect(isIgnoredPath('/dir/app.json', ['app.json'])).toBe(false);
});
it('match a file inside a dir should use a globstar', () => {
expect(isIgnoredPath('/dir/app.ts', ['*'])).toBe(false);
expect(isIgnoredPath('/dir/app.ts', ['**/*'])).toBe(true);
});
it('should use `!` to override default ignorePaths', () => {
const ignorePaths = ['**/ios/**/*', '!**/ios/Podfile', '**/android/**/*'];
expect(isIgnoredPath('/app/ios/Podfile', ignorePaths)).toBe(false);
expect(isIgnoredPath('/app/ios/Podfile.lock', ignorePaths)).toBe(true);
});
});

@@ -85,6 +85,10 @@ import { createHash } from 'crypto';

options: NormalizedOptions
): Promise<HashResult> {
): Promise<HashResult | null> {
// Backup code for faster hashing
/*
return limiter(async () => {
if (isIgnoredPath(filePath, options.ignorePaths)) {
return null;
}
const hasher = createHash(options.hashAlgorithm);

@@ -106,3 +110,7 @@

return limiter(() => {
return new Promise<HashResult>((resolve, reject) => {
return new Promise<HashResult | null>((resolve, reject) => {
if (isIgnoredPath(filePath, options.ignorePaths)) {
return resolve(null);
}
let resolved = false;

@@ -129,11 +137,24 @@ const hasher = createHash(options.hashAlgorithm);

/**
* Indicate the given `dirPath` should be excluded by `dirExcludes`
* Indicate the given `filePath` should be excluded by `ignorePaths`
*/
function isExcludedDir(dirPath: string, dirExcludes: string[]): boolean {
for (const exclude of dirExcludes) {
if (minimatch(dirPath, exclude)) {
return true;
export function isIgnoredPath(
filePath: string,
ignorePaths: string[],
minimatchOptions: minimatch.IOptions = { dot: true }
): boolean {
const minimatchObjs = ignorePaths.map(
(ignorePath) => new minimatch.Minimatch(ignorePath, minimatchOptions)
);
let result = false;
for (const minimatchObj of minimatchObjs) {
const currMatch = minimatchObj.match(filePath);
if (minimatchObj.negate && result && !currMatch) {
// Special handler for negate (!pattern).
// As long as previous match result is true and not matched from the current negate pattern, we should early return.
return false;
}
result ||= currMatch;
}
return false;
return result;
}

@@ -152,3 +173,3 @@

): Promise<HashResult | null> {
if (isExcludedDir(dirPath, options.dirExcludes)) {
if (isIgnoredPath(dirPath, options.ignorePaths)) {
return null;

@@ -171,8 +192,11 @@ }

const hasher = createHash(options.hashAlgorithm);
const results = await Promise.all(promises);
const results = (await Promise.all(promises)).filter(
(result): result is HashResult => result != null
);
if (results.length === 0) {
return null;
}
for (const result of results) {
if (result != null) {
hasher.update(result.id);
hasher.update(result.hex);
}
hasher.update(result.id);
hasher.update(result.hex);
}

@@ -179,0 +203,0 @@ const hex = hasher.digest('hex');

@@ -0,6 +1,27 @@

import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import type { NormalizedOptions, Options } from './Fingerprint.types';
export function normalizeOptions(options?: Options): NormalizedOptions {
export const FINGERPRINT_IGNORE_FILENAME = '.fingerprintignore';
export const DEFAULT_IGNORE_PATHS = [
FINGERPRINT_IGNORE_FILENAME,
'**/android/build/**/*',
'**/android/app/build/**/*',
'**/android/app/.cxx/**/*',
'**/ios/Pods/**/*',
// Ignore all expo configs because we will read expo config in a HashSourceContents already
'app.config.ts',
'app.config.js',
'app.config.json',
'app.json',
];
export async function normalizeOptionsAsync(
projectRoot: string,
options?: Options
): Promise<NormalizedOptions> {
return {

@@ -11,9 +32,26 @@ ...options,

hashAlgorithm: options?.hashAlgorithm ?? 'sha1',
dirExcludes: options?.dirExcludes ?? [
'**/android/build',
'**/android/app/build',
'**/android/app/.cxx',
'ios/Pods',
],
ignorePaths: await collectIgnorePathsAsync(projectRoot, options),
};
}
async function collectIgnorePathsAsync(projectRoot: string, options?: Options): Promise<string[]> {
const ignorePaths = [
...DEFAULT_IGNORE_PATHS,
...(options?.ignorePaths ?? []),
...(options?.dirExcludes?.map((dirExclude) => `${dirExclude}/**/*`) ?? []),
];
const fingerprintIgnorePath = path.join(projectRoot, FINGERPRINT_IGNORE_FILENAME);
try {
const fingerprintIgnore = await fs.readFile(fingerprintIgnorePath, 'utf8');
const fingerprintIgnoreLines = fingerprintIgnore.split('\n');
for (const line of fingerprintIgnoreLines) {
const trimmedLine = line.trim();
if (trimmedLine) {
ignorePaths.push(trimmedLine);
}
}
} catch {}
return ignorePaths;
}

@@ -6,3 +6,3 @@ import spawnAsync from '@expo/spawn-async';

import { normalizeOptions } from '../../Options';
import { normalizeOptionsAsync } from '../../Options';
import {

@@ -25,6 +25,6 @@ getBareAndroidSourcesAsync,

vol.fromJSON(require('./fixtures/BareReactNative70Project.json'));
let sources = await getBareAndroidSourcesAsync('/app', normalizeOptions());
let sources = await getBareAndroidSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(expect.objectContaining({ filePath: 'android', type: 'dir' }));
sources = await getBareIosSourcesAsync('/app', normalizeOptions());
sources = await getBareIosSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(expect.objectContaining({ filePath: 'ios', type: 'dir' }));

@@ -52,3 +52,6 @@ });

});
const sources = await getRncliAutolinkingSourcesAsync('/root/apps/demo', normalizeOptions());
const sources = await getRncliAutolinkingSourcesAsync(
'/root/apps/demo',
await normalizeOptionsAsync('/app')
);
expect(sources).toContainEqual(

@@ -76,3 +79,6 @@ expect.objectContaining({

});
const sources = await getRncliAutolinkingSourcesAsync('/root/apps/demo', normalizeOptions());
const sources = await getRncliAutolinkingSourcesAsync(
'/root/apps/demo',
await normalizeOptionsAsync('/app')
);
for (const source of sources) {

@@ -79,0 +85,0 @@ if (source.type === 'dir' || source.type === 'file') {

@@ -8,3 +8,4 @@ import spawnAsync from '@expo/spawn-async';

import { normalizeOptions } from '../../Options';
import { HashSourceContents } from '../../Fingerprint.types';
import { normalizeOptionsAsync } from '../../Options';
import {

@@ -59,3 +60,3 @@ getEasBuildSourcesAsync,

const sources = await getEasBuildSourcesAsync('/app', normalizeOptions());
const sources = await getEasBuildSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(

@@ -102,3 +103,6 @@ expect.objectContaining({

it('should contain expo autolinking projects', async () => {
let sources = await getExpoAutolinkingAndroidSourcesAsync('/app', normalizeOptions());
let sources = await getExpoAutolinkingAndroidSourcesAsync(
'/app',
await normalizeOptionsAsync('/app')
);
expect(sources).toContainEqual(

@@ -112,3 +116,3 @@ expect.objectContaining({

sources = await getExpoAutolinkingIosSourcesAsync('/app', normalizeOptions());
sources = await getExpoAutolinkingIosSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(

@@ -121,3 +125,6 @@ expect.objectContaining({ type: 'dir', filePath: 'node_modules/expo-modules-core' })

it('should not containt absolute path in contents', async () => {
let sources = await getExpoAutolinkingAndroidSourcesAsync('/app', normalizeOptions());
let sources = await getExpoAutolinkingAndroidSourcesAsync(
'/app',
await normalizeOptionsAsync('/app')
);
for (const source of sources) {

@@ -129,3 +136,3 @@ if (source.type === 'contents') {

sources = await getExpoAutolinkingIosSourcesAsync('/app', normalizeOptions());
sources = await getExpoAutolinkingIosSourcesAsync('/app', await normalizeOptionsAsync('/app'));
for (const source of sources) {

@@ -156,17 +163,58 @@ if (source.type === 'contents') {

});
const sources = await getExpoConfigSourcesAsync('/app', normalizeOptions());
const sources = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources.length).toBe(0);
});
it('should contain app.json', async () => {
it('should contain expo config', async () => {
vol.fromJSON(require('./fixtures/ExpoManaged47Project.json'));
const sources = await getExpoConfigSourcesAsync('/app', normalizeOptions());
expect(sources).toContainEqual(
expect.objectContaining({
type: 'file',
filePath: 'app.json',
})
const appJson = JSON.parse(vol.readFileSync('/app/app.json', 'utf8').toString());
const sources = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
const expoConfigSource = sources.find<HashSourceContents>(
(source): source is HashSourceContents =>
source.type === 'contents' && source.id === 'expoConfig'
);
const expoConfig = JSON.parse(expoConfigSource?.contents?.toString() ?? 'null');
expect(expoConfig).not.toBeNull();
expect(expoConfig.name).toEqual(appJson.expo.name);
});
it('should not contain runtimeVersion in expo config', async () => {
vol.fromJSON(require('./fixtures/ExpoManaged47Project.json'));
vol.writeFileSync(
'/app/app.config.js',
`\
export default ({ config }) => {
config.runtimeVersion = '1.0.0';
return config;
};`
);
const sources = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
const expoConfigSource = sources.find<HashSourceContents>(
(source): source is HashSourceContents =>
source.type === 'contents' && source.id === 'expoConfig'
);
const expoConfig = JSON.parse(expoConfigSource?.contents?.toString() ?? 'null');
expect(expoConfig).not.toBeNull();
expect(expoConfig.runtimeVersion).toBeUndefined();
});
it('should keep expo config contents in deterministic order', async () => {
vol.fromJSON(require('./fixtures/ExpoManaged47Project.json'));
const sources = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
const appJsonContents = vol.readFileSync('/app/app.json', 'utf8').toString();
const appJson = JSON.parse(appJsonContents);
const { name } = appJson.expo;
// Re-insert name to change the object order
delete appJson.expo.name;
appJson.expo.name = name;
const newAppJsonContents = JSON.stringify(appJson);
expect(newAppJsonContents).not.toEqual(appJsonContents);
vol.writeFileSync('/app/app.json', newAppJsonContents);
// Even new app.json contents changed its order, the source contents should be the same.
const sources2 = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toEqual(sources2);
});
it('should contain external icon file in app.json', async () => {

@@ -176,3 +224,3 @@ vol.fromJSON(require('./fixtures/ExpoManaged47Project.json'));

vol.writeFileSync('/app/assets/icon.png', 'PNG data');
const sources = await getExpoConfigSourcesAsync('/app', normalizeOptions());
const sources = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(

@@ -232,3 +280,3 @@ expect.objectContaining({

);
const sources = await getExpoConfigSourcesAsync('/app', normalizeOptions());
const sources = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(

@@ -255,3 +303,3 @@ expect.objectContaining({

);
const sources = await getExpoConfigSourcesAsync('/app', normalizeOptions());
const sources = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(

@@ -273,3 +321,3 @@ expect.objectContaining({

);
const sources = await getExpoConfigSourcesAsync('/app', normalizeOptions());
const sources = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));

@@ -286,5 +334,12 @@ vol.writeFileSync(

);
const sources2 = await getExpoConfigSourcesAsync('/app', normalizeOptions());
const sources2 = await getExpoConfigSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toEqual(sources2);
// sources2 will contain the plugins from expo config, but sources will not.
const sourcesWithoutExpoConfig = sources.filter(
(item) => item.type !== 'contents' || item.id !== 'expoConfig'
);
const sources2WithoutExpoConfig = sources2.filter(
(item) => item.type !== 'contents' || item.id !== 'expoConfig'
);
expect(sourcesWithoutExpoConfig).toEqual(sources2WithoutExpoConfig);
});

@@ -291,0 +346,0 @@ });

import { vol } from 'memfs';
import { normalizeOptions } from '../../Options';
import { normalizeOptionsAsync } from '../../Options';
import { getPatchPackageSourcesAsync } from '../PatchPackage';

@@ -19,3 +19,3 @@ import { getHashSourcesAsync } from '../Sourcer';

const sources = await getPatchPackageSourcesAsync('/app', normalizeOptions());
const sources = await getPatchPackageSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(

@@ -45,3 +45,3 @@ expect.objectContaining({

const sources = await getHashSourcesAsync('/app', normalizeOptions());
const sources = await getHashSourcesAsync('/app', await normalizeOptionsAsync('/app'));
expect(sources).toContainEqual(

@@ -48,0 +48,0 @@ expect.objectContaining({

@@ -1,2 +0,2 @@

import { normalizeOptions } from '../../Options';
import { normalizeOptionsAsync } from '../../Options';
import { getHashSourcesAsync } from '../Sourcer';

@@ -8,3 +8,3 @@

'/app',
normalizeOptions({
await normalizeOptionsAsync('/app', {
extraSources: [{ type: 'dir', filePath: '/app/scripts', reasons: ['extra'] }],

@@ -11,0 +11,0 @@ })

@@ -9,3 +9,3 @@ import spawnAsync from '@expo/spawn-async';

import { getFileBasedHashSourceAsync } from './Utils';
import { getFileBasedHashSourceAsync, stringifyJsonSorted } from './Utils';
import type { HashSource, NormalizedOptions } from '../Fingerprint.types';

@@ -19,2 +19,4 @@

): Promise<HashSource[]> {
const results: HashSource[] = [];
let config: ProjectConfig;

@@ -24,2 +26,8 @@ try {

config = await getConfig(projectRoot, { skipSDKVersionRequirement: true });
results.push({
type: 'contents',
id: 'expoConfig',
contents: normalizeExpoConfig(config.exp),
reasons: ['expoConfig'],
});
} catch (e: unknown) {

@@ -30,19 +38,2 @@ debug('Cannot get Expo config: ' + e);

const results: HashSource[] = [];
// app config files
const configFiles = ['app.config.ts', 'app.config.js', 'app.config.json', 'app.json'];
const configFileSources = (
await Promise.all(
configFiles.map(async (file) => {
const result = await getFileBasedHashSourceAsync(projectRoot, file, 'expoConfig');
if (result != null) {
debug(`Adding config file - ${chalk.dim(file)}`);
}
return result;
})
)
).filter(Boolean) as HashSource[];
results.push(...configFileSources);
// external files in config

@@ -106,2 +97,10 @@ const isAndroid = options.platforms.includes('android');

function normalizeExpoConfig(config: ExpoConfig): string {
// Deep clone by JSON.parse/stringify that assumes the config is serializable.
const normalizedConfig: ExpoConfig = JSON.parse(JSON.stringify(config));
delete normalizedConfig.runtimeVersion;
delete normalizedConfig._internal;
return stringifyJsonSorted(normalizedConfig);
}
function getConfigPluginSourcesAsync(

@@ -108,0 +107,0 @@ projectRoot: string,

@@ -24,1 +24,40 @@ import fs from 'fs/promises';

}
/**
* A version of `JSON.stringify` that keeps the keys sorted
*/
export function stringifyJsonSorted(target: any, space?: string | number | undefined): string {
return JSON.stringify(target, (_, value) => sortJson(value), space);
}
function sortJson(json: any): any {
if (Array.isArray(json)) {
return json.sort((a, b) => {
// Sort array items by their stringified value.
// We don't need the array to be sorted in meaningful way, just to be sorted in deterministic.
// E.g. `[{ b: '2' }, {}, { a: '3' }, null]` -> `[null, { a : '3' }, { b: '2' }, {}]`
// This result is not a perfect solution, but it's good enough for our use case.
const stringifiedA = stringifyJsonSorted(a);
const stringifiedB = stringifyJsonSorted(b);
if (stringifiedA < stringifiedB) {
return -1;
} else if (stringifiedA > stringifiedB) {
return 1;
}
return 0;
});
}
if (json != null && typeof json === 'object') {
// Sort object items by keys
return Object.keys(json)
.sort()
.reduce((acc: any, key: string) => {
acc[key] = json[key];
return acc;
}, {});
}
// Return primitives
return json;
}

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc