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

403 lines
15 KiB
JavaScript

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(import.meta.dirname, '../..')
const DEFAULT_GOOGLE_SERVICES_PATH = resolve(
ROOT,
'apps/mobile-rn/android/app/google-services.json',
)
const PRODUCTION_PACKAGE_NAME = 'com.d3ro.voice'
const GOOGLE_TEST_ADMOB_PUBLISHER_ID = '3940256099942544'
const FIREBASE_PROJECT_ID = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/
const FIREBASE_PROJECT_NUMBER = /^[1-9][0-9]{5,19}$/
const FIREBASE_MOBILESDK_APP_ID = /^1:([1-9][0-9]{5,19}):android:([0-9a-f]{16,64})$/
const ANDROID_PACKAGE_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/i
const GOOGLE_API_KEY = /^AIza[0-9A-Za-z_-]{20,96}$/
const ADMOB_APP_ID = /^ca-app-pub-([0-9]{16})~([0-9]{10})$/
const ADMOB_UNIT_ID = /^ca-app-pub-([0-9]{16})\/([0-9]{10})$/
const MAX_GOOGLE_SERVICES_BYTES = 1024 * 1024
function fail(code) {
throw new Error(`mobile_release_config_invalid:${code}`)
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function requireExactString(value, code) {
if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) fail(code)
return value
}
function requireExpectedFirebaseIdentity(expected) {
if (!isRecord(expected)) fail('firebase_expected_identity_missing')
const projectId = requireExactString(
expected.projectId,
'firebase_expected_project_id_missing',
)
const projectNumber = requireExactString(
expected.projectNumber,
'firebase_expected_project_number_missing',
)
const mobileSdkAppId = requireExactString(
expected.mobileSdkAppId,
'firebase_expected_mobilesdk_app_id_missing',
)
if (!FIREBASE_PROJECT_ID.test(projectId)) fail('firebase_expected_project_id_format')
if (!FIREBASE_PROJECT_NUMBER.test(projectNumber)) {
fail('firebase_expected_project_number_format')
}
const appIdMatch = mobileSdkAppId.match(FIREBASE_MOBILESDK_APP_ID)
if (!appIdMatch) fail('firebase_expected_mobilesdk_app_id_format')
if (appIdMatch[1] !== projectNumber) {
fail('firebase_expected_mobilesdk_app_id_project_number_mismatch')
}
return { projectId, projectNumber, mobileSdkAppId }
}
export function parseGoogleServicesSource(source) {
if (typeof source !== 'string' || source.length === 0) fail('google_services_empty')
if (Buffer.byteLength(source, 'utf8') > MAX_GOOGLE_SERVICES_BYTES) {
fail('google_services_too_large')
}
try {
return JSON.parse(source)
} catch {
fail('google_services_invalid_json')
}
}
export function verifyFirebaseConfiguration(configuration, expectedIdentity) {
const expected = requireExpectedFirebaseIdentity(expectedIdentity)
if (!isRecord(configuration)) fail('google_services_root_invalid')
if (configuration.configuration_version !== '1') {
fail('google_services_configuration_version_invalid')
}
const projectInfo = configuration.project_info
if (!isRecord(projectInfo)) fail('firebase_project_info_missing')
const projectId = requireExactString(projectInfo.project_id, 'firebase_project_id_missing')
const projectNumber = requireExactString(
projectInfo.project_number,
'firebase_project_number_missing',
)
if (!FIREBASE_PROJECT_ID.test(projectId)) fail('firebase_project_id_format')
if (!FIREBASE_PROJECT_NUMBER.test(projectNumber)) fail('firebase_project_number_format')
if (projectId !== expected.projectId) fail('firebase_project_id_mismatch')
if (projectNumber !== expected.projectNumber) fail('firebase_project_number_mismatch')
const clients = configuration.client
if (!Array.isArray(clients) || clients.length === 0) fail('firebase_clients_missing')
if (clients.length > 100) fail('firebase_client_count_excessive')
const packages = new Set()
const appIds = new Set()
let productionClient
for (const client of clients) {
if (!isRecord(client)) fail('firebase_client_invalid')
const clientInfo = client.client_info
if (!isRecord(clientInfo)) fail('firebase_client_info_missing')
const androidClientInfo = clientInfo.android_client_info
if (!isRecord(androidClientInfo)) fail('firebase_android_client_info_missing')
const packageName = requireExactString(
androidClientInfo.package_name,
'firebase_client_package_missing',
)
const mobileSdkAppId = requireExactString(
clientInfo.mobilesdk_app_id,
'firebase_client_mobilesdk_app_id_missing',
)
if (!ANDROID_PACKAGE_NAME.test(packageName)) fail('firebase_client_package_format')
const appIdMatch = mobileSdkAppId.match(FIREBASE_MOBILESDK_APP_ID)
if (!appIdMatch) fail('firebase_client_mobilesdk_app_id_format')
if (appIdMatch[1] !== projectNumber) {
fail('firebase_client_mobilesdk_app_id_project_number_mismatch')
}
if (packages.has(packageName)) fail('firebase_client_package_duplicate')
if (appIds.has(mobileSdkAppId)) fail('firebase_client_mobilesdk_app_id_duplicate')
packages.add(packageName)
appIds.add(mobileSdkAppId)
if (packageName === PRODUCTION_PACKAGE_NAME) productionClient = client
}
if (!productionClient) fail('firebase_production_client_missing')
if (productionClient.client_info.mobilesdk_app_id !== expected.mobileSdkAppId) {
fail('firebase_mobilesdk_app_id_mismatch')
}
const apiKeys = productionClient.api_key
if (!Array.isArray(apiKeys) || apiKeys.length !== 1) {
fail('firebase_production_api_key_must_be_unique')
}
if (!isRecord(apiKeys[0])) fail('firebase_production_api_key_invalid')
const apiKey = requireExactString(
apiKeys[0].current_key,
'firebase_production_api_key_missing',
)
if (!GOOGLE_API_KEY.test(apiKey)) fail('firebase_production_api_key_format')
return Object.freeze({
packageNameVerified: true,
projectIdentityVerified: true,
clientIdentityUnique: true,
apiKeyUnique: true,
})
}
function parseAdMobId(value, expression, missingCode, formatCode) {
const normalized = requireExactString(value, missingCode)
const match = normalized.match(expression)
if (!match) fail(formatCode)
if (/^0+$/.test(match[1]) || /^0+$/.test(match[2])) fail(formatCode)
return { value: normalized, publisherId: match[1] }
}
export function verifyAdMobConfiguration(configuration) {
if (!isRecord(configuration)) fail('admob_configuration_missing')
const app = parseAdMobId(
configuration.appId,
ADMOB_APP_ID,
'admob_app_id_missing',
'admob_app_id_format',
)
const banner = parseAdMobId(
configuration.bannerUnitId,
ADMOB_UNIT_ID,
'admob_banner_unit_id_missing',
'admob_banner_unit_id_format',
)
const rewarded = parseAdMobId(
configuration.rewardedUnitId,
ADMOB_UNIT_ID,
'admob_rewarded_unit_id_missing',
'admob_rewarded_unit_id_format',
)
for (const entry of [app, banner, rewarded]) {
if (entry.publisherId === GOOGLE_TEST_ADMOB_PUBLISHER_ID) {
fail('admob_google_test_publisher_rejected')
}
}
if (app.publisherId !== banner.publisherId || app.publisherId !== rewarded.publisherId) {
fail('admob_publisher_mismatch')
}
if (banner.value === rewarded.value) fail('admob_unit_ids_must_be_unique')
return Object.freeze({
productionFormatVerified: true,
googleTestPublisherRejected: true,
publisherConsistencyVerified: true,
unitIdentityUnique: true,
})
}
function clone(value) {
return structuredClone(value)
}
function expectFailure(label, action, expectedCode) {
try {
action()
} catch (error) {
const expectedMessage = `mobile_release_config_invalid:${expectedCode}`
if (error instanceof Error && error.message === expectedMessage) return
throw new Error(`mobile_release_config_self_test_unexpected:${label}`)
}
throw new Error(`mobile_release_config_self_test_missed:${label}`)
}
function makeFirebaseFixture() {
return {
configuration_version: '1',
project_info: {
project_number: '123456789012',
project_id: 'd3ro-production',
storage_bucket: 'd3ro-production.example.invalid',
},
client: [
{
client_info: {
mobilesdk_app_id: '1:123456789012:android:0123456789abcdef',
android_client_info: { package_name: PRODUCTION_PACKAGE_NAME },
},
api_key: [
{ current_key: ['AI', 'zaFixtureKeyMaterial1234567890abcd'].join('') },
],
},
],
}
}
function runSelfTest() {
const expected = {
projectId: 'd3ro-production',
projectNumber: '123456789012',
mobileSdkAppId: '1:123456789012:android:0123456789abcdef',
}
const firebase = makeFirebaseFixture()
const admob = {
appId: 'ca-app-pub-1234567890123456~1234567890',
bannerUnitId: 'ca-app-pub-1234567890123456/2345678901',
rewardedUnitId: 'ca-app-pub-1234567890123456/3456789012',
}
verifyFirebaseConfiguration(firebase, expected)
verifyAdMobConfiguration(admob)
parseGoogleServicesSource(JSON.stringify(firebase))
const firebaseCases = [
['invalid_json', () => parseGoogleServicesSource('{'), 'google_services_invalid_json'],
['root_array', () => verifyFirebaseConfiguration([], expected), 'google_services_root_invalid'],
['configuration_version', () => {
const value = clone(firebase)
value.configuration_version = '2'
return verifyFirebaseConfiguration(value, expected)
}, 'google_services_configuration_version_invalid'],
['project_id_mismatch', () => {
const value = clone(firebase)
value.project_info.project_id = 'different-production'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_project_id_mismatch'],
['project_number_mismatch', () => {
const value = clone(firebase)
value.project_info.project_number = '223456789012'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_project_number_mismatch'],
['missing_clients', () => {
const value = clone(firebase)
value.client = []
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_clients_missing'],
['duplicate_package', () => {
const value = clone(firebase)
const duplicate = clone(value.client[0])
duplicate.client_info.mobilesdk_app_id = '1:123456789012:android:fedcba9876543210'
value.client.push(duplicate)
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_client_package_duplicate'],
['duplicate_app_id', () => {
const value = clone(firebase)
const duplicate = clone(value.client[0])
duplicate.client_info.android_client_info.package_name = 'com.d3ro.voice.other'
value.client.push(duplicate)
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_client_mobilesdk_app_id_duplicate'],
['production_package_missing', () => {
const value = clone(firebase)
value.client[0].client_info.android_client_info.package_name = 'com.d3ro.voice.other'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_production_client_missing'],
['app_id_mismatch', () => {
const value = clone(firebase)
value.client[0].client_info.mobilesdk_app_id = '1:123456789012:android:fedcba9876543210'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_mobilesdk_app_id_mismatch'],
['app_id_project_number_mismatch', () => {
const value = clone(firebase)
value.client[0].client_info.mobilesdk_app_id = '1:223456789012:android:0123456789abcdef'
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_client_mobilesdk_app_id_project_number_mismatch'],
['api_key_missing', () => {
const value = clone(firebase)
value.client[0].api_key = []
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_production_api_key_must_be_unique'],
['api_key_ambiguous', () => {
const value = clone(firebase)
value.client[0].api_key.push(clone(value.client[0].api_key[0]))
return verifyFirebaseConfiguration(value, expected)
}, 'firebase_production_api_key_must_be_unique'],
['expected_identity_missing', () => verifyFirebaseConfiguration(firebase, {}), 'firebase_expected_project_id_missing'],
['expected_app_id_project_number_mismatch', () => verifyFirebaseConfiguration(firebase, {
...expected,
mobileSdkAppId: '1:223456789012:android:0123456789abcdef',
}), 'firebase_expected_mobilesdk_app_id_project_number_mismatch'],
]
for (const [label, action, expectedCode] of firebaseCases) {
expectFailure(label, action, expectedCode)
}
const admobCases = [
['app_format', { ...admob, appId: 'ca-app-pub-123~456' }, 'admob_app_id_format'],
['banner_format', { ...admob, bannerUnitId: 'ca-app-pub-1234567890123456~2345678901' }, 'admob_banner_unit_id_format'],
['rewarded_format', { ...admob, rewardedUnitId: 'ca-app-pub-1234567890123456/123' }, 'admob_rewarded_unit_id_format'],
['app_test_publisher', {
appId: 'ca-app-pub-3940256099942544~3347511713',
bannerUnitId: 'ca-app-pub-3940256099942544/6300978111',
rewardedUnitId: 'ca-app-pub-3940256099942544/5224354917',
}, 'admob_google_test_publisher_rejected'],
['banner_test_publisher', {
...admob,
bannerUnitId: 'ca-app-pub-3940256099942544/6300978111',
}, 'admob_google_test_publisher_rejected'],
['publisher_mismatch', {
...admob,
rewardedUnitId: 'ca-app-pub-2234567890123456/3456789012',
}, 'admob_publisher_mismatch'],
['unit_duplicate', { ...admob, rewardedUnitId: admob.bannerUnitId }, 'admob_unit_ids_must_be_unique'],
['whitespace', { ...admob, appId: `${admob.appId} ` }, 'admob_app_id_missing'],
['zero_identifier', {
...admob,
bannerUnitId: 'ca-app-pub-1234567890123456/0000000000',
}, 'admob_banner_unit_id_format'],
]
for (const [label, configuration, expectedCode] of admobCases) {
expectFailure(label, () => verifyAdMobConfiguration(configuration), expectedCode)
}
return { firebaseFailureCases: firebaseCases.length, adMobFailureCases: admobCases.length }
}
async function run() {
if (process.argv.includes('--self-test')) {
const result = runSelfTest()
console.log(JSON.stringify({ selfTest: 'verified', ...result }))
return
}
if (process.argv.length > 2) fail('unknown_argument')
let source
try {
source = await readFile(
process.env.D3RO_GOOGLE_SERVICES_JSON_FILE || DEFAULT_GOOGLE_SERVICES_PATH,
'utf8',
)
} catch {
fail('google_services_file_unreadable')
}
const firebase = verifyFirebaseConfiguration(parseGoogleServicesSource(source), {
projectId: process.env.D3RO_FIREBASE_EXPECTED_PROJECT_ID,
projectNumber: process.env.D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER,
mobileSdkAppId: process.env.D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID,
})
const adMob = verifyAdMobConfiguration({
appId: process.env.D3RO_ADMOB_APP_ID,
bannerUnitId: process.env.D3RO_ADMOB_BANNER_UNIT_ID,
rewardedUnitId: process.env.D3RO_ADMOB_REWARDED_UNIT_ID,
})
console.log(JSON.stringify({
releaseConfiguration: 'verified',
firebase,
adMob,
}))
}
const isMain = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
if (isMain) {
run().catch((error) => {
console.error(error instanceof Error ? error.message : 'mobile_release_config_invalid:unknown')
process.exitCode = 1
})
}