Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/metro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
"flow-enums-runtime": "^0.0.6",
"graceful-fs": "^4.2.4",
"hermes-parser": "0.37.0",
"image-size": "^1.0.2",
"invariant": "^2.2.4",
"jest-worker": "^29.7.0",
"jsc-safe-url": "^0.2.2",
Expand All @@ -53,6 +52,7 @@
"metro-transform-worker": "0.87.0",
"mime-types": "^3.0.1",
"nullthrows": "^1.1.1",
"probe-image-size": "7.3.0",
"serialize-error": "^2.1.0",
"source-map": "^0.5.6",
"throat": "^5.0.0",
Expand Down
34 changes: 16 additions & 18 deletions packages/metro/src/Assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@

import type {AssetPath} from './node-haste/lib/AssetPaths';

import {getAssetSize as parseAssetSize} from './lib/getAssetSize';
import {normalizePathSeparatorsToPosix} from './lib/pathUtils';
import * as AssetPaths from './node-haste/lib/AssetPaths';
// $FlowFixMe[untyped-import] image-size
import getImageSize from 'image-size';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
Expand Down Expand Up @@ -51,8 +50,8 @@ export type AssetDataFiltered = {
...
};

// Test extension against all types supported by image-size module.
// If it's not one of these, we won't treat it as an image.
// If an extension isn't explicitly supported here, we won't treat it as an
// image.
export function isAssetTypeAnImage(type: string): boolean {
return (
[
Expand All @@ -78,11 +77,7 @@ export function getAssetSize(
if (!isAssetTypeAnImage(type)) {
return null;
}
if (content.length === 0) {
throw new Error(`Image asset \`${filePath}\` cannot be an empty file.`);
}
const {width, height} = getImageSize(content);
return {width, height};
return parseAssetSize(type, content, filePath);
}

export type AssetData = AssetDataWithoutFiles & {
Expand Down Expand Up @@ -180,7 +175,7 @@ async function getAbsoluteAssetRecord(
async function getAbsoluteAssetInfo(
assetPath: string,
platform: ?string = null,
): Promise<AssetInfo> {
): Promise<{assetInfo: AssetInfo, firstFileContent: Buffer}> {
const nameData = AssetPaths.parse(
assetPath,
new Set(platform != null ? [platform] : []),
Expand All @@ -198,7 +193,10 @@ async function getAbsoluteAssetInfo(
hasher.update(data);
}

return {files, hash: hasher.digest('hex'), name, scales, type};
return {
assetInfo: {files, hash: hasher.digest('hex'), name, scales, type},
firstFileContent: fileData[0],
};
}

export async function getAssetData(
Expand All @@ -218,13 +216,13 @@ export async function getAssetData(
// On Windows, change backslashes to slashes to get proper URL path from file path.
assetUrlPath = normalizePathSeparatorsToPosix(assetUrlPath);

const isImage = isAssetTypeAnImage(path.extname(assetPath).slice(1));
const assetInfo = await getAbsoluteAssetInfo(assetPath, platform ?? null);

const isImageInput = assetInfo.files[0].includes('.zip/')
? fs.readFileSync(assetInfo.files[0])
: assetInfo.files[0];
const dimensions = isImage ? getImageSize(isImageInput) : null;
const {assetInfo, firstFileContent} = await getAbsoluteAssetInfo(
assetPath,
platform ?? null,
);
const dimensions = isAssetTypeAnImage(assetInfo.type)
? getAssetSize(assetInfo.type, firstFileContent, assetInfo.files[0])
: null;
const scale = assetInfo.scales[0];

const assetData = {
Expand Down
25 changes: 19 additions & 6 deletions packages/metro/src/__tests__/Assets-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@
'use strict';

jest.mock('node:fs', () => new (require('metro-memory-fs'))());
jest.mock('image-size');
jest.mock('../lib/getAssetSize', () => ({
getAssetSize: jest.fn(() => ({
width: mockImageWidth,
height: mockImageHeight,
})),
}));

jest.useRealTimers();

const {getAsset, getAssetData} = require('../Assets');
const getImageSize = require('../lib/getAssetSize').getAssetSize;
const crypto = require('node:crypto');
const path = require('node:path');

Expand All @@ -24,11 +30,6 @@ const fs = jest.requireMock('node:fs');
const mockImageWidth = 300;
const mockImageHeight = 200;

require('image-size').mockReturnValue({
width: mockImageWidth,
height: mockImageHeight,
});

describe('getAsset', () => {
beforeEach(() => {
fs.reset();
Expand Down Expand Up @@ -284,6 +285,18 @@ describe('getAssetData', () => {
});
});

test('parses dimensions from the first asset file buffer', async () => {
writeImages({'b@1x.png': 'b1 image', 'b@2x.png': 'b2 image'});

await getAssetData('/root/imgs/b.png', 'imgs/b.png', [], null, '/assets');

expect(getImageSize).toHaveBeenCalledWith(
'png',
Buffer.from('b1 image'),
'/root/imgs/b@1x.png',
);
});

test('should get assetData for non-png images', async () => {
writeImages({
'b@1x.jpg': 'b1 image',
Expand Down
214 changes: 214 additions & 0 deletions packages/metro/src/lib/__tests__/getAssetSize-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/

'use strict';

const {getAssetSize} = require('../getAssetSize');

const WIDTH = 300;
const HEIGHT = 200;
const KTX1_IDENTIFIER = [
0xab, 0x4b, 0x54, 0x58, 0x20, 0x31, 0x31, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
];
const KTX2_IDENTIFIER = [
0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
];

describe('getAssetSize', () => {
test.each([
['bmp', createBmp()],
['gif', createGif()],
['jpg', createJpeg()],
['jpeg', createJpeg()],
['png', createPng()],
['psd', createPsd()],
['svg', createSvg()],
['tiff', createTiff()],
['webp', createWebp()],
['ktx', createKtx1('little')],
['ktx', createKtx1('big')],
['ktx', createKtx2()],
])('parses a valid %s image', (type, content) => {
expect(getAssetSize(type, content, `/root/image.${type}`)).toEqual({
width: WIDTH,
height: HEIGHT,
});
});

test('rejects an empty image', () => {
expect(() =>
getAssetSize('png', Buffer.alloc(0), '/root/empty.png'),
).toThrow('Invalid png image asset: /root/empty.png');
});

test('rejects content that does not match the asset type', () => {
expect(() =>
getAssetSize('png', createJpeg(), '/root/disguised.png'),
).toThrow('Invalid png image asset: /root/disguised.png');
});

test('rejects a truncated image header', () => {
expect(() =>
getAssetSize(
'png',
Buffer.from([0x89, 0x50, 0x4e, 0x47]),
'/root/truncated.png',
),
).toThrow('Invalid png image asset: /root/truncated.png');
});

test.each([
['KTX1', createKtx1('little').subarray(0, 44)],
['KTX2', createKtx2().subarray(0, 28)],
])('rejects a truncated %s header', (_, content) => {
expect(() => getAssetSize('ktx', content, '/root/truncated.ktx')).toThrow(
'Invalid ktx image asset: /root/truncated.ktx',
);
});

test('rejects non-positive dimensions', () => {
const content = createPng();
content.writeUInt32BE(0, 16);

expect(() => getAssetSize('png', content, '/root/zero-width.png')).toThrow(
'Invalid png image asset: /root/zero-width.png',
);
});

test.each([
['JXL', createZeroLengthJxlBox()],
['HEIF', createZeroLengthHeifBox()],
['ICNS', createZeroLengthIcnsEntry()],
])(
'rejects a malformed %s payload without auto-detecting it',
(_, content) => {
expect(() => getAssetSize('png', content, '/root/malicious.png')).toThrow(
'Invalid png image asset: /root/malicious.png',
);
},
);
});

function createBmp() {
const content = Buffer.alloc(26);
content.write('BM', 0, 'ascii');
content.writeUInt32LE(40, 14);
content.writeInt32LE(WIDTH, 18);
content.writeInt32LE(HEIGHT, 22);
return content;
}

function createGif() {
const content = Buffer.alloc(10);
content.write('GIF89a', 0, 'ascii');
content.writeUInt16LE(WIDTH, 6);
content.writeUInt16LE(HEIGHT, 8);
return content;
}

function createJpeg() {
const content = Buffer.alloc(21);
content.set([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08]);
content.writeUInt16BE(HEIGHT, 7);
content.writeUInt16BE(WIDTH, 9);
return content;
}

function createPng() {
const content = Buffer.alloc(24);
content.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
content.write('IHDR', 12, 'ascii');
content.writeUInt32BE(WIDTH, 16);
content.writeUInt32BE(HEIGHT, 20);
return content;
}

function createPsd() {
const content = Buffer.alloc(22);
content.set([0x38, 0x42, 0x50, 0x53, 0x00, 0x01]);
content.writeUInt32BE(HEIGHT, 14);
content.writeUInt32BE(WIDTH, 18);
return content;
}

function createSvg() {
return Buffer.from(
`<svg width="${WIDTH}" height="${HEIGHT}" xmlns="http://www.w3.org/2000/svg"/>`,
);
}

function createTiff() {
const content = Buffer.alloc(34);
content.set([0x49, 0x49, 0x2a, 0x00]);
content.writeUInt32LE(8, 4);
content.writeUInt16LE(2, 8);
writeTiffEntry(content, 10, 256, WIDTH);
writeTiffEntry(content, 22, 257, HEIGHT);
return content;
}

function writeTiffEntry(content, offset, tag, value) {
content.writeUInt16LE(tag, offset);
content.writeUInt16LE(4, offset + 2);
content.writeUInt32LE(1, offset + 4);
content.writeUInt32LE(value, offset + 8);
}

function createWebp() {
const content = Buffer.alloc(30);
content.write('RIFF', 0, 'ascii');
content.writeUInt32LE(content.length - 8, 4);
content.write('WEBP', 8, 'ascii');
content.write('VP8X', 12, 'ascii');
content.writeUInt32LE(10, 16);
content.writeUIntLE(WIDTH - 1, 24, 3);
content.writeUIntLE(HEIGHT - 1, 27, 3);
return content;
}

function createKtx1(endianness) {
const content = Buffer.alloc(64);
content.set(KTX1_IDENTIFIER);
if (endianness === 'little') {
content.set([0x01, 0x02, 0x03, 0x04], 12);
content.writeUInt32LE(WIDTH, 36);
content.writeUInt32LE(HEIGHT, 40);
} else {
content.set([0x04, 0x03, 0x02, 0x01], 12);
content.writeUInt32BE(WIDTH, 36);
content.writeUInt32BE(HEIGHT, 40);
}
return content;
}

function createKtx2() {
const content = Buffer.alloc(80);
content.set(KTX2_IDENTIFIER);
content.writeUInt32LE(WIDTH, 20);
content.writeUInt32LE(HEIGHT, 24);
return content;
}

function createZeroLengthJxlBox() {
return Buffer.from([0x00, 0x00, 0x00, 0x00, 0x4a, 0x58, 0x4c, 0x20]);
}

function createZeroLengthHeifBox() {
return Buffer.from([
0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66,
]);
}

function createZeroLengthIcnsEntry() {
return Buffer.from([
0x69, 0x63, 0x6e, 0x73, 0x00, 0x00, 0x00, 0x10, 0x69, 0x63, 0x30, 0x37,
0x00, 0x00, 0x00, 0x00,
]);
}
Loading