import { createHash, createPrivateKey, createPublicKey, randomBytes, sign as signBytes, verify as verifyBytes, } from 'node:crypto' import { closeSync, chmodSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, rmdirSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' export const RELEASE_EVIDENCE_SCHEMA_VERSION = 2 export const RELEASE_EVIDENCE_KIND = 'd3ro-android-production-release' export const RELEASE_PACKAGE_NAME = 'com.d3ro.voice' export const RELEASE_APK_NAME = 'app-release.apk' export const RELEASE_AAB_NAME = 'app-release.aab' export const SIGNED_EVIDENCE_NAME = 'release-artifact-evidence.json' export const TEST_ADMOB_APP_ID = 'ca-app-pub-3940256099942544~3347511713' export const COMPROMISED_SIGNER_SHA256 = '06eec757722ee7cd3dfbc53202d974aaf0417a7d397f9ef1e5611088ebb2e481' const MAX_EVIDENCE_BYTES = 1024 * 1024 const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024 const DEBUG_SIGNER_SHA256 = new Set([ 'fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c', ]) const HEX_SHA256 = /^[0-9a-f]{64}$/ const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/ const REPOSITORY_IDENTITY = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+$/ const DECIMAL_ID = /^[1-9][0-9]{0,39}$/ const VERSION_NAME = /^[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/ const PRODUCTION_ADMOB_APP_ID = /^ca-app-pub-\d+~\d+$/ const RELEASE_PROVENANCE_KEYS = [ 'bundletoolSha256', 'commitSha', 'gitRef', 'repository', 'runAttempt', 'runId', 'runnerIdentity', 'treeSha', 'verifierSha256', 'workflowIdentity', ] export function releaseBoundaryFail(code) { throw new Error(`mobile_release_boundary:${code}`) } function isPlainObject(value) { if (value === null || typeof value !== 'object' || Array.isArray(value)) return false const prototype = Object.getPrototypeOf(value) return prototype === Object.prototype || prototype === null } function requireExactKeys(value, expected, label) { if (!isPlainObject(value)) releaseBoundaryFail(`${label}_must_be_object`) const actual = Object.keys(value).sort() const wanted = [...expected].sort() if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { releaseBoundaryFail(`${label}_keys_invalid`) } } function canonicalValue(value) { if (value === null || typeof value === 'string' || typeof value === 'boolean') return value if (typeof value === 'number') { if (!Number.isFinite(value)) releaseBoundaryFail('canonical_number_invalid') return value } if (Array.isArray(value)) return value.map(canonicalValue) if (!isPlainObject(value)) releaseBoundaryFail('canonical_value_invalid') return Object.fromEntries( Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]), ) } export function canonicalJson(value) { return JSON.stringify(canonicalValue(value)) } export function sha256Buffer(buffer) { return createHash('sha256').update(buffer).digest('hex') } export function normalizeSha256(value, label) { if (typeof value !== 'string') releaseBoundaryFail(`${label}_missing`) const normalized = value.replaceAll(':', '').trim().toLowerCase() if (!HEX_SHA256.test(normalized)) releaseBoundaryFail(`${label}_invalid`) return normalized } function normalizeBoundedIdentity(value, label, maximumLength = 256) { if (typeof value !== 'string' || value.length === 0 || value.length > maximumLength || value !== value.trim() || [...value].some((character) => { const codePoint = character.codePointAt(0) return codePoint <= 0x1f || codePoint === 0x7f })) { releaseBoundaryFail(`${label}_invalid`) } return value } function normalizeRepository(value, label) { const normalized = normalizeBoundedIdentity(value, label) if (!REPOSITORY_IDENTITY.test(normalized) || normalized.includes('//') || normalized.split('/').some((segment) => segment === '.' || segment === '..')) { releaseBoundaryFail(`${label}_invalid`) } return normalized } function normalizeGitObjectId(value, label) { if (typeof value !== 'string' || !GIT_OBJECT_ID.test(value)) { releaseBoundaryFail(`${label}_invalid`) } return value } function normalizeGitRef(value, label) { const normalized = normalizeBoundedIdentity(value, label) if (!normalized.startsWith('refs/') || normalized.endsWith('/') || normalized.endsWith('.') || normalized.includes('..') || normalized.includes('//') || normalized.includes('@{') || normalized.includes('\\') || [...' ~^:?*[]'].some((character) => normalized.includes(character)) || normalized.split('/').some((segment) => segment === '' || segment === '.' || segment === '..' || segment.endsWith('.lock'))) { releaseBoundaryFail(`${label}_invalid`) } return normalized } function normalizeRunId(value, label) { if (typeof value !== 'string' || !DECIMAL_ID.test(value)) { releaseBoundaryFail(`${label}_invalid`) } return value } function normalizeRunAttempt(value, label) { if (typeof value !== 'number' && (typeof value !== 'string' || !DECIMAL_ID.test(value))) { releaseBoundaryFail(`${label}_invalid`) } const number = typeof value === 'number' ? value : Number(value) if (!Number.isSafeInteger(number) || number <= 0 || number > 1_000_000) { releaseBoundaryFail(`${label}_invalid`) } return number } export function validateReleaseProvenance(provenance, label = 'provenance') { requireExactKeys(provenance, RELEASE_PROVENANCE_KEYS, label) return { repository: normalizeRepository(provenance.repository, `${label}_repository`), commitSha: normalizeGitObjectId(provenance.commitSha, `${label}_commit_sha`), treeSha: normalizeGitObjectId(provenance.treeSha, `${label}_tree_sha`), gitRef: normalizeGitRef(provenance.gitRef, `${label}_git_ref`), workflowIdentity: normalizeBoundedIdentity( provenance.workflowIdentity, `${label}_workflow_identity`, ), runId: normalizeRunId(provenance.runId, `${label}_run_id`), runAttempt: normalizeRunAttempt(provenance.runAttempt, `${label}_run_attempt`), runnerIdentity: normalizeBoundedIdentity( provenance.runnerIdentity, `${label}_runner_identity`, ), verifierSha256: normalizeSha256( provenance.verifierSha256, `${label}_verifier_sha256`, ), bundletoolSha256: normalizeSha256( provenance.bundletoolSha256, `${label}_bundletool_sha256`, ), } } function rejectCompromisedSigner(value, code) { if (value === COMPROMISED_SIGNER_SHA256) releaseBoundaryFail(code) return value } function stableStatValue(stat, key, fallbackKey) { if (key in stat) return stat[key] return BigInt(Math.trunc(Number(stat[fallbackKey]) * 1_000_000)) } function sameFileSnapshot(left, right) { // Windows lstat reports st_dev as zero while fstat reports the volume id. // The stable inode plus size/timestamps remain comparable on that platform. return (process.platform === 'win32' || left.dev === right.dev) && left.ino === right.ino && left.size === right.size && stableStatValue(left, 'mtimeNs', 'mtimeMs') === stableStatValue(right, 'mtimeNs', 'mtimeMs') && stableStatValue(left, 'ctimeNs', 'ctimeMs') === stableStatValue(right, 'ctimeNs', 'ctimeMs') } function sameFileIdentity(left, right) { return (process.platform === 'win32' || left.dev === right.dev) && left.ino === right.ino } function openRegularFileNoFollow(filePath, maximumBytes = MAX_ARTIFACT_BYTES) { const absolute = resolve(filePath) const pathStat = lstatSync(absolute, { bigint: true }) if (pathStat.isSymbolicLink()) releaseBoundaryFail(`symlink_rejected_${basename(absolute)}`) if (realpathSync(absolute) !== absolute) releaseBoundaryFail(`reparse_rejected_${basename(absolute)}`) if (!pathStat.isFile()) releaseBoundaryFail(`regular_file_required_${basename(absolute)}`) if (pathStat.nlink !== 1n) releaseBoundaryFail(`hardlink_rejected_${basename(absolute)}`) if (pathStat.size <= 0n || pathStat.size > BigInt(maximumBytes)) { releaseBoundaryFail(`file_size_invalid_${basename(absolute)}`) } const noFollow = fsConstants.O_NOFOLLOW ?? 0 const fd = openSync(absolute, fsConstants.O_RDONLY | noFollow) const openedStat = fstatSync(fd, { bigint: true }) if (!openedStat.isFile() || !sameFileSnapshot(pathStat, openedStat)) { closeSync(fd) releaseBoundaryFail(`file_identity_changed_${basename(absolute)}`) } return { absolute, fd, openedStat } } export function readSmallFileStable(filePath, maximumBytes = MAX_EVIDENCE_BYTES) { const opened = openRegularFileNoFollow(filePath, maximumBytes) try { const buffer = readFileSync(opened.fd) const after = fstatSync(opened.fd, { bigint: true }) if (!sameFileSnapshot(opened.openedStat, after) || BigInt(buffer.length) !== after.size) { releaseBoundaryFail(`file_changed_while_reading_${basename(opened.absolute)}`) } return buffer } finally { closeSync(opened.fd) } } export function hashRegularFileStable(filePath) { const opened = openRegularFileNoFollow(filePath) try { const hash = createHash('sha256') const chunk = Buffer.allocUnsafe(1024 * 1024) let total = 0n for (;;) { const count = readSync(opened.fd, chunk, 0, chunk.length, null) if (count === 0) break total += BigInt(count) hash.update(chunk.subarray(0, count)) } const after = fstatSync(opened.fd, { bigint: true }) if (!sameFileSnapshot(opened.openedStat, after) || total !== after.size) { releaseBoundaryFail(`file_changed_while_hashing_${basename(opened.absolute)}`) } return { sha256: hash.digest('hex'), bytes: Number(total) } } finally { closeSync(opened.fd) } } function assertPathWithinRoot(rootPath, candidatePath, expectedName) { const unresolvedRoot = resolve(rootPath) const unresolvedRootStat = lstatSync(unresolvedRoot) if (!unresolvedRootStat.isDirectory() || unresolvedRootStat.isSymbolicLink()) { releaseBoundaryFail('source_root_invalid') } const root = realpathSync(unresolvedRoot) const rootStat = lstatSync(root) if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) releaseBoundaryFail('source_root_invalid') const candidate = resolve(candidatePath) if (basename(candidate) !== expectedName) releaseBoundaryFail(`${expectedName}_name_invalid`) const relativePath = relative(root, candidate) if (relativePath === '' || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { releaseBoundaryFail(`${expectedName}_outside_source_root`) } const realCandidate = realpathSync(candidate) const realRelative = relative(root, realCandidate) if (realRelative === '' || realRelative === '..' || realRelative.startsWith(`..${sep}`) || isAbsolute(realRelative)) { releaseBoundaryFail(`${expectedName}_realpath_outside_source_root`) } if (basename(realCandidate) !== expectedName) releaseBoundaryFail(`${expectedName}_real_name_invalid`) return realCandidate } function normalizeVersionCode(value, label = 'version_code') { const number = typeof value === 'number' ? value : Number(value) if (!Number.isSafeInteger(number) || number <= 0 || number > 2_100_000_000) { releaseBoundaryFail(`${label}_invalid`) } return number } function normalizeVersionName(value, label = 'version_name') { if (typeof value !== 'string' || !VERSION_NAME.test(value)) { releaseBoundaryFail(`${label}_invalid`) } return value } function normalizeProductionAdMobId(value) { if (typeof value !== 'string' || !PRODUCTION_ADMOB_APP_ID.test(value)) { releaseBoundaryFail('production_admob_app_id_invalid') } if (value === TEST_ADMOB_APP_ID || value.startsWith('ca-app-pub-3940256099942544')) { releaseBoundaryFail('test_admob_rejected') } return value } function normalizeReleaseArtifact(value, expectedFileName, label) { requireExactKeys(value, ['bytes', 'fileName', 'sha256'], label) if (value.fileName !== expectedFileName || basename(value.fileName) !== value.fileName) { releaseBoundaryFail(`${label}_file_name_invalid`) } const bytes = Number(value.bytes) if (!Number.isSafeInteger(bytes) || bytes <= 0 || bytes > MAX_ARTIFACT_BYTES) { releaseBoundaryFail(`${label}_bytes_invalid`) } return { fileName: expectedFileName, sha256: normalizeSha256(value.sha256, `${label}_sha256`), bytes, } } export function validateReleasePayload(payload) { requireExactKeys(payload, [ 'aab', 'adMobAppId', 'apk', 'debuggable', 'mode', 'packageName', 'provenance', 'signerSha256', 'verificationSha256', 'verifier', 'versionCode', 'versionName', ], 'payload') if (payload.mode !== 'release') releaseBoundaryFail('nonrelease_mode_rejected') if (payload.packageName !== RELEASE_PACKAGE_NAME) releaseBoundaryFail('package_name_mismatch') if (payload.debuggable !== false) releaseBoundaryFail('debuggable_release_rejected') if (payload.verifier !== 'scripts/ci/verify-android-artifact.mjs') { releaseBoundaryFail('verifier_identity_invalid') } const signerSha256 = normalizeSha256(payload.signerSha256, 'signer_sha256') if (DEBUG_SIGNER_SHA256.has(signerSha256)) releaseBoundaryFail('debug_signer_rejected') rejectCompromisedSigner(signerSha256, 'compromised_signer_rejected') return { mode: 'release', packageName: RELEASE_PACKAGE_NAME, debuggable: false, versionName: normalizeVersionName(payload.versionName), versionCode: normalizeVersionCode(payload.versionCode), adMobAppId: normalizeProductionAdMobId(payload.adMobAppId), signerSha256, provenance: validateReleaseProvenance(payload.provenance), verifier: 'scripts/ci/verify-android-artifact.mjs', verificationSha256: normalizeSha256(payload.verificationSha256, 'verification_sha256'), apk: normalizeReleaseArtifact(payload.apk, RELEASE_APK_NAME, 'apk'), aab: normalizeReleaseArtifact(payload.aab, RELEASE_AAB_NAME, 'aab'), } } function requireReleaseVerification(verification, expected) { if (!isPlainObject(verification)) releaseBoundaryFail('verification_must_be_object') if (verification.mode !== 'release') releaseBoundaryFail('verification_nonrelease_mode') if (verification.packageName !== RELEASE_PACKAGE_NAME) releaseBoundaryFail('verification_package_mismatch') if (verification.debuggable !== false) releaseBoundaryFail('verification_debuggable_release') const versionName = normalizeVersionName(verification.versionName, 'verification_version_name') const versionCode = normalizeVersionCode(verification.versionCode, 'verification_version_code') const adMobAppId = normalizeProductionAdMobId(verification.adMobAppId) const signerSha256 = normalizeSha256(verification.signerSha256, 'verification_signer_sha256') if (DEBUG_SIGNER_SHA256.has(signerSha256)) releaseBoundaryFail('verification_debug_signer') rejectCompromisedSigner(signerSha256, 'verification_compromised_signer') if (verification.artifact !== RELEASE_APK_NAME) releaseBoundaryFail('verification_apk_name') if (!isPlainObject(verification.aab) || verification.aab.artifact !== RELEASE_AAB_NAME) { releaseBoundaryFail('verification_aab_name') } if (verification.aab.packageName !== RELEASE_PACKAGE_NAME) { releaseBoundaryFail('verification_aab_package_mismatch') } if (!Array.isArray(verification.abis) || verification.abis.length !== 1 || verification.abis[0] !== 'arm64-v8a') { releaseBoundaryFail('verification_apk_abis') } if (!Array.isArray(verification.aab.abis) || verification.aab.abis.length !== 1 || verification.aab.abis[0] !== 'arm64-v8a') { releaseBoundaryFail('verification_aab_abis') } const expectedVersionName = normalizeVersionName(expected.versionName, 'expected_version_name') const expectedVersionCode = normalizeVersionCode(expected.versionCode, 'expected_version_code') const expectedAdMobAppId = normalizeProductionAdMobId(expected.adMobAppId) const expectedSigner = normalizeSha256(expected.signerSha256, 'expected_signer_sha256') rejectCompromisedSigner(expectedSigner, 'expected_compromised_signer') if (versionName !== expectedVersionName) releaseBoundaryFail('expected_version_name_mismatch') if (versionCode !== expectedVersionCode) releaseBoundaryFail('expected_version_code_mismatch') if (adMobAppId !== expectedAdMobAppId) releaseBoundaryFail('expected_admob_app_id_mismatch') if (signerSha256 !== expectedSigner) releaseBoundaryFail('expected_signer_mismatch') if (verification.aab.versionName !== versionName) releaseBoundaryFail('aab_apk_version_name_mismatch') if (normalizeVersionCode(verification.aab.versionCode, 'aab_version_code') !== versionCode) { releaseBoundaryFail('aab_apk_version_code_mismatch') } if (normalizeProductionAdMobId(verification.aab.adMobAppId) !== adMobAppId) { releaseBoundaryFail('aab_apk_admob_app_id_mismatch') } if (verification.aab.debuggable !== false) releaseBoundaryFail('verification_aab_debuggable') const aabSignerSha256 = normalizeSha256(verification.aab.signerSha256, 'aab_signer_sha256') rejectCompromisedSigner(aabSignerSha256, 'verification_aab_compromised_signer') if (aabSignerSha256 !== signerSha256) { releaseBoundaryFail('aab_apk_signer_mismatch') } return { versionName, versionCode, adMobAppId, signerSha256 } } export function buildReleasePayload({ verification, apkPath, aabPath, expected, provenance }) { const normalized = requireReleaseVerification(verification, expected) const apk = hashRegularFileStable(apkPath) const aab = hashRegularFileStable(aabPath) if (normalizeSha256(verification.apkSha256, 'verification_apk_sha256') !== apk.sha256) { releaseBoundaryFail('verification_apk_hash_mismatch') } if (normalizeSha256(verification.aab.sha256, 'verification_aab_sha256') !== aab.sha256) { releaseBoundaryFail('verification_aab_hash_mismatch') } return validateReleasePayload({ mode: 'release', packageName: RELEASE_PACKAGE_NAME, debuggable: false, versionName: normalized.versionName, versionCode: normalized.versionCode, adMobAppId: normalized.adMobAppId, signerSha256: normalized.signerSha256, provenance, verifier: 'scripts/ci/verify-android-artifact.mjs', verificationSha256: sha256Buffer(Buffer.from(canonicalJson(verification))), apk: { fileName: RELEASE_APK_NAME, sha256: apk.sha256, bytes: apk.bytes }, aab: { fileName: RELEASE_AAB_NAME, sha256: aab.sha256, bytes: aab.bytes }, }) } function publicKeyFingerprint(key) { const publicKey = key?.type === 'public' ? key : createPublicKey(key) if (publicKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_key_required') return sha256Buffer(publicKey.export({ format: 'der', type: 'spki' })) } export function signReleaseEvidence(payload, privateKeyPem) { const normalizedPayload = validateReleasePayload(payload) const privateKey = createPrivateKey(privateKeyPem) if (privateKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_private_key_required') const unsigned = { schemaVersion: RELEASE_EVIDENCE_SCHEMA_VERSION, kind: RELEASE_EVIDENCE_KIND, payload: normalizedPayload, } const signature = signBytes(null, Buffer.from(canonicalJson(unsigned)), privateKey) return { ...unsigned, signature: { algorithm: 'Ed25519', keyId: publicKeyFingerprint(privateKey), value: signature.toString('base64'), }, } } export function verifyReleaseEvidence(envelope, publicKeyPem) { requireExactKeys(envelope, ['kind', 'payload', 'schemaVersion', 'signature'], 'evidence') if (envelope.schemaVersion !== RELEASE_EVIDENCE_SCHEMA_VERSION) { releaseBoundaryFail('evidence_schema_version') } if (envelope.kind !== RELEASE_EVIDENCE_KIND) releaseBoundaryFail('evidence_kind') requireExactKeys(envelope.signature, ['algorithm', 'keyId', 'value'], 'signature') if (envelope.signature.algorithm !== 'Ed25519') releaseBoundaryFail('signature_algorithm') const publicKey = createPublicKey(publicKeyPem) if (publicKey.asymmetricKeyType !== 'ed25519') releaseBoundaryFail('ed25519_public_key_required') const expectedKeyId = publicKeyFingerprint(publicKey) if (normalizeSha256(envelope.signature.keyId, 'signature_key_id') !== expectedKeyId) { releaseBoundaryFail('signature_key_mismatch') } let signature try { signature = Buffer.from(envelope.signature.value, 'base64') } catch { releaseBoundaryFail('signature_encoding') } if (signature.length !== 64) releaseBoundaryFail('signature_length') const unsigned = { schemaVersion: envelope.schemaVersion, kind: envelope.kind, payload: envelope.payload, } if (!verifyBytes(null, Buffer.from(canonicalJson(unsigned)), publicKey, signature)) { releaseBoundaryFail('signature_invalid') } return validateReleasePayload(envelope.payload) } export function verifyExpectedRelease(payload, expected) { const normalized = validateReleasePayload(payload) if (normalized.versionName !== normalizeVersionName(expected.versionName, 'expected_version_name')) { releaseBoundaryFail('publication_version_name_mismatch') } if (normalized.versionCode !== normalizeVersionCode(expected.versionCode, 'expected_version_code')) { releaseBoundaryFail('publication_version_code_mismatch') } if (normalized.adMobAppId !== normalizeProductionAdMobId(expected.adMobAppId)) { releaseBoundaryFail('publication_admob_app_id_mismatch') } const expectedSigner = normalizeSha256(expected.signerSha256, 'expected_signer_sha256') rejectCompromisedSigner(expectedSigner, 'publication_expected_compromised_signer') if (normalized.signerSha256 !== expectedSigner) { releaseBoundaryFail('publication_signer_mismatch') } const expectedProvenance = validateReleaseProvenance( expected.provenance, 'expected_provenance', ) for (const key of RELEASE_PROVENANCE_KEYS) { if (normalized.provenance[key] !== expectedProvenance[key]) { const code = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) releaseBoundaryFail(`publication_provenance_${code}_mismatch`) } } return normalized } function snapshotArtifact(sourcePath, destinationDirectory, expected) { const directoryIdentity = lstatSync(destinationDirectory, { bigint: true }) const opened = openRegularFileNoFollow(sourcePath) const temporaryName = `.${expected.fileName}.${randomBytes(16).toString('hex')}.tmp` const temporaryPath = join(destinationDirectory, temporaryName) const destinationPath = join(destinationDirectory, expected.fileName) let outputFd = -1 let completed = false try { outputFd = openSync( temporaryPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600, ) const hash = createHash('sha256') const chunk = Buffer.allocUnsafe(1024 * 1024) let total = 0n for (;;) { const count = readSync(opened.fd, chunk, 0, chunk.length, null) if (count === 0) break writeSync(outputFd, chunk, 0, count) hash.update(chunk.subarray(0, count)) total += BigInt(count) } fsyncSync(outputFd) closeSync(outputFd) outputFd = -1 const sourceAfter = fstatSync(opened.fd, { bigint: true }) if (!sameFileSnapshot(opened.openedStat, sourceAfter) || total !== sourceAfter.size) { releaseBoundaryFail(`source_changed_during_snapshot_${expected.fileName}`) } if (Number(total) !== expected.bytes) releaseBoundaryFail(`${expected.fileName}_size_mismatch`) if (hash.digest('hex') !== expected.sha256) releaseBoundaryFail(`${expected.fileName}_hash_mismatch`) copyFileSync(temporaryPath, destinationPath, fsConstants.COPYFILE_EXCL) const directoryAfterCreate = lstatSync(destinationDirectory, { bigint: true }) if (!sameFileIdentity(directoryIdentity, directoryAfterCreate) || realpathSync(destinationDirectory) !== destinationDirectory) { releaseBoundaryFail('destination_directory_replaced') } const copied = hashRegularFileStable(destinationPath) if (copied.bytes !== expected.bytes || copied.sha256 !== expected.sha256) { releaseBoundaryFail(`${expected.fileName}_sealed_copy_mismatch`) } chmodSync(destinationPath, 0o400) completed = true return destinationPath } finally { closeSync(opened.fd) if (outputFd >= 0) closeSync(outputFd) if (existsSync(temporaryPath)) unlinkSync(temporaryPath) if (!completed && existsSync(destinationPath)) unlinkSync(destinationPath) } } function safeCreateDestination(destinationDirectory) { const destination = resolve(destinationDirectory) const unresolvedParent = dirname(destination) const parentStat = lstatSync(unresolvedParent, { bigint: true }) if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) { releaseBoundaryFail('destination_parent_invalid') } const parent = realpathSync(unresolvedParent) if (destination === parent || basename(destination) === '' || basename(destination) === '.' || basename(destination) === '..') { releaseBoundaryFail('destination_directory_too_broad') } if (existsSync(destination)) releaseBoundaryFail('destination_must_not_exist') mkdirSync(destination, { recursive: false, mode: 0o700 }) const created = realpathSync(destination) if (dirname(created) !== parent) releaseBoundaryFail('destination_parent_mismatch') return created } export function createImmutableVerificationSnapshot({ apkPath, aabPath, destinationDirectory, verifierPath, bundletoolPath, }) { if (basename(resolve(apkPath)) !== RELEASE_APK_NAME) releaseBoundaryFail('snapshot_apk_name_invalid') if (basename(resolve(aabPath)) !== RELEASE_AAB_NAME) releaseBoundaryFail('snapshot_aab_name_invalid') const includesTools = verifierPath !== undefined || bundletoolPath !== undefined if (includesTools && (verifierPath === undefined || bundletoolPath === undefined)) { releaseBoundaryFail('snapshot_tool_pair_required') } const expectedApk = { fileName: RELEASE_APK_NAME, ...hashRegularFileStable(apkPath) } const expectedAab = { fileName: RELEASE_AAB_NAME, ...hashRegularFileStable(aabPath) } const expectedVerifier = includesTools ? { fileName: 'verify-android-artifact.mjs', ...hashRegularFileStable(verifierPath) } : null const expectedBundletool = includesTools ? { fileName: 'bundletool.jar', ...hashRegularFileStable(bundletoolPath) } : null const destination = safeCreateDestination(destinationDirectory) const createdFiles = [] try { const apk = snapshotArtifact(apkPath, destination, expectedApk) createdFiles.push(apk) const aab = snapshotArtifact(aabPath, destination, expectedAab) createdFiles.push(aab) if (!includesTools) return { destination, apkPath: apk, aabPath: aab } const verifier = snapshotArtifact(verifierPath, destination, expectedVerifier) createdFiles.push(verifier) const bundletool = snapshotArtifact(bundletoolPath, destination, expectedBundletool) createdFiles.push(bundletool) return { destination, apkPath: apk, aabPath: aab, verifierPath: verifier, bundletoolPath: bundletool, } } catch (error) { cleanupCreatedDestination(destination, createdFiles) throw error } } function cleanupCreatedDestination(destination, createdFiles) { for (const file of [...createdFiles].reverse()) { if (existsSync(file) && dirname(file) === destination) unlinkSync(file) } if (existsSync(destination)) rmdirSync(destination) } export function prepareVerifiedReleasePublication({ sourceRoot, apkPath, aabPath, evidencePath, publicKeyPath, destinationDirectory, expected, }) { const apk = assertPathWithinRoot(sourceRoot, apkPath, RELEASE_APK_NAME) const aab = assertPathWithinRoot(sourceRoot, aabPath, RELEASE_AAB_NAME) const evidence = assertPathWithinRoot(sourceRoot, evidencePath, SIGNED_EVIDENCE_NAME) const evidenceBuffer = readSmallFileStable(evidence) let envelope try { envelope = JSON.parse(evidenceBuffer.toString('utf8')) } catch { releaseBoundaryFail('evidence_json_invalid') } const publicKeyPem = readSmallFileStable(publicKeyPath, 64 * 1024) const payload = verifyExpectedRelease( verifyReleaseEvidence(envelope, publicKeyPem), expected, ) const destination = safeCreateDestination(destinationDirectory) const createdFiles = [] try { createdFiles.push(snapshotArtifact(apk, destination, payload.apk)) createdFiles.push(snapshotArtifact(aab, destination, payload.aab)) const sealedEvidence = join(destination, 'android-release-evidence.json') writeFileSync(sealedEvidence, evidenceBuffer, { flag: 'wx', mode: 0o600 }) createdFiles.push(sealedEvidence) const manifest = { schemaVersion: 2, kind: 'd3ro-android-publication-set', packageName: payload.packageName, versionName: payload.versionName, versionCode: payload.versionCode, signerSha256: payload.signerSha256, adMobAppId: payload.adMobAppId, evidenceKeyId: envelope.signature.keyId, provenance: payload.provenance, artifacts: [payload.apk, payload.aab], } const manifestPath = join(destination, 'android-publication-manifest.json') writeFileSync(manifestPath, `${canonicalJson(manifest)}\n`, { flag: 'wx', mode: 0o600 }) createdFiles.push(manifestPath) return { destination, manifest, files: createdFiles.map((file) => basename(file)) } } catch (error) { cleanupCreatedDestination(destination, createdFiles) throw error } } export function writeJsonCreateOnly(filePath, value) { const absolute = resolve(filePath) if (!existsSync(dirname(absolute))) releaseBoundaryFail('output_parent_missing') writeFileSync(absolute, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) }