@@ -16,6 +16,7 @@ import { promisify } from 'util';
1616import semver from 'semver' ;
1717import stream from 'stream' ;
1818import zlib from 'zlib' ;
19+ import { createHash } from 'crypto' ;
1920import { decompress } from 'fzstd' ;
2021const tar = require ( 'tar' ) ;
2122
@@ -116,6 +117,57 @@ const FALLBACK_RELEASE_TAG = '20250818';
116117// Pre-compiled regex for better performance
117118const ASSET_NAME_REGEX = / ^ c p y t h o n - ( \d + \. \d + \. \d + ) \+ ( \d + ) - ( [ ^ - ] + ) - ( [ ^ - ] + ) - ( [ ^ - ] + ) (?: - ( [ ^ - ] + ) ) ? (?: - ( [ ^ . ] + ) ) ? \. ( t a r \. (?: g z | z s t ) ) $ / ;
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 */
205261export 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