'use strict'; const https = require('node:https'); const LATEST_RELEASE_URL = 'https://git.pejicorp.com/api/v1/repos/peji/Alta-Proxy-Tool/releases/latest'; const RELEASES_PAGE_URL = 'https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases'; const REQUEST_TIMEOUT_MS = 5000; const MAX_BODY_BYTES = 64 * 1024; const MAX_RELEASE_NAME_LENGTH = 200; // Strict SemVer 2.0.0 without loose forms such as omitted fields. const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; class UpdatePolicyError extends Error { constructor(code, message, options) { super(message, options); this.name = 'UpdatePolicyError'; this.code = code; } } function policyError(code, message, cause) { return new UpdatePolicyError(code, message, cause ? { cause } : undefined); } function parseSemver(version, errorCode = 'INVALID_VERSION') { if (typeof version !== 'string') { throw policyError(errorCode, 'Version must be a strict semantic version string'); } const match = SEMVER_PATTERN.exec(version); if (!match) { throw policyError(errorCode, 'Version must use strict SemVer 2.0.0 syntax'); } return { major: BigInt(match[1]), minor: BigInt(match[2]), patch: BigInt(match[3]), prerelease: match[4] === undefined ? null : match[4].split('.'), }; } function normalizeReleaseTag(tag) { const version = tag.startsWith('v') ? tag.slice(1) : tag; parseSemver(version, 'INVALID_RELEASE_VERSION'); return version; } function compareIdentifier(left, right) { const leftNumeric = /^\d+$/.test(left); const rightNumeric = /^\d+$/.test(right); if (leftNumeric && rightNumeric) { const leftNumber = BigInt(left); const rightNumber = BigInt(right); return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0; } if (leftNumeric !== rightNumeric) { return leftNumeric ? -1 : 1; } return left < right ? -1 : left > right ? 1 : 0; } function compareSemver(leftVersion, rightVersion) { const left = parseSemver(leftVersion); const right = parseSemver(rightVersion); for (const key of ['major', 'minor', 'patch']) { if (left[key] < right[key]) return -1; if (left[key] > right[key]) return 1; } if (left.prerelease === null && right.prerelease === null) return 0; if (left.prerelease === null) return 1; if (right.prerelease === null) return -1; const count = Math.max(left.prerelease.length, right.prerelease.length); for (let index = 0; index < count; index += 1) { if (left.prerelease[index] === undefined) return -1; if (right.prerelease[index] === undefined) return 1; const comparison = compareIdentifier(left.prerelease[index], right.prerelease[index]); if (comparison !== 0) return comparison; } return 0; } function sanitizeText(value) { return value .trim() .slice(0, MAX_RELEASE_NAME_LENGTH) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function validateRuntimeValue(value, field) { if (typeof value !== 'string' || value.length === 0 || value.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(value)) { throw policyError('INVALID_RUNTIME_METADATA', `Invalid ${field} metadata`); } return value; } function parseRelease(body) { let release; try { release = JSON.parse(body.toString('utf8')); } catch (error) { throw policyError('INVALID_RESPONSE', 'Release response was not valid JSON', error); } if (!release || typeof release !== 'object' || Array.isArray(release)) { throw policyError('INVALID_RESPONSE', 'Release response must be an object'); } if (typeof release.tag_name !== 'string' || release.tag_name.length > 128) { throw policyError('INVALID_RESPONSE', 'Release tag_name must be a bounded string'); } if (release.name !== undefined && release.name !== null && typeof release.name !== 'string') { throw policyError('INVALID_RESPONSE', 'Release name must be a string when provided'); } if (typeof release.name === 'string' && release.name.length > 1000) { throw policyError('INVALID_RESPONSE', 'Release name exceeds the schema limit'); } if (release.published_at !== undefined && release.published_at !== null && typeof release.published_at !== 'string') { throw policyError('INVALID_RESPONSE', 'Release published_at must be a string when provided'); } let publishedAt; if (typeof release.published_at === 'string') { if (release.published_at.length > 64) { throw policyError('INVALID_RESPONSE', 'Release published_at exceeds the schema limit'); } const timestamp = new Date(release.published_at); if (Number.isNaN(timestamp.getTime())) { throw policyError('INVALID_RESPONSE', 'Release published_at must be a valid timestamp'); } publishedAt = timestamp.toISOString(); } return { latestVersion: release.tag_name, releaseName: sanitizeText(release.name || release.tag_name), publishedAt, }; } function defaultRequest({ url, timeoutMs, maxBodyBytes, httpsGet = https.get, setTimer = setTimeout, clearTimer = clearTimeout, }) { if (url !== LATEST_RELEASE_URL) { return Promise.reject(policyError('UNTRUSTED_REQUEST_URL', 'Update checks are restricted to GitPeji')); } return new Promise((resolve, reject) => { let settled = false; let responseStream = null; let deadlineTimer = null; const clearDeadline = () => { if (deadlineTimer !== null) { clearTimer(deadlineTimer); deadlineTimer = null; } }; const finishReject = (error) => { if (!settled) { settled = true; clearDeadline(); reject(error); } }; const request = httpsGet(url, { headers: { accept: 'application/json', 'user-agent': 'Alta-Proxy-Tool-update-check', }, agent: false, }, (response) => { responseStream = response; const chunks = []; let receivedBytes = 0; const contentLength = Number(response.headers['content-length']); if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { response.destroy(); finishReject(policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit')); return; } response.on('data', (chunk) => { receivedBytes += chunk.length; if (receivedBytes > maxBodyBytes) { response.destroy(); finishReject(policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit')); return; } chunks.push(chunk); }); response.on('end', () => { if (settled) return; settled = true; clearDeadline(); resolve({ statusCode: response.statusCode, headers: response.headers, body: Buffer.concat(chunks), url, }); }); response.on('error', finishReject); }); deadlineTimer = setTimer(() => { const timeoutError = policyError('REQUEST_TIMEOUT', 'Release check timed out'); if (responseStream && typeof responseStream.destroy === 'function') responseStream.destroy(); request.destroy(timeoutError); finishReject(timeoutError); }, timeoutMs); request.on('error', (error) => { if (error instanceof UpdatePolicyError) { finishReject(error); } else { finishReject(policyError('REQUEST_FAILED', 'Release check failed', error)); } }); }); } async function checkForUpdate({ currentVersion, request = defaultRequest, platform = process.platform, arch = process.arch, } = {}) { parseSemver(currentVersion, 'INVALID_CURRENT_VERSION'); if (typeof request !== 'function') { throw policyError('INVALID_REQUEST_ADAPTER', 'Request adapter must be a function'); } const safePlatform = validateRuntimeValue(platform, 'platform'); const safeArch = validateRuntimeValue(arch, 'architecture'); const baseMetadata = { currentVersion, releasesPageUrl: RELEASES_PAGE_URL, platform: safePlatform, arch: safeArch, }; const response = await request({ url: LATEST_RELEASE_URL, timeoutMs: REQUEST_TIMEOUT_MS, maxBodyBytes: MAX_BODY_BYTES, redirects: 'error', }); if (!response || typeof response !== 'object') { throw policyError('INVALID_RESPONSE', 'Request adapter returned an invalid response'); } if (response.url !== LATEST_RELEASE_URL) { throw policyError('UNTRUSTED_RESPONSE_URL', 'Release response did not come from the exact GitPeji endpoint'); } const statusCode = Number(response.statusCode); if (statusCode >= 300 && statusCode < 400) { throw policyError('REDIRECT_REJECTED', 'Release endpoint redirects are not allowed'); } if (statusCode === 404) { return { status: 'no-release', ...baseMetadata }; } if (statusCode !== 200) { throw policyError('HTTP_ERROR', `Release endpoint returned HTTP ${statusCode}`); } const contentType = response.headers && response.headers['content-type']; if (typeof contentType !== 'string' || !/^application\/json(?:\s*;|$)/i.test(contentType)) { throw policyError('INVALID_CONTENT_TYPE', 'Release endpoint did not return JSON'); } const body = Buffer.isBuffer(response.body) ? response.body : typeof response.body === 'string' ? Buffer.from(response.body) : null; if (!body) { throw policyError('INVALID_RESPONSE', 'Release response body must be bytes or text'); } if (body.length > MAX_BODY_BYTES) { throw policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit'); } const release = parseRelease(body); const latestVersion = normalizeReleaseTag(release.latestVersion); const metadata = { status: compareSemver(latestVersion, currentVersion) > 0 ? 'update-available' : 'up-to-date', ...baseMetadata, latestVersion, releaseName: release.releaseName, }; if (release.publishedAt !== undefined) { metadata.publishedAt = release.publishedAt; } return metadata; } module.exports = { LATEST_RELEASE_URL, RELEASES_PAGE_URL, REQUEST_TIMEOUT_MS, MAX_BODY_BYTES, UpdatePolicyError, checkForUpdate, compareSemver, defaultRequest, };