feat: publish av-tools 0.2.0
This commit is contained in:
@@ -1,138 +1,203 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const expectedVersion = '0.12.10';
|
||||
const outputRoot = join(
|
||||
repositoryRoot,
|
||||
'public',
|
||||
'vendor',
|
||||
'ffmpeg',
|
||||
expectedVersion
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
|
||||
const packages = [
|
||||
{
|
||||
packageName: '@ffmpeg/core',
|
||||
mode: 'st',
|
||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'],
|
||||
},
|
||||
{
|
||||
packageName: '@ffmpeg/core-mt',
|
||||
mode: 'mt',
|
||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedHashes = new Map([
|
||||
[
|
||||
'@ffmpeg/core:ffmpeg-core.js',
|
||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core:ffmpeg-core.wasm',
|
||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.js',
|
||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.wasm',
|
||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.worker.js',
|
||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
||||
],
|
||||
const lockPath = path.join(
|
||||
repositoryRoot,
|
||||
'scripts',
|
||||
'reviewed-ffmpeg-core-lock.json'
|
||||
);
|
||||
const maximumArchiveBytes = 64 * 1024 * 1024;
|
||||
const maximumAssetBytes = 64 * 1024 * 1024;
|
||||
const requiredAssetPaths = new Set([
|
||||
'st/ffmpeg-core.js',
|
||||
'st/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.js',
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
]);
|
||||
|
||||
async function loadPackageVersion(packageName) {
|
||||
const packagePath = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'package.json'
|
||||
);
|
||||
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
|
||||
if (parsed.version !== expectedVersion) {
|
||||
throw new Error(
|
||||
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
|
||||
);
|
||||
}
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
async function copyAsset(packageName, mode, fileName) {
|
||||
const source = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'dist',
|
||||
'esm',
|
||||
fileName
|
||||
);
|
||||
const destination = join(outputRoot, mode, fileName);
|
||||
const sourceBytes = await readFile(source).catch((error) => {
|
||||
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
|
||||
cause: error,
|
||||
});
|
||||
function assertSafeRelativePath(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0') ||
|
||||
value.startsWith('/') ||
|
||||
path.posix.normalize(value) !== value ||
|
||||
value.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
) {
|
||||
throw new Error(`Unsafe reviewed-core path: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function requireRealDirectory(directory, create = false) {
|
||||
let details = await lstat(directory).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
|
||||
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
|
||||
if (!expectedDigest || sourceDigest !== expectedDigest) {
|
||||
throw new Error(
|
||||
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
|
||||
);
|
||||
if (!details && create) {
|
||||
await mkdir(directory);
|
||||
details = await lstat(directory);
|
||||
}
|
||||
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
dereference: true,
|
||||
errorOnExist: false,
|
||||
force: true,
|
||||
preserveTimestamps: false,
|
||||
});
|
||||
|
||||
const destinationBytes = await readFile(destination);
|
||||
if (!sourceBytes.equals(destinationBytes)) {
|
||||
throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: relative(outputRoot, destination).replaceAll('\\', '/'),
|
||||
bytes: destinationBytes.byteLength,
|
||||
sha256: sourceDigest,
|
||||
};
|
||||
}
|
||||
|
||||
await rm(outputRoot, { force: true, recursive: true });
|
||||
|
||||
const assets = [];
|
||||
for (const entry of packages) {
|
||||
await loadPackageVersion(entry.packageName);
|
||||
for (const fileName of entry.files) {
|
||||
assets.push(await copyAsset(entry.packageName, entry.mode, fileName));
|
||||
if (!details || details.isSymbolicLink() || !details.isDirectory()) {
|
||||
throw new Error(`Required directory is unsafe: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
|
||||
const versionManifest = {
|
||||
schemaVersion: 1,
|
||||
coreVersion: expectedVersion,
|
||||
packages: {
|
||||
'@ffmpeg/core': expectedVersion,
|
||||
'@ffmpeg/core-mt': expectedVersion,
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
||||
const lockedAssetPaths = new Set(
|
||||
Array.isArray(lock.assets) ? lock.assets.map((asset) => asset.path) : []
|
||||
);
|
||||
if (
|
||||
lock.schemaVersion !== 1 ||
|
||||
typeof lock.buildId !== 'string' ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(lock.buildId) ||
|
||||
lock.coreVersion !== '0.12.10' ||
|
||||
lock.profile !== 'reviewed-stack5m' ||
|
||||
lock.sourceArchive?.path !==
|
||||
'release/ffmpeg-core-0.12.10-reviewed-stack5m.1-corresponding-source.tar.xz' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceArchive?.sha256 ?? '') ||
|
||||
lock.sourceLock?.path !== 'scripts/ffmpeg-source-lock.json' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.sourceLock?.sha256 ?? '') ||
|
||||
lock.buildDriver?.path !== 'scripts/build-reviewed-ffmpeg-core.sh' ||
|
||||
!/^[a-f0-9]{64}$/.test(lock.buildDriver?.sha256 ?? '') ||
|
||||
lock.patch?.upstreamCommit !== 'b409e36475bc21f0451b5b1e1d126fa82871439a' ||
|
||||
lock.patch?.stackSizeBytes !== 5 * 1024 * 1024 ||
|
||||
typeof lock.opusRegression?.singleThread !== 'boolean' ||
|
||||
typeof lock.opusRegression?.multiThread !== 'boolean' ||
|
||||
!Array.isArray(lock.assets) ||
|
||||
lock.assets.length !== 5 ||
|
||||
lock.assets.some(
|
||||
(asset) =>
|
||||
!requiredAssetPaths.has(asset.path) ||
|
||||
!Number.isSafeInteger(asset.bytes) ||
|
||||
asset.bytes < 1 ||
|
||||
asset.bytes > maximumAssetBytes ||
|
||||
!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')
|
||||
) ||
|
||||
lockedAssetPaths.size !== requiredAssetPaths.size ||
|
||||
[...requiredAssetPaths].some((assetPath) => !lockedAssetPaths.has(assetPath))
|
||||
) {
|
||||
throw new Error(`Invalid reviewed FFmpeg core lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
const artifactRelativePath = assertSafeRelativePath(lock.artifact?.path);
|
||||
if (artifactRelativePath !== `vendor/ffmpeg-core-${lock.buildId}.zip`) {
|
||||
throw new Error('Reviewed-core artifact path and build identity disagree.');
|
||||
}
|
||||
const artifact = await readFile(
|
||||
path.join(repositoryRoot, ...artifactRelativePath.split('/'))
|
||||
);
|
||||
if (
|
||||
artifact.byteLength < 1 ||
|
||||
artifact.byteLength > maximumArchiveBytes ||
|
||||
artifact.byteLength !== lock.artifact.bytes ||
|
||||
sha256(artifact) !== lock.artifact.sha256
|
||||
) {
|
||||
throw new Error('Reviewed FFmpeg core archive failed locked verification.');
|
||||
}
|
||||
|
||||
const lockedArchiveEntries = new Map(
|
||||
lock.assets.map((asset) => [asset.path, asset])
|
||||
);
|
||||
const archiveEntries = new Set();
|
||||
const uncompressed = unzipSync(artifact, {
|
||||
filter: (entry) => {
|
||||
const relative = assertSafeRelativePath(entry.name);
|
||||
const locked = lockedArchiveEntries.get(relative);
|
||||
if (
|
||||
!locked ||
|
||||
archiveEntries.has(relative) ||
|
||||
entry.originalSize !== locked.bytes ||
|
||||
entry.originalSize < 1 ||
|
||||
entry.originalSize > maximumAssetBytes ||
|
||||
entry.size < 1 ||
|
||||
entry.size > maximumArchiveBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed FFmpeg core archive has an unexpected entry: ${entry.name}`
|
||||
);
|
||||
}
|
||||
archiveEntries.add(relative);
|
||||
return true;
|
||||
},
|
||||
assets,
|
||||
};
|
||||
});
|
||||
if (archiveEntries.size !== lockedArchiveEntries.size) {
|
||||
throw new Error('Reviewed FFmpeg core archive is missing an expected entry.');
|
||||
}
|
||||
|
||||
const vendorRoot = path.join(repositoryRoot, 'public', 'vendor', 'ffmpeg');
|
||||
const outputRoot = path.join(vendorRoot, lock.buildId);
|
||||
await requireRealDirectory(repositoryRoot);
|
||||
await requireRealDirectory(path.join(repositoryRoot, 'public'));
|
||||
await requireRealDirectory(path.join(repositoryRoot, 'public', 'vendor'), true);
|
||||
const existingVendorRoot = await lstat(vendorRoot).catch((error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (
|
||||
existingVendorRoot &&
|
||||
(existingVendorRoot.isSymbolicLink() || !existingVendorRoot.isDirectory())
|
||||
) {
|
||||
throw new Error(`Generated FFmpeg vendor root is unsafe: ${vendorRoot}`);
|
||||
}
|
||||
await rm(vendorRoot, { force: true, recursive: true });
|
||||
|
||||
for (const asset of lock.assets) {
|
||||
const relativeAssetPath = assertSafeRelativePath(asset.path);
|
||||
const bytes = uncompressed[relativeAssetPath];
|
||||
if (
|
||||
!bytes ||
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > maximumAssetBytes ||
|
||||
bytes.byteLength !== asset.bytes ||
|
||||
sha256(bytes) !== asset.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Reviewed FFmpeg core entry failed locked verification: ${asset.path}`
|
||||
);
|
||||
}
|
||||
const destination = path.join(outputRoot, ...relativeAssetPath.split('/'));
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await writeFile(destination, bytes, { flag: 'wx', mode: 0o644 });
|
||||
}
|
||||
|
||||
const versionManifest = {
|
||||
schemaVersion: 2,
|
||||
buildId: lock.buildId,
|
||||
profile: lock.profile,
|
||||
coreVersion: lock.coreVersion,
|
||||
ffmpegVersion: lock.ffmpegVersion,
|
||||
sourceArchive: lock.sourceArchive,
|
||||
sourceLock: lock.sourceLock,
|
||||
buildDriver: lock.buildDriver,
|
||||
toolchain: lock.toolchain,
|
||||
patch: lock.patch,
|
||||
opusRegression: lock.opusRegression,
|
||||
assets: lock.assets,
|
||||
};
|
||||
await writeFile(
|
||||
join(outputRoot, 'version.json'),
|
||||
`${JSON.stringify(versionManifest, null, 2)}\n`
|
||||
path.join(outputRoot, 'version.json'),
|
||||
`${JSON.stringify(versionManifest, null, 2)}\n`,
|
||||
{ flag: 'wx', mode: 0o644 }
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
|
||||
`Verified and unpacked ${lock.assets.length} reviewed FFmpeg assets to ${path.relative(repositoryRoot, outputRoot)}`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user