import type { Stats } from 'node:fs'; import os from 'node:os'; import { isTruthy } from '@sindresorhus/is'; import { quote } from 'shlex'; import upath from 'upath'; import { GlobalConfig } from '../../../config/global.ts'; import { logger } from '../../../logger/index.ts'; import { withCache } from '../../../util/cache/package/with-cache.ts'; import { exec } from '../../../util/exec/index.ts'; import type { ExecOptions, ExtraEnv } from '../../../util/exec/types.ts'; import { chmodLocalFile, deleteLocalFile, readLocalFile, statLocalFile, writeLocalFile, } from '../../../util/fs/index.ts'; import { getRepoStatus } from '../../../util/git/index.ts'; import type { StatusResult } from '../../../util/git/types.ts'; import { hashStream } from '../../../util/hash.ts'; import { Http } from '../../../util/http/index.ts'; import { regEx } from '../../../util/regex.ts'; import mavenVersioning from '../../versioning/maven/index.ts'; import type { PackageDependency, UpdateArtifact, UpdateArtifactsConfig, UpdateArtifactsResult, } from '../types.ts'; const http = new Http('maven-wrapper'); const DEFAULT_MAVEN_REPO_URL = 'https://repo.maven.apache.org/maven2'; function getChecksumFromUrl(url: string): Promise { return withCache( { namespace: 'url-sha256', key: url, ttlMinutes: 3 * 24 * 60, // 3 days }, () => hashStream(http.stream(url), 'sha256'), ); } function getDistributionUrl(content: string): string | null { const match = regEx(/^distributionUrl\s*=\s*(?.+)$/m).exec( content, ); return ( match?.groups?.distributionUrl?.replace(regEx(/\\:/g), ':').trim() ?? null ); } function getWrapperUrl(content: string): string | null { const match = regEx(/^wrapperUrl\s*=\s*(?.+)$/m).exec(content); return match?.groups?.wrapperUrl?.replace(regEx(/\\:/g), ':').trim() ?? null; } function constructWrapperUrl( version: string, repoUrl: string = DEFAULT_MAVEN_REPO_URL, ): string { return `${repoUrl}/org/apache/maven/wrapper/maven-wrapper/${version}/maven-wrapper-${version}.jar`; } function getWrapperVersion(content: string): string | null { const match = regEx(/^wrapperVersion\s*=\s*(?.+)$/m).exec( content, ); return match?.groups?.wrapperVersion?.trim() ?? null; } function getDistributionType(content: string): string | null { const match = regEx(/^distributionType\s*=\s*(?.+)$/m).exec( content, ); return match?.groups?.distributionType?.trim() ?? null; } function getChecksumValue(content: string, key: string): string | null { const match = regEx(`^${key}\\s*=\\s*(?.+)$`, 'm').exec(content); return match?.groups?.value?.trim() ?? null; } function addChecksumAfterLine( content: string, lineRegex: RegExp, key: string, value: string, ): string { return content.replace( regEx(lineRegex), (lineMatch) => `${lineMatch}\n${key}=${value}`, ); } async function updateChecksums( content: string, updatedDeps: PackageDependency[], addDistributionChecksum: boolean, addWrapperChecksum: boolean, originalContent: string, ): Promise { let updatedContent = content; // Update or add distribution checksum if (addDistributionChecksum) { const existingChecksum = getChecksumValue( updatedContent, 'distributionSha256Sum', ); const fallbackChecksum = getChecksumValue( originalContent, 'distributionSha256Sum', ); const distUrl = getDistributionUrl(updatedContent); if (distUrl) { try { const checksum = await getChecksumFromUrl(distUrl); if (existingChecksum) { updatedContent = updatedContent.replace( regEx(/distributionSha256Sum=.*/), `distributionSha256Sum=${checksum}`, ); } else { // Add checksum after distributionUrl updatedContent = addChecksumAfterLine( updatedContent, regEx(/^(?:distributionUrl\s*=\s*.+)$/m), 'distributionSha256Sum', checksum, ); } } catch (err) { logger.warn( { err, url: distUrl }, 'Failed to fetch distribution checksum', ); if (!existingChecksum && fallbackChecksum) { updatedContent = addChecksumAfterLine( updatedContent, regEx(/^(?:distributionUrl\s*=\s*.+)$/m), 'distributionSha256Sum', fallbackChecksum, ); } } } } // Update or add wrapper checksum if (addWrapperChecksum) { const existingChecksum = getChecksumValue( updatedContent, 'wrapperSha256Sum', ); const fallbackChecksum = getChecksumValue( originalContent, 'wrapperSha256Sum', ); let wrapperUrl = getWrapperUrl(updatedContent); // If no wrapperUrl, try to construct from wrapperVersion if (!wrapperUrl) { const wrapperVersion = getWrapperVersion(updatedContent); if (wrapperVersion) { const customRepoUrl = getCustomMavenWrapperRepoUrl(updatedDeps); wrapperUrl = constructWrapperUrl( wrapperVersion, customRepoUrl ?? DEFAULT_MAVEN_REPO_URL, ); } } if (wrapperUrl) { try { const checksum = await getChecksumFromUrl(wrapperUrl); if (existingChecksum) { updatedContent = updatedContent.replace( regEx(/wrapperSha256Sum=.*/), `wrapperSha256Sum=${checksum}`, ); } else { // Add checksum after wrapperUrl or wrapperVersion updatedContent = addChecksumAfterLine( updatedContent, regEx(/^(?:wrapperUrl\s*=\s*.+|wrapperVersion\s*=\s*.+)$/m), 'wrapperSha256Sum', checksum, ); } } catch (err) { logger.warn( { err, url: wrapperUrl }, 'Failed to fetch wrapper checksum', ); if (!existingChecksum && fallbackChecksum) { updatedContent = addChecksumAfterLine( updatedContent, regEx(/^(?:wrapperUrl\s*=\s*.+|wrapperVersion\s*=\s*.+)$/m), 'wrapperSha256Sum', fallbackChecksum, ); } } } } return updatedContent; } interface MavenWrapperPaths { wrapperExecutableFileName: string; localProjectDir: string; wrapperFullyQualifiedPath: string; } async function addIfUpdated( status: StatusResult, fileProjectPath: string, ): Promise { if (status.modified.includes(fileProjectPath)) { return { file: { type: 'addition', path: fileProjectPath, contents: await readLocalFile(fileProjectPath), }, }; } return null; } export async function updateArtifacts({ packageFileName, newPackageFileContent, updatedDeps, config, }: UpdateArtifact): Promise { try { logger.debug({ updatedDeps }, 'maven-wrapper.updateArtifacts()'); const hasMavenUpdate = updatedDeps.some((dep) => dep.depName === 'maven'); const hasWrapperUpdate = updatedDeps.some( (dep) => dep.depName === 'maven-wrapper', ); // Remember original content properties - we'll restore them after wrapper:wrapper const hadDistributionChecksum = newPackageFileContent.includes( 'distributionSha256Sum=', ); const hadWrapperChecksum = newPackageFileContent.includes('wrapperSha256Sum='); const originalDistributionType = getDistributionType(newPackageFileContent); // Skip if no relevant updates if (!hasWrapperUpdate && !(hasMavenUpdate && hadDistributionChecksum)) { logger.debug( 'No Maven wrapper or distribution checksum updates - skipping Artifacts update', ); return null; } // If wrapper is being updated, check if mvnw exists first let cmd: string | null = null; if (hasWrapperUpdate) { cmd = await createWrapperCommand( packageFileName, originalDistributionType, ); if (!cmd) { logger.info('No mvnw found - skipping Artifacts update'); return null; } } // Delete old maven-wrapper.jar if checksums exist and wrapper:wrapper will run // This prevents checksum validation failure when wrapper:wrapper runs if (hasWrapperUpdate && (hadWrapperChecksum || hadDistributionChecksum)) { const jarPath = packageFileName.replace( 'maven-wrapper.properties', 'maven-wrapper.jar', ); if (jarPath.endsWith('maven-wrapper.jar')) { try { await deleteLocalFile(jarPath); logger.debug({ jarPath }, 'Deleted old maven-wrapper.jar'); } catch { // File may not exist, ignore } } } // Strip checksum lines before running wrapper:wrapper to avoid validation failure // (mvnw would try to validate NEW jar against OLD checksum) let contentToWrite = newPackageFileContent; if (hasWrapperUpdate && (hadWrapperChecksum || hadDistributionChecksum)) { contentToWrite = contentToWrite .replace(regEx(/^distributionSha256Sum=.*\n?/m), '') .replace(regEx(/^wrapperSha256Sum=.*\n?/m), ''); } // Write the properties file await writeLocalFile(packageFileName, contentToWrite); // Run wrapper:wrapper if the wrapper itself is being updated if (hasWrapperUpdate && cmd) { const extraEnv = getExtraEnvOptions(updatedDeps); await executeWrapperCommand(cmd, config, packageFileName, extraEnv); } // Now update checksums AFTER wrapper:wrapper has run (if it ran) // This ensures we compute checksums for the actual generated content if (hadDistributionChecksum || hadWrapperChecksum) { const currentContent = await readLocalFile(packageFileName, 'utf8'); if (currentContent) { const contentWithChecksums = await updateChecksums( currentContent, updatedDeps, hadDistributionChecksum, hadWrapperChecksum, newPackageFileContent, ); await writeLocalFile(packageFileName, contentWithChecksums); } } const status = await getRepoStatus(); const artifactFileNames = [ '.mvn/wrapper/maven-wrapper.properties', '.mvn/wrapper/maven-wrapper.jar', '.mvn/wrapper/MavenWrapperDownloader.java', 'mvnw', 'mvnw.cmd', ].map( (filename) => packageFileName.replace('.mvn/wrapper/maven-wrapper.properties', '') + filename, ); const updateArtifactsResult = ( await getUpdatedArtifacts(status, artifactFileNames) ).filter(isTruthy); logger.debug( { files: updateArtifactsResult.map((r) => r.file?.path) }, `Returning updated maven-wrapper files`, ); return updateArtifactsResult; } catch (err) { logger.debug({ err }, 'Error setting new Maven Wrapper release value'); return [ { artifactError: { fileName: packageFileName, stderr: err.message, }, }, ]; } } async function getUpdatedArtifacts( status: StatusResult, artifactFileNames: string[], ): Promise { const updatedResults: UpdateArtifactsResult[] = []; for (const artifactFileName of artifactFileNames) { const updatedResult = await addIfUpdated(status, artifactFileName); if (updatedResult !== null) { updatedResults.push(updatedResult); } } return updatedResults; } /** * Find compatible java version for maven. * see https://maven.apache.org/developers/compatibility-plan.html * @param mavenWrapperVersion current maven version * @returns A Java semver range */ export function getJavaConstraint( mavenWrapperVersion: string | null | undefined, ): string | null { const major = mavenWrapperVersion ? mavenVersioning.getMajor(mavenWrapperVersion) : null; if (major && major >= 3) { return '^17.0.0'; } return '^8.0.0'; } async function executeWrapperCommand( cmd: string, config: UpdateArtifactsConfig, packageFileName: string, extraEnv: ExtraEnv, ): Promise { logger.debug(`Updating maven wrapper: "${cmd}"`); const { wrapperFullyQualifiedPath } = getMavenPaths(packageFileName); const execOptions: ExecOptions = { cwdFile: wrapperFullyQualifiedPath, docker: {}, extraEnv, toolConstraints: [ { toolName: 'java', constraint: config.constraints?.java ?? getJavaConstraint(config.currentValue), }, ], }; try { await exec(cmd, execOptions); } catch (err) { logger.error({ err }, 'Error executing maven wrapper update command.'); throw err; } } function getExtraEnvOptions(deps: PackageDependency[]): ExtraEnv { const customMavenWrapperUrl = getCustomMavenWrapperRepoUrl(deps); if (customMavenWrapperUrl) { return { MVNW_REPOURL: customMavenWrapperUrl }; } return {}; } function getCustomMavenWrapperRepoUrl( deps: PackageDependency[], ): string | null { const replaceString = deps.find( (dep) => dep.depName === 'maven-wrapper', )?.replaceString; if (!replaceString) { return null; } const match = regEx(/^(?.*?)\/org\/apache\/maven\/wrapper\//).exec( replaceString, ); if (!match) { return null; } return match.groups!.repoUrl === DEFAULT_MAVEN_REPO_URL ? null : match.groups!.repoUrl; } async function createWrapperCommand( packageFileName: string, distributionType: string | null, ): Promise { const { wrapperExecutableFileName, localProjectDir, wrapperFullyQualifiedPath, } = getMavenPaths(packageFileName); // Use existing distributionType or default to 'script' to preserve JAR-based mode // (prevents Maven 3.3.x from defaulting to only-script which doesn't support checksums) const type = distributionType ?? 'script'; const args = `wrapper:wrapper -Dtype=${quote(type)}`; return await prepareCommand( wrapperExecutableFileName, localProjectDir, await statLocalFile(wrapperFullyQualifiedPath), args, ); } function mavenWrapperFileName(): string { if ( os.platform() === 'win32' && GlobalConfig.get('binarySource') !== 'docker' ) { return 'mvnw.cmd'; } return './mvnw'; } function getMavenPaths(packageFileName: string): MavenWrapperPaths { const wrapperExecutableFileName = mavenWrapperFileName(); const localProjectDir = upath.join( upath.dirname(packageFileName), packageFileName.includes('mvnw') ? '.' : '../../', ); const wrapperFullyQualifiedPath = upath.join( localProjectDir, wrapperExecutableFileName, ); return { wrapperExecutableFileName, localProjectDir, wrapperFullyQualifiedPath, }; } async function prepareCommand( fileName: string, cwd: string | undefined, pathFileStats: Stats | null, args: string | null, ): Promise { /* v8 ignore next -- hard to test */ if (pathFileStats?.isFile() === true) { // if the file is not executable by others if (os.platform() !== 'win32' && (pathFileStats.mode & 0o1) === 0) { // add the execution permission to the owner, group and others logger.warn('Maven wrapper is missing the executable bit'); await chmodLocalFile( // TODO: types (#22198) upath.join(cwd!, fileName), pathFileStats.mode | 0o111, ); } if (args === null) { return fileName; } return `${fileName} ${args}`; } return null; }