d3ro-voice/scripts/ci/mobile-release-evidence-lib.mjs
2026-08-29 18:33:45 +09:00

737 lines
29 KiB
JavaScript

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 })
}