Skip to content
Merged
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
7 changes: 6 additions & 1 deletion app/core/Snaps/location/npm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ jest.mock('react-native-blob-util', () => ({
fetch: jest.fn(() => ({
flush: jest.fn(),
data: '/document-dir/archive.tgz',
respInfo: {
status: 200,
headers: {
'content-length': 2000,
},
},
})),
})),
fs: {
dirs: { DocumentDir: '/document-dir/' },
unlink: jest.fn().mockResolvedValue(undefined),
isDir: jest.fn((path) => path.endsWith('archive') || path.endsWith('dist')),
ls: jest.fn((path) => {
Expand Down
171 changes: 84 additions & 87 deletions app/core/Snaps/location/npm.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
/* eslint-disable import/prefer-default-export */
///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps)
import { VirtualFile } from '@metamask/snaps-utils';
import { stringToBytes } from '@metamask/utils';
import { assert, getErrorMessage } from '@metamask/utils';
import { NativeModules } from 'react-native';
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util';
import {
BaseNpmLocation,
DetectSnapLocationOptions,
getNpmCanonicalBasePath,
TARBALL_SIZE_SAFETY_LIMIT,
} from '@metamask/snaps-controllers';

const { RNTar } = NativeModules;

const SNAPS_NPM_LOG_TAG = 'snaps/ NPM';

const decompressFile = async (
path: string,
targetPath: string,
): Promise<string> => {
try {
const decompressedDataLocation = await RNTar.unTar(path, targetPath);
if (decompressedDataLocation) {
return decompressedDataLocation;
}
throw new Error('Was unable to decompress tgz file');
} catch (error) {
throw new Error(`${SNAPS_NPM_LOG_TAG} decompressFile error: ${error}`);
}
};

const findAllPaths = async (path: string): Promise<string[]> => {
const isDir = await ReactNativeBlobUtil.fs.isDir(path);
if (!isDir) {
Expand All @@ -41,87 +26,99 @@ const findAllPaths = async (path: string): Promise<string[]> => {
};

const readAndParseAt = async (path: string) => {
try {
const contents = stringToBytes(
await ReactNativeBlobUtil.fs.readFile(path, 'utf8'),
);
return { path, contents };
} catch (error) {
throw new Error(`${SNAPS_NPM_LOG_TAG} readAndParseAt error: ${error}`);
}
const contents = await ReactNativeBlobUtil.fs.readFile(path, 'utf8');
return { path, contents };
};

const fetchAndStoreNPMPackage = async (
inputRequest: RequestInfo,
): Promise<string> => {
const targetDir = ReactNativeBlobUtil.fs.dirs.DocumentDir;
const filePath = `${targetDir}/archive.tgz`;
const urlToFetch: string =
typeof inputRequest === 'string' ? inputRequest : inputRequest.url;
export class NpmLocation extends BaseNpmLocation {
#blobFetch: ReactNativeBlobUtil['fetch'];

constructor(url: URL, opts: DetectSnapLocationOptions = {}) {
super(url, opts);

try {
const response: FetchBlobResponse = await ReactNativeBlobUtil.config({
const { fetch: blobFetch } = ReactNativeBlobUtil.config({
fileCache: true,
path: filePath,
}).fetch('GET', urlToFetch);
const dataPath = response.data;
const decompressedPath = await decompressFile(dataPath, targetDir);
// remove response file from cache
response.flush();
return decompressedPath;
} catch (error) {
throw new Error(
`${SNAPS_NPM_LOG_TAG} fetchAndStoreNPMPackage failed to fetch with error: ${error}`,
);
}
};
appendExt: 'tgz',
});

const cleanupFileSystem = async (path: string) => {
ReactNativeBlobUtil.fs.unlink(path).catch((error) => {
throw new Error(
`${SNAPS_NPM_LOG_TAG} cleanupFileSystem failed to clean files at path with error: ${error}`,
);
});
};
this.#blobFetch = blobFetch;
}

export class NpmLocation extends BaseNpmLocation {
async fetchNpmTarball(
tarballUrl: URL,
): Promise<Map<string, VirtualFile<unknown>>> {
// Fetches and unpacks the NPM package on the local filesystem using native code
const npmPackageDataLocation = await fetchAndStoreNPMPackage(
tarballUrl.toString(),
);

// Find all paths contained within the tarball
const paths = await findAllPaths(npmPackageDataLocation);

const files = await Promise.all(paths.map(readAndParseAt));

const canonicalBase = getNpmCanonicalBasePath(
this.meta.registry,
this.meta.packageName,
);

const map = new Map();

files.forEach(({ path, contents }) => {
// Remove most of the base path
const normalizedPath = path.replace(`${npmPackageDataLocation}/`, '');
map.set(
normalizedPath,
new VirtualFile({
value: contents,
path: normalizedPath,
data: { canonicalPath: new URL(path, canonicalBase).toString() },
}),
let response: FetchBlobResponse | null = null;
let untarPath: string | null = null;
try {
response = await this.#blobFetch('GET', tarballUrl.toString());

const responseInfo = response.respInfo;

assert(
responseInfo.status !== 404,
`"${this.meta.packageName}" was not found in the NPM registry`,
);
});

// Cleanup filesystem
await cleanupFileSystem(npmPackageDataLocation);
assert(
responseInfo.status === 200,
`Failed to fetch tarball for package "${this.meta.packageName}"`,
);

// We assume that NPM is a good actor and provides us with a valid `content-length` header.
const tarballSizeString = responseInfo.headers['content-length'];
assert(tarballSizeString, 'Snap tarball has invalid content-length');
const tarballSize = parseInt(tarballSizeString, 10);
assert(
tarballSize <= TARBALL_SIZE_SAFETY_LIMIT,
'Snap tarball exceeds size limit',
);

// Returns the path where the file is cached
const dataPath = response.data;

// Slice .tgz extension
const outPath = dataPath.slice(0, -4);
untarPath = (await RNTar.unTar(dataPath, outPath)) as string;

return map;
// Find all paths contained within the tarball
const paths = await findAllPaths(untarPath);

const files = await Promise.all(paths.map(readAndParseAt));

const canonicalBase = getNpmCanonicalBasePath(
this.meta.registry,
this.meta.packageName,
);

const map = new Map();

files.forEach(({ path, contents }) => {
// Remove most of the base path
const normalizedPath = path.replace(`${untarPath}/`, '');
map.set(
normalizedPath,
new VirtualFile({
value: contents,
path: normalizedPath,
data: { canonicalPath: new URL(path, canonicalBase).toString() },
}),
);
});

return map;
} catch (error) {
throw new Error(
`Failed to fetch and unpack NPM tarball for "${
this.meta.packageName
}": ${getErrorMessage(error)}`,
);
} finally {
response?.flush();

if (untarPath) {
ReactNativeBlobUtil.fs.unlink(untarPath).catch(console.error);
}
}
}
}
///: END:ONLY_INCLUDE_IF
Loading