Skip to content

Commit d14de6e

Browse files
feat: Validate platform version against production (#3417)
Validate `platformVersion` against the rough version used in production for MetaMask (by inspecting GitHub) and produce a warning if the Snap is using a newer version. Closes MetaMask/MetaMask-planning#4980
1 parent cbee1d7 commit d14de6e

File tree

12 files changed

+382
-11
lines changed

12 files changed

+382
-11
lines changed

packages/snaps-cli/src/commands/build/implementation.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,10 @@ describe('build', () => {
106106

107107
await build(config);
108108

109-
// Manifest checksum mismatch is the warning
110109
expect(warn).toHaveBeenCalledWith(
111-
expect.stringMatching(/Compiled 1 file in \d+ms with 1 warning\./u),
110+
expect.stringMatching(
111+
/Compiled \d+ files? in \d+ms with \d+ warnings?\./u,
112+
),
112113
);
113114

114115
const output = await fs.readFile('/snap/output.js', 'utf8');
@@ -140,9 +141,10 @@ describe('build', () => {
140141

141142
await build(config);
142143

143-
// Manifest checksum mismatch is the warning
144144
expect(warn).toHaveBeenCalledWith(
145-
expect.stringMatching(/Compiled 1 file in \d+ms with 1 warning\./u),
145+
expect.stringMatching(
146+
/Compiled \d+ files? in \d+ms with \d+ warnings?\./u,
147+
),
146148
);
147149

148150
const output = await fs.readFile('/snap/output.js', 'utf8');

packages/snaps-cli/src/commands/watch/watch.e2e.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ describe('mm-snap watch', () => {
2626
async (command) => {
2727
runner = getCommandRunner(command, ['--port', '0']);
2828
await runner.waitForStderr(
29-
/Compiled \d+ files? in \d+ms with 1 warning\./u,
29+
/Compiled \d+ files? in \d+ms with \d+ warnings?\./u,
3030
);
3131

3232
await fs.writeFile(SNAP_FILE, originalFile);
3333
await runner.waitForStdout(/Changes detected in .+, recompiling\./u);
3434
await runner.waitForStderr(
35-
/Compiled \d+ files? in \d+ms with 1 warning\./u,
35+
/Compiled \d+ files? in \d+ms with \d+ warnings?\./u,
3636
);
3737

3838
expect(runner.stdout).toContainEqual(
@@ -50,7 +50,9 @@ describe('mm-snap watch', () => {
5050
expect.stringMatching(/Building the Snap bundle\./u),
5151
);
5252
expect(runner.stderr).toContainEqual(
53-
expect.stringMatching(/Compiled \d+ files? in \d+ms with 1 warning\./u),
53+
expect.stringMatching(
54+
/Compiled \d+ files? in \d+ms with \d+ warnings?\./u,
55+
),
5456
);
5557
expect(runner.stderr).toContainEqual(
5658
expect.stringContaining(

packages/snaps-utils/coverage.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"branches": 99.76,
3-
"functions": 99.01,
4-
"lines": 98.62,
5-
"statements": 97.18
3+
"functions": 99.02,
4+
"lines": 98.66,
5+
"statements": 97.25
66
}

packages/snaps-utils/jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ module.exports = deepmerge(baseConfig, {
1919
// https://github.com/facebook/jest/issues/5274
2020
'./src/eval-worker.ts',
2121
],
22+
23+
// This is required for `jest-fetch-mock` to work.
24+
resetMocks: false,
2225
});

packages/snaps-utils/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"istanbul-lib-report": "^3.0.0",
126126
"istanbul-reports": "^3.1.5",
127127
"jest": "^29.0.2",
128+
"jest-fetch-mock": "^3.0.3",
128129
"jest-silent-reporter": "^0.6.0",
129130
"memfs": "^3.4.13",
130131
"prettier": "^3.3.3",

packages/snaps-utils/src/fs.test.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { promises as fs } from 'fs';
22
import * as path from 'path';
3-
import { join } from 'path';
3+
import { join, dirname } from 'path';
44

55
import {
66
getOutfilePath,
77
isDirectory,
88
isFile,
99
readJsonFile,
10+
useFileSystemCache,
1011
useTemporaryFile,
1112
validateDirPath,
1213
validateFilePath,
@@ -20,13 +21,15 @@ jest.mock('fs');
2021

2122
const BASE_PATH = '/snap';
2223
const MANIFEST_PATH = join(BASE_PATH, NpmSnapFileNames.Manifest);
24+
const CACHE_PATH = join(process.cwd(), 'node_modules/.cache/snaps');
2325

2426
/**
2527
* Clears out all the files in the in-memory file system, and writes the default
2628
* files to the `BASE_PATH` folder, including sub-folders.
2729
*/
2830
async function resetFileSystem() {
2931
await fs.rm(BASE_PATH, { recursive: true, force: true });
32+
await fs.rm(CACHE_PATH, { recursive: true, force: true });
3033

3134
// Create `dist` folder.
3235
await fs.mkdir(join(BASE_PATH, 'dist'), { recursive: true });
@@ -262,3 +265,95 @@ describe('useTemporaryFile', () => {
262265
expect(await isFile(filePath)).toBe(false);
263266
});
264267
});
268+
269+
describe('useFileSystemCache', () => {
270+
beforeEach(async () => {
271+
await resetFileSystem();
272+
});
273+
274+
const cachedFunction = useFileSystemCache('foo', 5000, async () => {
275+
return 'foo';
276+
});
277+
278+
const cachedFilePath = join(CACHE_PATH, 'foo.json');
279+
280+
it('writes cached value to the file system', async () => {
281+
const spy = jest.spyOn(fs, 'writeFile');
282+
expect(await cachedFunction()).toBe('foo');
283+
284+
expect(spy).toHaveBeenCalledTimes(1);
285+
286+
const cacheValue = await fs.readFile(cachedFilePath, 'utf8');
287+
const cacheJson = JSON.parse(cacheValue);
288+
289+
expect(cacheJson).toStrictEqual({
290+
timestamp: expect.any(Number),
291+
value: 'foo',
292+
});
293+
});
294+
295+
it('reads cached value from the file system', async () => {
296+
const readSpy = jest.spyOn(fs, 'readFile');
297+
const writeSpy = jest.spyOn(fs, 'writeFile');
298+
299+
expect(await cachedFunction()).toBe('foo');
300+
expect(await cachedFunction()).toBe('foo');
301+
302+
expect(readSpy).toHaveBeenCalledTimes(2);
303+
expect(writeSpy).toHaveBeenCalledTimes(1);
304+
305+
const cacheValue = await fs.readFile(cachedFilePath, 'utf8');
306+
const cacheJson = JSON.parse(cacheValue);
307+
308+
expect(cacheJson).toStrictEqual({
309+
timestamp: expect.any(Number),
310+
value: 'foo',
311+
});
312+
});
313+
314+
it('discards cached value if it is expired', async () => {
315+
await fs.mkdir(dirname(cachedFilePath), { recursive: true });
316+
await fs.writeFile(
317+
cachedFilePath,
318+
JSON.stringify({ timestamp: Date.now() - 6000, value: 'bar' }),
319+
);
320+
321+
const readSpy = jest.spyOn(fs, 'readFile');
322+
const writeSpy = jest.spyOn(fs, 'writeFile');
323+
324+
expect(await cachedFunction()).toBe('foo');
325+
326+
expect(readSpy).toHaveBeenCalledTimes(1);
327+
expect(writeSpy).toHaveBeenCalledTimes(1);
328+
329+
const cacheValue = await fs.readFile(cachedFilePath, 'utf8');
330+
const cacheJson = JSON.parse(cacheValue);
331+
332+
expect(cacheJson).toStrictEqual({
333+
timestamp: expect.any(Number),
334+
value: 'foo',
335+
});
336+
});
337+
338+
it('skips persisting undefined', async () => {
339+
const fn = useFileSystemCache('foo', 5000, async () => {
340+
return undefined;
341+
});
342+
343+
const spy = jest.spyOn(fs, 'writeFile');
344+
expect(await fn()).toBeUndefined();
345+
346+
expect(spy).toHaveBeenCalledTimes(0);
347+
});
348+
349+
it('skips persisting null', async () => {
350+
const fn = useFileSystemCache('foo', 5000, async () => {
351+
return null;
352+
});
353+
354+
const spy = jest.spyOn(fs, 'writeFile');
355+
expect(await fn()).toBeNull();
356+
357+
expect(spy).toHaveBeenCalledTimes(0);
358+
});
359+
});

packages/snaps-utils/src/fs.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,56 @@ export async function useTemporaryFile<Type = unknown>(
182182
}
183183
}
184184
}
185+
186+
/**
187+
* Use the file system to cache a return value with a given key and TTL.
188+
*
189+
* @param cacheKey - The key to use for the cache.
190+
* @param ttl - The time-to-live in milliseconds.
191+
* @param fn - The callback function to wrap.
192+
* @returns The result from the callback.
193+
*/
194+
export function useFileSystemCache<Type = unknown>(
195+
cacheKey: string,
196+
ttl: number,
197+
fn: () => Promise<Type>,
198+
) {
199+
return async () => {
200+
const filePath = pathUtils.join(
201+
process.cwd(),
202+
'node_modules/.cache/snaps',
203+
`${cacheKey}.json`,
204+
);
205+
206+
try {
207+
const cacheContents = await fs.readFile(filePath, 'utf8');
208+
const json = JSON.parse(cacheContents);
209+
210+
if (json.timestamp + ttl > Date.now()) {
211+
return json.value;
212+
}
213+
} catch {
214+
// No-op
215+
}
216+
217+
const value = await fn();
218+
219+
// Null or undefined is not persisted.
220+
if (value === null || value === undefined) {
221+
return value;
222+
}
223+
224+
try {
225+
await fs.mkdir(pathUtils.dirname(filePath), { recursive: true });
226+
227+
const json = { timestamp: Date.now(), value };
228+
await fs.writeFile(filePath, JSON.stringify(json), {
229+
encoding: 'utf8',
230+
});
231+
} catch {
232+
// No-op
233+
}
234+
235+
return value;
236+
};
237+
}

packages/snaps-utils/src/manifest/manifest.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { promises as fs } from 'fs';
2+
import fetchMock from 'jest-fetch-mock';
23
import { join } from 'path';
34

45
import {
@@ -34,6 +35,17 @@ const BASE_PATH = '/snap';
3435
const MANIFEST_PATH = join(BASE_PATH, NpmSnapFileNames.Manifest);
3536
const PACKAGE_JSON_PATH = join(BASE_PATH, NpmSnapFileNames.PackageJson);
3637

38+
const MOCK_GITHUB_RESPONSE = JSON.stringify({
39+
// eslint-disable-next-line @typescript-eslint/naming-convention
40+
target_commitish: '5fceb7ed2ef18a3984786db1161a76ca5c8e15b9',
41+
});
42+
43+
const MOCK_PACKAGE_JSON = JSON.stringify({
44+
dependencies: {
45+
'@metamask/snaps-sdk': getPlatformVersion(),
46+
},
47+
});
48+
3749
/**
3850
* Get the default manifest for the current platform version.
3951
*
@@ -73,9 +85,16 @@ async function resetFileSystem() {
7385

7486
describe('checkManifest', () => {
7587
beforeEach(async () => {
88+
fetchMock.enableMocks();
89+
fetchMock.mockResponses(MOCK_GITHUB_RESPONSE, MOCK_PACKAGE_JSON);
90+
7691
await resetFileSystem();
7792
});
7893

94+
afterAll(() => {
95+
fetchMock.disableMocks();
96+
});
97+
7998
it('returns the status and warnings after processing', async () => {
8099
const { updated, reports } = await checkManifest(BASE_PATH);
81100
expect(reports).toHaveLength(0);

packages/snaps-utils/src/manifest/validators/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './manifest-localization';
1010
export * from './package-json-recommended-fields';
1111
export * from './package-name-match';
1212
export * from './platform-version';
13+
export * from './production-platform-version';
1314
export * from './repository-match';
1415
export * from './version-match';
1516
export * from './icon-declared';

0 commit comments

Comments
 (0)