Skip to content

Commit 660ae45

Browse files
authored
Implement logging and SHA256 verification with retry
1 parent bdd5462 commit 660ae45

File tree

1 file changed

+103
-8
lines changed

1 file changed

+103
-8
lines changed

src/installer/get-python.js

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { promisify } from 'util';
1616
import semver from 'semver';
1717
import stream from 'stream';
1818
import zlib from 'zlib';
19+
import { createHash } from 'crypto';
1920
import { decompress } from 'fzstd';
2021
const tar = require('tar');
2122

@@ -116,6 +117,57 @@ const FALLBACK_RELEASE_TAG = '20250818';
116117
// Pre-compiled regex for better performance
117118
const ASSET_NAME_REGEX = /^cpython-(\d+\.\d+\.\d+)\+(\d+)-([^-]+)-([^-]+)-([^-]+)(?:-([^-]+))?(?:-([^.]+))?\.(tar\.(?:gz|zst))$/;
118119

120+
/**
121+
* Simple logger for minimal output
122+
* @param {string} level - Log level (info, warn, error)
123+
* @param {string} message - Log message
124+
*/
125+
function log(level, message) {
126+
const timestamp = new Date().toISOString();
127+
console[level](`[${timestamp}] [Python-Installer] ${message}`);
128+
}
129+
130+
/**
131+
* Calculate SHA256 hash of a file
132+
* @param {string} filePath - Path to file
133+
* @returns {Promise<string>} SHA256 hex digest
134+
*/
135+
async function calculateFileSHA256(filePath) {
136+
return new Promise((resolve, reject) => {
137+
const hash = createHash('sha256');
138+
const stream = fs.createReadStream(filePath);
139+
140+
stream.on('error', reject);
141+
stream.on('data', chunk => hash.update(chunk));
142+
stream.on('end', () => resolve(hash.digest('hex')));
143+
});
144+
}
145+
146+
/**
147+
* Verify file integrity using SHA256 checksum
148+
* @param {string} filePath - Path to downloaded file
149+
* @param {string} expectedSHA - Expected SHA256 hash from API
150+
* @returns {Promise<boolean>} True if verification passes
151+
*/
152+
async function verifyFileIntegrity(filePath, expectedSHA) {
153+
try {
154+
const actualSHA = await calculateFileSHA256(filePath);
155+
const expectedSHAClean = expectedSHA.replace('sha256:', '').toLowerCase();
156+
const actualSHAClean = actualSHA.toLowerCase();
157+
158+
if (actualSHAClean === expectedSHAClean) {
159+
log('info', `File integrity verified: ${path.basename(filePath)}`);
160+
return true;
161+
} else {
162+
log('error', `File integrity check failed: expected ${expectedSHAClean}, got ${actualSHAClean}`);
163+
return false;
164+
}
165+
} catch (err) {
166+
log('error', `SHA256 verification failed: ${err.message}`);
167+
return false;
168+
}
169+
}
170+
119171
/**
120172
* Search for existing Python executable in system PATH with version validation
121173
* Only accepts Python versions 3.10 through 3.13
@@ -126,6 +178,8 @@ export async function findPythonExecutable() {
126178
const envPath = process.env.PLATFORMIO_PATH || process.env.PATH;
127179
const errors = [];
128180

181+
log('info', 'Searching for compatible Python installation (3.10-3.13)');
182+
129183
// Search through all PATH locations for Python executables
130184
for (const location of envPath.split(path.delimiter)) {
131185
for (const exename of exenames) {
@@ -134,10 +188,10 @@ export async function findPythonExecutable() {
134188
if (fs.existsSync(executable) &&
135189
(await isValidPythonVersion(executable)) &&
136190
(await callInstallerScript(executable, ['check', 'python']))) {
191+
log('info', `Found compatible Python: ${executable}`);
137192
return executable;
138193
}
139194
} catch (err) {
140-
console.warn(executable, err);
141195
errors.push(err);
142196
}
143197
}
@@ -149,6 +203,8 @@ export async function findPythonExecutable() {
149203
throw err;
150204
}
151205
}
206+
207+
log('info', 'No compatible system Python found, will install portable Python');
152208
return null;
153209
}
154210

@@ -203,11 +259,15 @@ async function ensurePythonExeExists(pythonDir) {
203259
* @returns {Promise<string>} Path to installed Python directory
204260
*/
205261
export async function installPortablePython(destinationDir, options = undefined) {
262+
log('info', 'Starting portable Python installation');
263+
206264
const registryFile = await getRegistryFile();
207265
if (!registryFile) {
208266
throw new Error(`Could not find portable Python for ${proc.getSysType()}`);
209267
}
210268

269+
log('info', `Selected Python package: ${registryFile.name}`);
270+
211271
const archivePath = await downloadRegistryFile(
212272
registryFile,
213273
core.getTmpDir(),
@@ -221,12 +281,15 @@ export async function installPortablePython(destinationDir, options = undefined)
221281
try {
222282
await fs.promises.rm(destinationDir, { recursive: true, force: true });
223283
} catch (err) {
224-
console.warn(err);
284+
// Ignore cleanup errors
225285
}
226286

227287
// Extract archive and verify Python executable
288+
log('info', 'Extracting Python archive');
228289
await extractArchive(archivePath, destinationDir);
229290
await ensurePythonExeExists(destinationDir);
291+
292+
log('info', `Python installation completed: ${destinationDir}`);
230293
return destinationDir;
231294
}
232295

@@ -243,6 +306,7 @@ async function getLatestReleaseTag() {
243306
}
244307

245308
try {
309+
log('info', 'Fetching latest release tag from GitHub');
246310
const latestRelease = await got(
247311
'https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest',
248312
{
@@ -261,9 +325,11 @@ async function getLatestReleaseTag() {
261325
cachedLatestTag = latestRelease.tag_name;
262326
latestTagCacheTime = now;
263327

328+
log('info', `Using latest release: ${cachedLatestTag}`);
264329
return cachedLatestTag;
265330
} catch (err) {
266331
// Fallback to known stable release if API fails
332+
log('warn', `Failed to get latest release, using fallback: ${FALLBACK_RELEASE_TAG}`);
267333
return FALLBACK_RELEASE_TAG;
268334
}
269335
}
@@ -286,6 +352,7 @@ async function getRegistryFile() {
286352

287353
// If latest release has no compatible assets, fallback to known working release
288354
if (!selectedAsset && cachedLatestTag !== FALLBACK_RELEASE_TAG) {
355+
log('warn', 'No compatible assets in latest release, trying fallback release');
289356
selectedAsset = await tryGetRegistryFromRelease(FALLBACK_RELEASE_TAG, systype);
290357
}
291358

@@ -325,7 +392,7 @@ async function tryGetRegistryFromRelease(releaseTag, systype) {
325392

326393
return selectBestAsset(releaseData, systype);
327394
} catch (err) {
328-
// If release fetch fails, return null to trigger fallback
395+
log('warn', `Failed to fetch release ${releaseTag}: ${err.message}`);
329396
return null;
330397
}
331398
}
@@ -364,6 +431,7 @@ function selectBestAsset(releaseData, systype) {
364431
size: bestAsset.size,
365432
system: [systype],
366433
compression: getCompressionType(bestAsset.name),
434+
digest: bestAsset.digest || null, // SHA256 checksum from GitHub API
367435
};
368436
}
369437

@@ -608,7 +676,7 @@ function getCompressionType(filename) {
608676
}
609677

610678
/**
611-
* Download registry file with optimized streaming
679+
* Download registry file with SHA256 verification
612680
* @param {object} regfile - Registry file information
613681
* @param {string} destinationDir - Download destination directory
614682
* @param {object} options - Optional configuration
@@ -621,20 +689,37 @@ async function downloadRegistryFile(regfile, destinationDir, options = {}) {
621689
if (options.predownloadedPackageDir) {
622690
archivePath = path.join(options.predownloadedPackageDir, regfile.name);
623691
if (await fileExists(archivePath)) {
624-
console.info('Using predownloaded package from ' + archivePath);
625-
return archivePath;
692+
log('info', `Using predownloaded package: ${regfile.name}`);
693+
694+
// Verify integrity of predownloaded file if digest is available
695+
if (regfile.digest && !(await verifyFileIntegrity(archivePath, regfile.digest))) {
696+
log('warn', 'Predownloaded file failed integrity check, re-downloading');
697+
} else {
698+
return archivePath;
699+
}
626700
}
627701
}
628702

629703
archivePath = path.join(destinationDir, regfile.name);
630704

631-
// Skip if already downloaded
705+
// Skip if already downloaded and verified
632706
if (await fileExists(archivePath)) {
633-
return archivePath;
707+
if (regfile.digest) {
708+
if (await verifyFileIntegrity(archivePath, regfile.digest)) {
709+
return archivePath;
710+
} else {
711+
log('warn', 'Existing file failed integrity check, re-downloading');
712+
await fs.promises.unlink(archivePath);
713+
}
714+
} else {
715+
return archivePath;
716+
}
634717
}
635718

636719
const pipeline = promisify(stream.pipeline);
637720

721+
log('info', `Downloading Python package: ${regfile.name} (${Math.round(regfile.size / 1024 / 1024)}MB)`);
722+
638723
await pipeline(
639724
got.stream(regfile.download_url, {
640725
timeout: { request: 60000 },
@@ -651,6 +736,16 @@ async function downloadRegistryFile(regfile, destinationDir, options = {}) {
651736
throw new Error('Failed to download Python archive');
652737
}
653738

739+
// Verify file integrity using SHA256 if available
740+
if (regfile.digest) {
741+
if (!(await verifyFileIntegrity(archivePath, regfile.digest))) {
742+
await fs.promises.unlink(archivePath);
743+
throw new Error('Downloaded file failed SHA256 integrity check');
744+
}
745+
} else {
746+
log('warn', 'No SHA256 digest available for verification');
747+
}
748+
654749
return archivePath;
655750
}
656751

0 commit comments

Comments
 (0)