146 lines
4.9 KiB
JavaScript
146 lines
4.9 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import { constants, createReadStream, createWriteStream } from 'node:fs';
|
|
import { copyFile, lstat, mkdir } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { Readable, Transform } from 'node:stream';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024;
|
|
const MAX_REDIRECTS = 5;
|
|
|
|
function usesLatestAlias(value) {
|
|
let decoded = value;
|
|
try {
|
|
decoded = decodeURIComponent(value);
|
|
} catch {
|
|
// The URL parser below reports malformed encodings where relevant.
|
|
}
|
|
return /(?:^|[^a-z0-9])latest(?:[^a-z0-9]|$)/i.test(decoded);
|
|
}
|
|
|
|
function parseArtifactSource(source, lockDirectory) {
|
|
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(source)) {
|
|
const url = new URL(source);
|
|
if (url.protocol === 'file:')
|
|
return { type: 'local', path: fileURLToPath(url) };
|
|
if (url.protocol === 'https:') {
|
|
return { type: 'https', url: validateHttpsArtifactUrl(url) };
|
|
}
|
|
throw new Error(`Artifact URL protocol is not allowed: ${url.protocol}`);
|
|
}
|
|
return { type: 'local', path: path.resolve(lockDirectory, source) };
|
|
}
|
|
|
|
function validateHttpsArtifactUrl(value, label = 'Artifact HTTPS URL') {
|
|
const url = value instanceof URL ? value : new URL(value);
|
|
if (url.protocol !== 'https:')
|
|
throw new Error(`${label} must use HTTPS: ${url.href}`);
|
|
if (url.username || url.password)
|
|
throw new Error(`${label} must not contain credentials.`);
|
|
if (usesLatestAlias(url.href))
|
|
throw new Error(`${label} must not use a latest alias.`);
|
|
return url;
|
|
}
|
|
|
|
async function fetchArtifact(url) {
|
|
let current = validateHttpsArtifactUrl(url);
|
|
|
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
|
const response = await fetch(current, {
|
|
redirect: 'manual',
|
|
headers: { Accept: 'application/zip, application/octet-stream' },
|
|
});
|
|
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
if (redirects === MAX_REDIRECTS)
|
|
throw new Error(
|
|
`Artifact download exceeded ${MAX_REDIRECTS} redirects.`
|
|
);
|
|
const location = response.headers.get('location');
|
|
if (!location)
|
|
throw new Error('Artifact redirect did not include a Location header.');
|
|
current = validateHttpsArtifactUrl(
|
|
new URL(location, current),
|
|
'Artifact redirect URL'
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (response.url) {
|
|
const finalUrl = validateHttpsArtifactUrl(
|
|
new URL(response.url),
|
|
'Artifact final response URL'
|
|
);
|
|
if (finalUrl.href !== current.href) {
|
|
throw new Error(
|
|
`Artifact fetch unexpectedly changed URL from ${current.href} to ${finalUrl.href}.`
|
|
);
|
|
}
|
|
}
|
|
return { response, finalUrl: current };
|
|
}
|
|
|
|
throw new Error(`Artifact download exceeded ${MAX_REDIRECTS} redirects.`);
|
|
}
|
|
|
|
export async function sha256File(file) {
|
|
const hash = createHash('sha256');
|
|
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
export async function acquireArtifact(
|
|
source,
|
|
expectedSha256,
|
|
lockDirectory,
|
|
downloadDirectory
|
|
) {
|
|
const parsed = parseArtifactSource(source, lockDirectory);
|
|
await mkdir(downloadDirectory, { recursive: true });
|
|
const file = path.join(downloadDirectory, `${expectedSha256}.zip`);
|
|
let downloaded = false;
|
|
if (parsed.type === 'local') {
|
|
const details = await lstat(parsed.path);
|
|
if (!details.isFile() || details.isSymbolicLink())
|
|
throw new Error(`Local artifact is not a regular file: ${parsed.path}`);
|
|
// Work from a private snapshot. The checksum below therefore covers the
|
|
// exact bytes later inspected and extracted, even if the source is replaced.
|
|
await copyFile(parsed.path, file, constants.COPYFILE_EXCL);
|
|
} else {
|
|
const { response, finalUrl } = await fetchArtifact(parsed.url);
|
|
if (!response.ok || !response.body)
|
|
throw new Error(
|
|
`Artifact download failed with HTTP ${response.status}: ${finalUrl.href}`
|
|
);
|
|
const declaredLength = Number(response.headers.get('content-length'));
|
|
if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_SIZE)
|
|
throw new Error('Artifact download exceeds the size limit.');
|
|
let bytes = 0;
|
|
const limiter = new Transform({
|
|
transform(chunk, _encoding, callback) {
|
|
bytes += chunk.length;
|
|
callback(
|
|
bytes > MAX_DOWNLOAD_SIZE
|
|
? new Error('Artifact download exceeds the size limit.')
|
|
: null,
|
|
chunk
|
|
);
|
|
},
|
|
});
|
|
await pipeline(
|
|
Readable.fromWeb(response.body),
|
|
limiter,
|
|
createWriteStream(file, { flags: 'wx', mode: 0o600 })
|
|
);
|
|
downloaded = true;
|
|
}
|
|
|
|
const actualSha256 = await sha256File(file);
|
|
if (actualSha256 !== expectedSha256) {
|
|
throw new Error(
|
|
`SHA-256 mismatch for ${source}: expected ${expectedSha256}, received ${actualSha256}.`
|
|
);
|
|
}
|
|
return { file, downloaded, sha256: actualSha256 };
|
|
}
|