fix: harden provider and promotion evidence

This commit is contained in:
DongHyeonka
2026-08-02 16:28:24 +09:00
parent 42ffb79997
commit 30ceac23c1
29 changed files with 3961 additions and 1076 deletions
+360 -219
View File
@@ -1,40 +1,54 @@
import { createHash, createPublicKey, randomUUID } from "node:crypto";
import {
createHash,
createPublicKey,
randomBytes as cryptoRandomBytes,
} from "node:crypto";
import { constants } from "node:fs";
import { lstat, mkdtemp, open, rename, rm } from "node:fs/promises";
import {
lstat,
mkdir,
open,
rm,
stat,
} from "node:fs/promises";
import path from "node:path";
import {
evaluatePromotionEvidence,
providerVerificationArtifactSchema,
provenanceProviderAttestationSchema,
vulnerabilityProviderReportSchema,
} from "./provider-evidence.ts";
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
import { verifyReleaseCandidate } from "./release-candidate.ts";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
PROMOTED_FILE_NAMES,
type PromotedFileName,
} from "../contracts/promotion-artifacts.ts";
import {
assertSafePublishLeaf,
ensureSafePublishDirectory,
} from "./ci-gate-log.ts";
import { PROMOTED_STAGING_PATHS } from "../contracts/promotion-artifacts.ts";
export { PROMOTED_STAGING_PATHS };
type PromotionSource = Readonly<{
sourcePath: string;
destinationName: string;
maxBytes: number;
validate: (bytes: Buffer) => void;
}>;
evaluatePromotionEvidence,
providerPublicKeyFingerprint,
providerVerificationArtifactSchema,
PROMOTION_VERIFIER_ID,
PROMOTION_VERIFIER_VERSION,
provenanceProviderAttestationSchema,
trustPolicySha256,
vulnerabilityProviderReportSchema,
type ProviderTrust,
} from "./provider-evidence.ts";
import {
captureCiCandidateArchive,
withVerifiedCapturedCandidate,
} from "./ci-candidate-archive.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
type StagedFile = Readonly<{
destinationName: string;
name: PromotedFileName;
bytes: Buffer;
digest: string;
sha256: string;
}>;
export async function stageVerifiedPromotion(input: Readonly<{
export type FinalizedPromotion = Readonly<{
stagingRoot: string;
cleanupToken: string;
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
}>;
export async function finalizeVerifiedPromotion(input: Readonly<{
repositoryRoot: string;
archivePath: string;
expectedArchiveSha256: string;
@@ -44,203 +58,330 @@ export async function stageVerifiedPromotion(input: Readonly<{
vulnerabilityKeyId: string;
provenancePublicKeyPath: string;
provenanceKeyId: string;
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
vulnerabilityInvocationNonce: string;
provenanceInvocationNonce: string;
runnerTempRoot: string;
}>, dependencies: Readonly<{
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
captureArchive?: typeof captureCiCandidateArchive;
nowEpochMs?: () => number;
randomBytes?: (bytes: number) => Buffer;
afterCapture?: () => Promise<void>;
beforePublishRename?: () => Promise<void>;
}> = {}): Promise<ReadonlyArray<Readonly<{ path: string; sha256: string }>>> {
beforePublish?: () => Promise<void>;
afterStagingWrite?: () => Promise<void>;
}> = {}): Promise<FinalizedPromotion> {
const root = path.resolve(input.repositoryRoot);
if (!/^[a-f0-9]{64}$/u.test(input.expectedArchiveSha256)) {
throw new TypeError("promotion archive SHA-256 is invalid");
}
const sources: PromotionSource[] = [
{
sourcePath: input.archivePath,
destinationName: "release-candidate.tar.gz",
maxBytes: 268_435_456,
validate: (bytes) => {
if (sha256(bytes) !== input.expectedArchiveSha256) {
throw new Error("promotion archive SHA-256 changed before staging");
}
},
},
{
sourcePath: input.vulnerabilityReportPath,
destinationName: "vulnerability-report.json",
maxBytes: 16_777_216,
validate: (bytes) => vulnerabilityProviderReportSchema.parse(parseJson(bytes)),
},
{
sourcePath: input.provenanceAttestationPath,
destinationName: "provenance-attestation.json",
maxBytes: 16_777_216,
validate: (bytes) => provenanceProviderAttestationSchema.parse(parseJson(bytes)),
},
{
sourcePath: "artifacts/security/provider-verification.json",
destinationName: "provider-verification.json",
maxBytes: 4_194_304,
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
},
{
sourcePath: "artifacts/security/promotion-verification.json",
destinationName: "promotion-verification.json",
maxBytes: 4_194_304,
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
},
];
const [captured, vulnerabilityPublicKey, provenancePublicKey] = await Promise.all([
Promise.all(
sources.map(async (source) => {
const relativePath = repositoryRelative(root, source.sourcePath);
const bytes = await readBoundedRegularFile({
root,
relativePath,
maxBytes: source.maxBytes,
});
source.validate(bytes);
return Object.freeze({ ...source, bytes, digest: sha256(bytes) });
}),
),
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
capture(root, input.provenancePublicKeyPath, 1_048_576),
]);
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
archivePath: input.archivePath,
expectedSha256: input.expectedArchiveSha256,
});
const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] =
await Promise.all([
capture(root, input.vulnerabilityReportPath, 16_777_216),
capture(root, input.provenanceAttestationPath, 16_777_216),
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
capture(root, input.provenancePublicKeyPath, 1_048_576),
]);
await dependencies.afterCapture?.();
let capturedLocalStatus: "PASS" | "FAIL" = "FAIL";
const archive = await verifyCapturedCiCandidateArchive(
captured[0]!.bytes,
input.expectedArchiveSha256,
{
verifyExtracted: async (extractionRoot, manifest) => {
const candidate = await verifyReleaseCandidate(manifest, extractionRoot);
if (candidate.failures.length > 0) {
throw new Error(`captured candidate failed final verification: ${candidate.failures.join(", ")}`);
}
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
repositoryRoot: extractionRoot,
candidate: manifest,
});
if (local.status !== "PASS" || local.failures.length > 0) {
throw new Error(`captured local evidence failed final verification: ${local.failures.join(", ")}`);
}
capturedLocalStatus = local.status;
},
},
const vulnerabilityTrust = capturedTrust(
input.vulnerabilityKeyId,
vulnerabilityKeyBytes,
);
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(captured[1]!.bytes));
const provenance = provenanceProviderAttestationSchema.parse(parseJson(captured[2]!.bytes));
const reevaluated = evaluatePromotionEvidence({
candidate: archive.manifest,
currentDistSha256: archive.manifest.distSha256,
localStatus: capturedLocalStatus,
vulnerabilityReport: vulnerability,
provenanceAttestation: provenance,
vulnerabilityTrust: {
keyId: input.vulnerabilityKeyId,
publicKey: createPublicKey(
new TextDecoder("utf-8", { fatal: true }).decode(vulnerabilityPublicKey),
),
},
provenanceTrust: {
keyId: input.provenanceKeyId,
publicKey: createPublicKey(
new TextDecoder("utf-8", { fatal: true }).decode(provenancePublicKey),
),
const provenanceTrust = capturedTrust(
input.provenanceKeyId,
provenanceKeyBytes,
);
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
parseJson(vulnerabilityBytes),
);
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
parseJson(provenanceBytes),
);
const now = (dependencies.nowEpochMs ?? Date.now)();
const verifiedAt = new Date(now).toISOString();
const generated = await withVerifiedCapturedCandidate({
captured: capturedArchive,
verify: async ({ extractionRoot, manifest }) => {
const local = await verifyArchivedLocalEvidence({
extractionRoot,
expectedManifest: manifest,
});
if (local.status !== "PASS" || !local.identity) {
throw new Error(
`captured local evidence failed final verification: ${local.failures.join(", ")}`,
);
}
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
throw new Error("captured source revision differs from expected promotion revision");
}
const expected = {
run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt },
source: {
revision: local.identity.sourceRevision,
sourceSetSha256: local.identity.sourceSetSha256,
},
candidate: {
archiveSha256: capturedArchive.archiveSha256,
bundleSha256: manifest.bundleSha256,
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
},
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
provenanceInvocationNonce: input.provenanceInvocationNonce,
} as const;
const reevaluated = evaluatePromotionEvidence({
expected,
localStatus: local.status,
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust,
provenanceTrust,
nowEpochMs: () => now,
});
if (reevaluated.status !== "PASS") {
throw new Error(
`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`,
);
}
const providerEvidence = {
vulnerabilityReportSha256: sha256(vulnerabilityBytes),
provenanceAttestationSha256: sha256(provenanceBytes),
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
provenanceInvocationNonce: input.provenanceInvocationNonce,
vulnerabilityKeyId: vulnerabilityTrust.keyId,
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
provenanceKeyId: provenanceTrust.keyId,
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
} as const;
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
const common = {
schemaVersion: 3 as const,
verifiedAt,
status: "PASS" as const,
verifier: {
id: PROMOTION_VERIFIER_ID,
version: PROMOTION_VERIFIER_VERSION,
},
run: expected.run,
source: expected.source,
candidate: expected.candidate,
providerEvidence,
trustPolicySha256: trustDigest,
failures: [] as const,
};
const providerRecord = providerVerificationArtifactSchema.parse({
...common,
artifactType: "provider-verification",
vulnerabilityStatus: reevaluated.vulnerabilityStatus,
provenanceAttestationStatus: reevaluated.provenanceAttestationStatus,
});
const providerRecordBytes = canonicalJsonBytes(providerRecord);
const promotionRecord = providerVerificationArtifactSchema.parse({
...common,
artifactType: "promotion-verification",
localEvidenceStatus: local.status,
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
providerVerificationSha256: sha256(providerRecordBytes),
});
return Object.freeze({
providerRecordBytes,
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
});
},
});
if (reevaluated.status !== "PASS" || reevaluated.failures.length > 0) {
throw new Error(`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`);
}
const expectedBindings = {
candidateArchiveSha256: captured[0]!.digest,
vulnerabilityReportSha256: captured[1]!.digest,
provenanceAttestationSha256: captured[2]!.digest,
};
for (const [index, expectedArtifactType] of [
[3, "provider-verification"],
[4, "promotion-verification"],
] as const) {
const verification = providerVerificationArtifactSchema.parse(parseJson(captured[index]!.bytes));
if (verification.artifactType !== expectedArtifactType) {
throw new Error(
`${captured[index]!.destinationName} artifactType role mismatch: expected ${expectedArtifactType}`,
);
}
if (
verification.status !== reevaluated.status ||
verification.vulnerabilityStatus !== reevaluated.vulnerabilityStatus ||
verification.provenanceAttestationStatus !== reevaluated.provenanceAttestationStatus ||
verification.failures.length > 0
) {
throw new Error(`${captured[index]!.destinationName} status disagrees with trusted revalidation`);
}
if (verification.lockfileSha256 !== archive.manifest.lockfileSha256) {
throw new Error(`${captured[index]!.destinationName} lockfileSha256 digest mismatch`);
}
if (verification.distSha256 !== archive.manifest.distSha256) {
throw new Error(`${captured[index]!.destinationName} distSha256 digest mismatch`);
}
for (const [binding, expectedDigest] of Object.entries(expectedBindings) as ReadonlyArray<
readonly [keyof typeof expectedBindings, string]
>) {
if (verification[binding] !== expectedDigest) {
throw new Error(`${captured[index]!.destinationName} ${binding} digest mismatch`);
}
}
}
const stagedFiles: readonly StagedFile[] = captured;
const releaseRoot = path.join(root, ".release");
const releaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
const stagingRoot = path.join(releaseRoot, "promoted-staging");
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
const temporary = await mkdtemp(path.join(root, `.promoted-staging.${randomUUID()}.`));
let ownsTemporary = true;
const stagedFiles: readonly StagedFile[] = Object.freeze([
staged("release-candidate.tar.gz", capturedArchive.bytes),
staged("vulnerability-report.json", vulnerabilityBytes),
staged("provenance-attestation.json", provenanceBytes),
staged("provider-verification.json", generated.providerRecordBytes),
staged("promotion-verification.json", generated.promotionRecordBytes),
]);
if (
JSON.stringify(stagedFiles.map(({ name }) => name)) !==
JSON.stringify(PROMOTED_FILE_NAMES)
) {
throw new Error("promotion exact-five canonical file order drift");
}
await dependencies.beforePublish?.();
return publishPrivateStaging(
input.runnerTempRoot,
input.expectedRun,
stagedFiles,
dependencies.randomBytes ?? cryptoRandomBytes,
dependencies.afterStagingWrite,
);
}
export const stageVerifiedPromotion = finalizeVerifiedPromotion;
export async function cleanupFinalizedPromotion(input: Readonly<{
runnerTempRoot: string;
stagingRoot: string;
cleanupToken: string;
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
}>, dependencies: Readonly<{
beforeRemove?: () => Promise<void>;
}> = {}): Promise<void> {
const parent = path.resolve(input.runnerTempRoot);
const expected = path.join(parent, input.cleanupToken);
if (
!/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) ||
path.resolve(input.stagingRoot) !== expected ||
!Number.isSafeInteger(input.runnerTempIdentity.dev) ||
input.runnerTempIdentity.dev <= 0 ||
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
input.runnerTempIdentity.ino <= 0
) {
throw new TypeError("promotion cleanup root/token mismatch");
}
const parentHandle = await open(
parent,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
try {
for (const source of stagedFiles) {
const openedParent = await parentHandle.stat();
assertRunnerTempIdentity(openedParent, input.runnerTempIdentity);
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
const descriptorMetadata = await stat(descriptorRoot);
if (!descriptorMetadata.isDirectory()) {
throw new Error("descriptor-relative cleanup is unavailable");
}
const descriptorExpected = path.join(descriptorRoot, input.cleanupToken);
let metadata;
try {
metadata = await lstat(descriptorExpected);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return;
throw error;
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new TypeError("promotion cleanup leaf is unsafe");
}
await dependencies.beforeRemove?.();
const visibleParent = await lstat(parent);
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
await rm(descriptorExpected, { recursive: true, force: true });
const afterParent = await lstat(parent);
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
} finally {
await parentHandle.close();
}
}
async function publishPrivateStaging(
runnerTempRoot: string,
run: Readonly<{ id: string; attempt: number }>,
files: readonly StagedFile[],
randomBytes: (bytes: number) => Buffer,
afterStagingWrite?: () => Promise<void>,
): Promise<FinalizedPromotion> {
const parentPath = path.resolve(runnerTempRoot);
const before = await lstat(parentPath);
if (!before.isDirectory() || before.isSymbolicLink()) {
throw new TypeError("runner temporary root must be a real directory");
}
const parentHandle = await open(
parentPath,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
const tokenBytes = randomBytes(16);
if (tokenBytes.byteLength !== 16) {
await parentHandle.close();
throw new TypeError("promotion staging nonce must contain exactly 128 random bits");
}
const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run";
const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`;
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
const visibleStaging = path.join(parentPath, cleanupToken);
let ownsStaging = false;
try {
const procMetadata = await stat(descriptorRoot);
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
await mkdir(descriptorStaging, { mode: 0o700 });
ownsStaging = true;
for (const file of files) {
const handle = await open(
path.join(temporary, source.destinationName),
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
path.join(descriptorStaging, file.name),
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW,
0o400,
);
try {
await handle.writeFile(source.bytes);
await handle.writeFile(file.bytes);
await handle.sync();
} finally {
await handle.close();
}
}
await syncDirectory(temporary);
await dependencies.beforePublishRename?.();
const currentReleaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
await syncDirectory(descriptorStaging);
await syncHandle(parentHandle);
await afterStagingWrite?.();
const after = await lstat(parentPath);
if (
releaseIdentity.dev <= 0 ||
releaseIdentity.ino <= 0 ||
currentReleaseIdentity.dev !== releaseIdentity.dev ||
currentReleaseIdentity.ino !== releaseIdentity.ino
after.dev !== before.dev ||
after.ino !== before.ino ||
after.isSymbolicLink() ||
!after.isDirectory()
) {
throw new Error("promotion staging parent identity changed");
throw new Error("runner temporary parent identity changed during staging");
}
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
await rename(temporary, stagingRoot);
ownsTemporary = false;
await syncDirectory(releaseRoot);
const visible = await lstat(visibleStaging);
if (!visible.isDirectory() || visible.isSymbolicLink()) {
throw new Error("promotion staging visibility identity mismatch");
}
ownsStaging = false;
return Object.freeze({
stagingRoot: visibleStaging,
cleanupToken,
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
files: Object.freeze(
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
),
});
} finally {
if (ownsTemporary) await rm(temporary, { recursive: true, force: true });
if (ownsStaging) {
await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined);
}
await parentHandle.close();
}
return Object.freeze(
stagedFiles.map(({ destinationName, digest }) =>
Object.freeze({ path: `.release/promoted-staging/${destinationName}`, sha256: digest }),
),
}
function assertRunnerTempIdentity(
metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>,
expected: Readonly<{ dev: number; ino: number }>,
): void {
if (
metadata.dev !== expected.dev ||
metadata.ino !== expected.ino ||
!metadata.isDirectory() ||
metadata.isSymbolicLink?.()
) {
throw new Error("runner temporary parent identity changed during cleanup");
}
}
function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust {
const publicKey = createPublicKey(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
);
return Object.freeze({
keyId,
publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
});
}
async function capture(root: string, configuredPath: string, maxBytes: number): Promise<Buffer> {
const absolute = path.resolve(root, configuredPath);
const relative = path.relative(root, absolute);
const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
const outside =
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
return readBoundedRegularFile({
root: outside ? path.dirname(absolute) : root,
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
@@ -248,43 +389,43 @@ async function capture(root: string, configuredPath: string, maxBytes: number):
});
}
function parseJson(bytes: Buffer): unknown {
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
function staged(name: PromotedFileName, bytes: Buffer): StagedFile {
return Object.freeze({ name, bytes, sha256: sha256(bytes) });
}
function repositoryRelative(root: string, configuredPath: string): string {
const absolute = path.resolve(root, configuredPath);
const relative = path.relative(root, absolute);
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new TypeError(`promotion source escapes repository: ${configuredPath}`);
function canonicalJsonBytes(value: unknown): Buffer {
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function parseJson(bytes: Buffer): unknown {
try {
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
} catch {
throw new TypeError("captured provider evidence is not valid UTF-8 JSON");
}
return relative.replaceAll(path.sep, "/");
}
function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
async function exists(target: string): Promise<boolean> {
async function syncDirectory(directory: string): Promise<void> {
const handle = await open(
directory,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
try {
await lstat(target);
return true;
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
await syncHandle(handle);
} finally {
await handle.close();
}
}
async function syncDirectory(directory: string): Promise<void> {
const handle = await open(directory, constants.O_RDONLY);
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
try {
try {
await handle.sync();
} catch (error) {
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
}
} finally {
await handle.close();
await handle.sync();
} catch (error) {
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
}
}