blob: 6fe513e9fee43e2055a3a477d74e72018488c971 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createHash, randomUUID } from 'node:crypto';
import { constants } from 'node:fs';
import { copyFile, mkdir, open, readdir, realpath, rm, stat } from 'node:fs/promises';
import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
const MAX_FILES = 256;
const MAX_FILE_BYTES = 8 * 1024 * 1024;
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
const BUNDLE_IMPORTS_DIRECTORY = 'bundle-imports-v1';
interface BundleFile {
readonly path: string;
readonly sha256: string;
readonly content: string;
}
interface ExtensionBundleDocument {
readonly schemaVersion: 1;
readonly digest: string;
readonly files: readonly BundleFile[];
}
export class ExtensionBundleError extends Error {
readonly name = 'ExtensionBundleError';
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
export async function exportExtensionBundle(sourceRoot: string, targetPath: string): Promise<void> {
if (!isAbsolute(targetPath)) throw invalid('Extension bundle targetPath must be absolute');
const files = await readDirectory(sourceRoot);
const document: ExtensionBundleDocument = Object.freeze({
schemaVersion: 1,
digest: extensionPackageContentDigest(files),
files: Object.freeze(
files.map((file) =>
Object.freeze({
path: file.path,
sha256: createHash('sha256').update(file.content).digest('hex'),
content: file.content.toString('base64'),
}),
),
),
});
const encoded = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8');
if (encoded.byteLength > MAX_BUNDLE_BYTES * 2)
throw invalid('Encoded Extension bundle is too large');
await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 });
const temporary = `${targetPath}.${randomUUID()}.tmp`;
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(temporary, 'wx', 0o600);
await handle.writeFile(encoded);
await handle.sync();
await handle.close();
handle = undefined;
await copyFile(temporary, targetPath, constants.COPYFILE_EXCL);
} catch (error) {
throw invalid('Unable to export Extension bundle', error);
} finally {
await handle?.close().catch(() => undefined);
await rm(temporary, { force: true }).catch(() => undefined);
}
}
export async function materializeExtensionPackage(
sourcePath: string,
controlDirectory: string,
): Promise<{ readonly root: string; readonly dispose: () => Promise<void> }> {
if (!isAbsolute(sourcePath)) throw invalid('Extension package sourcePath must be absolute');
const canonical = await realpath(resolve(sourcePath)).catch((error) => {
throw invalid('Extension package source is unavailable', error);
});
const metadata = await stat(canonical);
if (metadata.isDirectory()) return { root: canonical, dispose: async () => undefined };
if (!metadata.isFile())
throw invalid('Extension package source must be a directory or bundle file');
if (metadata.size > MAX_BUNDLE_BYTES * 2)
throw invalid('Extension bundle exceeds its size limit');
const handle = await open(canonical, constants.O_RDONLY | constants.O_NOFOLLOW);
let document: ExtensionBundleDocument;
try {
document = decodeBundle(JSON.parse((await handle.readFile()).toString('utf8')));
} catch (error) {
if (error instanceof ExtensionBundleError) throw error;
throw invalid('Extension bundle is invalid', error);
} finally {
await handle.close();
}
const imports = join(controlDirectory, BUNDLE_IMPORTS_DIRECTORY);
const root = join(imports, randomUUID());
await mkdir(root, { recursive: true, mode: 0o700 });
try {
for (const file of document.files) {
const target = join(root, ...file.path.split('/'));
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
const output = await open(target, 'wx', 0o600);
try {
await output.writeFile(Buffer.from(file.content, 'base64'));
} finally {
await output.close();
}
}
return { root, dispose: () => rm(root, { recursive: true, force: true }) };
} catch (error) {
await rm(root, { recursive: true, force: true }).catch(() => undefined);
throw invalid('Unable to materialize Extension bundle', error);
}
}
/** Removes bundle materializations left behind when the previous owner died. */
export async function recoverExtensionBundleImports(controlDirectory: string): Promise<void> {
try {
await rm(join(controlDirectory, BUNDLE_IMPORTS_DIRECTORY), {
recursive: true,
force: true,
});
} catch (error) {
throw invalid('Unable to recover Extension bundle imports', error);
}
}
async function readDirectory(
rootValue: string,
): Promise<readonly { path: string; content: Buffer }[]> {
const root = await realpath(rootValue);
if (!(await stat(root)).isDirectory())
throw invalid('Extension bundle source is not a directory');
const paths: string[] = [];
await collect(root, '', paths);
if (paths.length === 0 || paths.length > MAX_FILES)
throw invalid('Extension bundle file count is invalid');
let total = 0;
const files: { path: string; content: Buffer }[] = [];
for (const path of paths.sort()) {
const handle = await open(
join(root, ...path.split('/')),
constants.O_RDONLY | constants.O_NOFOLLOW,
);
try {
const metadata = await handle.stat();
if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES)
throw invalid(`Extension bundle file is invalid: ${path}`);
const content = await handle.readFile();
total += content.byteLength;
if (total > MAX_BUNDLE_BYTES) throw invalid('Extension bundle payload is too large');
files.push({ path, content });
} finally {
await handle.close();
}
}
return files;
}
async function collect(root: string, directory: string, paths: string[]): Promise<void> {
const entries = await readdir(directory ? join(root, ...directory.split('/')) : root, {
withFileTypes: true,
});
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (entry.name === '.git') continue;
const path = directory ? `${directory}/${entry.name}` : entry.name;
safePath(path);
if (entry.isSymbolicLink()) throw invalid(`Extension bundle may not contain symlinks: ${path}`);
if (entry.isDirectory()) await collect(root, path, paths);
else if (entry.isFile()) paths.push(path);
else throw invalid(`Extension bundle contains an unsupported entry: ${path}`);
if (paths.length > MAX_FILES) throw invalid('Extension bundle contains too many files');
}
}
function decodeBundle(value: unknown): ExtensionBundleDocument {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw invalid('Extension bundle must be an object');
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join() !== 'digest,files,schemaVersion' ||
record.schemaVersion !== 1 ||
!Array.isArray(record.files) ||
record.files.length === 0 ||
record.files.length > MAX_FILES
) {
throw invalid('Extension bundle fields are invalid');
}
let total = 0;
const paths = new Set<string>();
const files = record.files.map((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw invalid('Extension bundle file is invalid');
const file = value as Record<string, unknown>;
if (
Object.keys(file).sort().join() !== 'content,path,sha256' ||
typeof file.path !== 'string' ||
typeof file.content !== 'string' ||
typeof file.sha256 !== 'string'
)
throw invalid('Extension bundle file fields are invalid');
const path = safePath(file.path);
if (paths.has(path)) throw invalid(`Extension bundle repeats file: ${path}`);
paths.add(path);
const content = Buffer.from(file.content, 'base64');
total += content.byteLength;
if (
content.byteLength > MAX_FILE_BYTES ||
total > MAX_BUNDLE_BYTES ||
createHash('sha256').update(content).digest('hex') !== file.sha256
) {
throw invalid(`Extension bundle file integrity failed: ${path}`);
}
return { path, content };
});
if (typeof record.digest !== 'string' || extensionPackageContentDigest(files) !== record.digest)
throw invalid('Extension bundle digest is invalid');
return Object.freeze({
schemaVersion: 1,
digest: record.digest,
files: Object.freeze(
files.map((file) =>
Object.freeze({
path: file.path,
sha256: createHash('sha256').update(file.content).digest('hex'),
content: file.content.toString('base64'),
}),
),
),
});
}
export function extensionPackageContentDigest(
files: readonly { path: string; content: Buffer }[],
): string {
const hash = createHash('sha256');
for (const file of files) {
const path = Buffer.from(file.path, 'utf8');
const length = Buffer.allocUnsafe(8);
length.writeBigUInt64BE(BigInt(path.byteLength));
hash.update(length).update(path);
length.writeBigUInt64BE(BigInt(file.content.byteLength));
hash.update(length).update(file.content);
}
return `sha256-${hash.digest('hex')}`;
}
function safePath(value: string): string {
if (
!value ||
value.length > 512 ||
value.includes('\\') ||
value.startsWith('/') ||
posix.normalize(value) !== value ||
value.split('/').some((part) => !part || part === '.' || part === '..')
) {
throw invalid('Extension bundle path is invalid');
}
return value;
}
function invalid(message: string, cause?: unknown): ExtensionBundleError {
return new ExtensionBundleError(message, { cause });
}