Files
Arnaud Dezandee 6a782d3a18 feat(terraform-provider): use OpenTofu packages API for lockfile hashes (#43573)
* feat(terraform-provider): use OpenTofu packages API for lockfile hashes

* refactor(terraform-provider): reuse OpenTofu registry constant

* refactor(terraform-provider): test file structure consistency

* refactor(terraform-provider): simplify hashes with flatMap

Co-authored-by: Michael Kriese <michael.kriese@gmx.de>

* fix(terraform-provider): handle OpenTofu API URL for lockfile hashes

* perf(terraform-provider): skip /versions request for OpenTofu lockfile hashes

* refactor(terraform-provider): improve function return flow

Co-authored-by: RahulGautamSingh <rahultesnik@gmail.com>

* docs(terraform-provider): clarify OpenTofu packages endpoint behavior

* test(terraform-provider): add message to toThrow assertion

* fix(terraform-provider): wrap OpenTofu packages errors in ExternalHostError

* test(terraform-provider): cover OpenTofu package errors

* refactor(terraform-provider): use async/await for provider packages

* refactor(terraform-provider): use LooseRecord for schema packages prop

Co-authored-by: Sergei Zharinov <zharinov@users.noreply.github.com>

* fix(terraform-provider): import LooseRecord in schema

---------

Co-authored-by: Michael Kriese <michael.kriese@gmx.de>
Co-authored-by: RahulGautamSingh <rahultesnik@gmail.com>
Co-authored-by: Sergei Zharinov <zharinov@users.noreply.github.com>
2026-08-14 09:51:00 +00:00

236 lines
7.5 KiB
TypeScript

import crypto from 'node:crypto';
import { once } from 'node:events';
import { Transform, type TransformCallback } from 'node:stream';
import { crc32 } from 'node:zlib';
import upath from 'upath';
import { openPromise, validateFileName } from 'yauzl';
import { logger } from '../../../../logger/index.ts';
import {
coerceArray,
deduplicateArray,
isNotNullOrUndefined,
} from '../../../../util/array.ts';
import { withCache } from '../../../../util/cache/package/with-cache.ts';
import * as fs from '../../../../util/fs/index.ts';
import { hashStream } from '../../../../util/hash.ts';
import { Http } from '../../../../util/http/index.ts';
import * as p from '../../../../util/promises.ts';
import { TerraformProviderDatasource } from '../../../datasource/terraform-provider/index.ts';
import type { TerraformBuild } from '../../../datasource/terraform-provider/schema.ts';
class Crc32Stream extends Transform {
checksum = 0;
override _transform(
chunk: Buffer,
_encoding: BufferEncoding,
callback: TransformCallback,
): void {
this.checksum = crc32(chunk, this.checksum);
callback(null, chunk);
}
}
export class TerraformProviderHash {
static http = new Http(TerraformProviderDatasource.id);
static terraformDatasource = new TerraformProviderDatasource();
static hashCacheTTL = 10080; // in minutes == 1 week
/**
* Computes the `h1:` checksum used in Terraform dependency lock files.
*
* Terraform verifies extracted providers with Go's `HashDir`, which ignores
* ZIP directory entries. Ignore them here so the archive produces the same
* checksum as the extracted provider.
*
* See https://github.com/golang/go/issues/53448.
*/
static async hashOfZipContent(zipFilePath: string): Promise<string> {
const zipFile = await openPromise(zipFilePath, {
autoClose: false,
decodeStrings: false,
});
const entryHashes: { path: Buffer; hash: string }[] = [];
const entryPaths = new Set<string>();
try {
for await (const entry of zipFile.eachEntry()) {
// Go's ZIP reader preserves the central-directory name bytes instead of decoding CP437 or Unicode path fields.
const fileName = entry.fileNameRaw
.toString('latin1')
.replaceAll('\\', '/');
// Terraform verifies extracted packages with Go HashDir semantics, which omit directory records.
if (fileName.endsWith('/')) {
continue;
}
if (fileName.includes('\n')) {
throw new Error('ZIP entry name contains a newline');
}
const fileNameError = validateFileName(fileName);
if (fileNameError) {
throw new Error(fileNameError);
}
const normalizedPath = upath.normalize(fileName);
if (entryPaths.has(normalizedPath)) {
throw new Error(`Duplicate ZIP entry path: ${normalizedPath}`);
}
entryPaths.add(normalizedPath);
const path = Buffer.from(normalizedPath, 'latin1');
const entryStream = await zipFile.openReadStreamPromise(entry);
const crc32Stream = new Crc32Stream();
const hash = await hashStream(
entryStream.compose(crc32Stream),
'sha256',
);
if (crc32Stream.checksum !== entry.crc32) {
throw new Error(
`CRC-32 mismatch for ZIP entry ${entry.fileNameRaw.toString('utf8')}`,
);
}
entryHashes.push({ path, hash });
}
} finally {
const closePromise = once(zipFile, 'close');
zipFile.close();
await closePromise;
}
entryHashes.sort((left, right) => Buffer.compare(left.path, right.path));
const rootHash = crypto.createHash('sha256');
for (const entry of entryHashes) {
rootHash.update(`${entry.hash} `);
rootHash.update(entry.path);
rootHash.update('\n');
}
return rootHash.digest('base64');
}
private static async _calculateSingleHash(
build: TerraformBuild,
cacheDir: string,
): Promise<string> {
const downloadFileName = upath.join(cacheDir, crypto.randomUUID());
logger.trace(
`Downloading archive and generating hash for ${build.name}-${build.version}...`,
);
const startTime = Date.now();
const readStream = TerraformProviderHash.http.stream(build.url);
const writeStream = fs.createCacheWriteStream(downloadFileName);
try {
await fs.pipeline(readStream, writeStream);
const hash = await this.hashOfZipContent(downloadFileName);
logger.debug(
`Hash generation for ${build.url} took ${Date.now() - startTime}ms for ${build.name}-${build.version}`,
);
return hash;
} finally {
await fs.rmCache(downloadFileName);
}
}
static calculateSingleHash(
build: TerraformBuild,
cacheDir: string,
): Promise<string> {
return withCache(
{
namespace: `terraform-provider-hash`,
key: `calculateSingleHash:${build.url}`,
ttlMinutes: TerraformProviderHash.hashCacheTTL,
},
() => TerraformProviderHash._calculateSingleHash(build, cacheDir),
);
}
static async calculateHashScheme1Hashes(
builds: TerraformBuild[],
): Promise<string[]> {
logger.debug(`Calculating hashes for ${builds.length} builds`);
const cacheDir = await fs.ensureCacheDir('terraform');
return p.map(builds, (build) => this.calculateSingleHash(build, cacheDir), {
concurrency: 4,
});
}
static async createHashes(
registryURL: string,
repository: string,
version: string,
): Promise<string[] | null> {
logger.debug(
`Creating hashes for ${repository}@${version} (${registryURL})`,
);
if (
registryURL === TerraformProviderDatasource.openTofuRegistryUrl ||
registryURL === TerraformProviderDatasource.openTofuApiUrl
) {
const packagesHashes =
await TerraformProviderHash.terraformDatasource.getProviderPackages(
repository,
version,
);
if (packagesHashes?.length) {
logger.debug(
`Using OpenTofu packages API for ${repository}@${version}`,
);
// hashes are a logical set which Terraform deduplicates and sorts
return deduplicateArray(packagesHashes).sort();
}
logger.debug(
`OpenTofu packages field unavailable for ${repository}@${version}, falling back to zip download`,
);
}
const builds = await TerraformProviderHash.terraformDatasource.getBuilds(
registryURL,
repository,
version,
);
if (!builds) {
return null;
}
// check if the publisher uses one shasum file for all builds or separate ones
// we deduplicate to reduce the number of API calls
const shaUrls = deduplicateArray(
builds.map((build) => build.shasums_url).filter(isNotNullOrUndefined),
);
logger.debug(
`Getting zip hashes for ${shaUrls.length} shasum URL(s) for ${repository}@${version}`,
);
const zhHashes: string[] = [];
for (const shaUrl of shaUrls) {
const hashes =
await TerraformProviderHash.terraformDatasource.getZipHashes(shaUrl);
zhHashes.push(...coerceArray(hashes));
}
logger.debug(
`Got ${zhHashes.length} zip hashes for ${repository}@${version}`,
);
const h1Hashes =
await TerraformProviderHash.calculateHashScheme1Hashes(builds);
const hashes = [];
hashes.push(...h1Hashes.map((hash) => `h1:${hash}`));
hashes.push(...zhHashes.map((hash) => `zh:${hash}`));
// hashes are a logical set which Terraform deduplicates and sorts
return deduplicateArray(hashes).sort();
}
}